mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-01 07:26:38 +00:00
Merge branch 'multi-role' into dev
This commit is contained in:
@@ -1,56 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import { FormDescription } from "@app/components/ui/form";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Control, FieldValues, Path } from "react-hook-form";
|
||||
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";
|
||||
|
||||
type Role = {
|
||||
roleId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AutoProvisionConfigWidgetProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
type AutoProvisionConfigWidgetProps = {
|
||||
autoProvision: boolean;
|
||||
onAutoProvisionChange: (checked: boolean) => void;
|
||||
roleMappingMode: "role" | "expression";
|
||||
onRoleMappingModeChange: (mode: "role" | "expression") => void;
|
||||
roleMappingMode: RoleMappingMode;
|
||||
onRoleMappingModeChange: (mode: RoleMappingMode) => void;
|
||||
roles: Role[];
|
||||
roleIdFieldName: Path<T>;
|
||||
roleMappingFieldName: Path<T>;
|
||||
fixedRoleNames: string[];
|
||||
onFixedRoleNamesChange: (roleNames: string[]) => void;
|
||||
mappingBuilderClaimPath: string;
|
||||
onMappingBuilderClaimPathChange: (claimPath: string) => void;
|
||||
mappingBuilderRules: MappingBuilderRule[];
|
||||
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
|
||||
rawExpression: string;
|
||||
onRawExpressionChange: (expression: string) => void;
|
||||
};
|
||||
|
||||
export default function AutoProvisionConfigWidget<T extends FieldValues>({
|
||||
control,
|
||||
export default function AutoProvisionConfigWidget({
|
||||
autoProvision,
|
||||
onAutoProvisionChange,
|
||||
roleMappingMode,
|
||||
onRoleMappingModeChange,
|
||||
roles,
|
||||
roleIdFieldName,
|
||||
roleMappingFieldName
|
||||
}: AutoProvisionConfigWidgetProps<T>) {
|
||||
fixedRoleNames,
|
||||
onFixedRoleNamesChange,
|
||||
mappingBuilderClaimPath,
|
||||
onMappingBuilderClaimPathChange,
|
||||
mappingBuilderRules,
|
||||
onMappingBuilderRulesChange,
|
||||
rawExpression,
|
||||
onRawExpressionChange
|
||||
}: AutoProvisionConfigWidgetProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
return (
|
||||
@@ -63,114 +58,26 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{autoProvision && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<FormLabel className="mb-2">
|
||||
{t("roleMapping")}
|
||||
</FormLabel>
|
||||
<FormDescription className="mb-4">
|
||||
{t("roleMappingDescription")}
|
||||
</FormDescription>
|
||||
|
||||
<RadioGroup
|
||||
value={roleMappingMode}
|
||||
onValueChange={onRoleMappingModeChange}
|
||||
className="flex space-x-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="role" id="role-mode" />
|
||||
<label
|
||||
htmlFor="role-mode"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("selectRole")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="expression"
|
||||
id="expression-mode"
|
||||
/>
|
||||
<label
|
||||
htmlFor="expression-mode"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("roleMappingExpression")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{roleMappingMode === "role" ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name={roleIdFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectRolePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("selectRoleDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name={roleMappingFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
defaultValue={field.value || ""}
|
||||
value={field.value || ""}
|
||||
placeholder={t(
|
||||
"roleMappingExpressionPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("roleMappingExpressionDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
29
src/components/IdpAutoProvisionUsersDescription.tsx
Normal file
29
src/components/IdpAutoProvisionUsersDescription.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const AUTO_PROVISION_DOCS_URL =
|
||||
"https://docs.pangolin.net/manage/identity-providers/auto-provisioning";
|
||||
|
||||
type IdpAutoProvisionUsersDescriptionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function IdpAutoProvisionUsersDescription({
|
||||
className
|
||||
}: IdpAutoProvisionUsersDescriptionProps) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<span className={className}>
|
||||
{t("idpAutoProvisionUsersDescription")}{" "}
|
||||
<a
|
||||
href={AUTO_PROVISION_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("learnMore")}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -163,9 +164,6 @@ export function IdpCreateWizard({
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/navigation";
|
||||
import UserRoleBadges from "@app/components/UserRoleBadges";
|
||||
|
||||
export type InvitationRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
roleLabels: string[];
|
||||
roleIds: number[];
|
||||
};
|
||||
|
||||
type InvitationsTableProps = {
|
||||
@@ -90,9 +90,13 @@ export default function InvitationsTable({
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
id: "roles",
|
||||
accessorFn: (row) => row.roleLabels.join(", "),
|
||||
friendlyName: t("role"),
|
||||
header: () => <span className="p-3">{t("role")}</span>
|
||||
header: () => <span className="p-3">{t("role")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<UserRoleBadges roleLabels={row.original.roleLabels} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "dots",
|
||||
|
||||
117
src/components/OrgRolesTagField.tsx
Normal file
117
src/components/OrgRolesTagField.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type RoleTag = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
|
||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||
name?: Path<TFieldValues>;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
allRoleOptions: Tag[];
|
||||
supportsMultipleRolesPerUser: boolean;
|
||||
showMultiRolePaywallMessage: boolean;
|
||||
paywallMessage: string;
|
||||
loading?: boolean;
|
||||
activeTagIndex: number | null;
|
||||
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
|
||||
};
|
||||
|
||||
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
form,
|
||||
name = "roles" as Path<TFieldValues>,
|
||||
label,
|
||||
placeholder,
|
||||
allRoleOptions,
|
||||
supportsMultipleRolesPerUser,
|
||||
showMultiRolePaywallMessage,
|
||||
paywallMessage,
|
||||
loading = false,
|
||||
activeTagIndex,
|
||||
setActiveTagIndex
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
|
||||
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
|
||||
const prev = form.getValues(name) as Tag[];
|
||||
const nextValue =
|
||||
typeof updater === "function" ? updater(prev) : updater;
|
||||
const next = supportsMultipleRolesPerUser
|
||||
? nextValue
|
||||
: nextValue.length > 1
|
||||
? [nextValue[nextValue.length - 1]]
|
||||
: nextValue;
|
||||
|
||||
if (
|
||||
!supportsMultipleRolesPerUser &&
|
||||
next.length === 0 &&
|
||||
prev.length > 0
|
||||
) {
|
||||
form.setValue(name, [prev[prev.length - 1]] as never, {
|
||||
shouldDirty: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (next.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(name, next as never, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
tags={field.value}
|
||||
setTags={setRoleTags}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoleOptions}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMultiRolePaywallMessage && (
|
||||
<FormDescription>{paywallMessage}</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,8 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionListRole")]: "listRoles",
|
||||
[t("actionUpdateRole")]: "updateRole",
|
||||
[t("actionListAllowedRoleResources")]: "listRoleResources",
|
||||
[t("actionAddUserRole")]: "addUserRole"
|
||||
[t("actionAddUserRole")]: "addUserRole",
|
||||
[t("actionRemoveUserRole")]: "removeUserRole"
|
||||
},
|
||||
"Access Token": {
|
||||
[t("actionGenerateAccessToken")]: "generateAccessToken",
|
||||
|
||||
@@ -32,15 +32,15 @@ type RegenerateInvitationFormProps = {
|
||||
invitation: {
|
||||
id: string;
|
||||
email: string;
|
||||
roleId: number;
|
||||
role: string;
|
||||
roleIds: number[];
|
||||
roleLabels: string[];
|
||||
} | null;
|
||||
onRegenerate: (updatedInvitation: {
|
||||
id: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
roleLabels: string[];
|
||||
roleIds: number[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({
|
||||
try {
|
||||
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
|
||||
email: invitation.email,
|
||||
roleId: invitation.roleId,
|
||||
roleIds: invitation.roleIds,
|
||||
validHours,
|
||||
sendEmail,
|
||||
regenerate: true
|
||||
@@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({
|
||||
onRegenerate({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
expiresAt: res.data.data.expiresAt,
|
||||
role: invitation.role,
|
||||
roleId: invitation.roleId
|
||||
expiresAt: new Date(
|
||||
res.data.data.expiresAt
|
||||
).toISOString(),
|
||||
roleLabels: invitation.roleLabels,
|
||||
roleIds: invitation.roleIds
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
471
src/components/RoleMappingConfigFields.tsx
Normal file
471
src/components/RoleMappingConfigFields.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { FormLabel, FormDescription } from "@app/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
createMappingBuilderRule,
|
||||
MappingBuilderRule,
|
||||
RoleMappingMode
|
||||
} from "@app/lib/idpRoleMapping";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type RoleMappingRoleOption = {
|
||||
roleId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type RoleMappingConfigFieldsProps = {
|
||||
roleMappingMode: RoleMappingMode;
|
||||
onRoleMappingModeChange: (mode: RoleMappingMode) => void;
|
||||
roles: RoleMappingRoleOption[];
|
||||
fixedRoleNames: string[];
|
||||
onFixedRoleNamesChange: (roleNames: string[]) => void;
|
||||
mappingBuilderClaimPath: string;
|
||||
onMappingBuilderClaimPathChange: (claimPath: string) => void;
|
||||
mappingBuilderRules: MappingBuilderRule[];
|
||||
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
|
||||
rawExpression: string;
|
||||
onRawExpressionChange: (expression: string) => void;
|
||||
/** Unique prefix for radio `id`/`htmlFor` when multiple instances exist on one page. */
|
||||
fieldIdPrefix?: string;
|
||||
/** When true, show extra hint for global default policies (no org role list). */
|
||||
showFreeformRoleNamesHint?: boolean;
|
||||
};
|
||||
|
||||
export default function RoleMappingConfigFields({
|
||||
roleMappingMode,
|
||||
onRoleMappingModeChange,
|
||||
roles,
|
||||
fixedRoleNames,
|
||||
onFixedRoleNamesChange,
|
||||
mappingBuilderClaimPath,
|
||||
onMappingBuilderClaimPathChange,
|
||||
mappingBuilderRules,
|
||||
onMappingBuilderRulesChange,
|
||||
rawExpression,
|
||||
onRawExpressionChange,
|
||||
fieldIdPrefix = "role-mapping",
|
||||
showFreeformRoleNamesHint = false
|
||||
}: RoleMappingConfigFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
|
||||
const showSingleRoleDisclaimer =
|
||||
!env.flags.disableEnterpriseFeatures &&
|
||||
!isPaidUser(tierMatrix.fullRbac);
|
||||
|
||||
const restrictToOrgRoles = roles.length > 0;
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
roles.map((role) => ({
|
||||
id: role.name,
|
||||
text: role.name
|
||||
})),
|
||||
[roles]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!supportsMultipleRolesPerUser &&
|
||||
mappingBuilderRules.length > 1
|
||||
) {
|
||||
onMappingBuilderRulesChange([mappingBuilderRules[0]]);
|
||||
}
|
||||
}, [
|
||||
supportsMultipleRolesPerUser,
|
||||
mappingBuilderRules,
|
||||
onMappingBuilderRulesChange
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
|
||||
onFixedRoleNamesChange([fixedRoleNames[0]]);
|
||||
}
|
||||
}, [
|
||||
supportsMultipleRolesPerUser,
|
||||
fixedRoleNames,
|
||||
onFixedRoleNamesChange
|
||||
]);
|
||||
|
||||
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
|
||||
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
|
||||
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
|
||||
|
||||
const mappingBuilderShowsRemoveColumn =
|
||||
supportsMultipleRolesPerUser || mappingBuilderRules.length > 1;
|
||||
|
||||
/** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
|
||||
const mappingRulesGridClass = mappingBuilderShowsRemoveColumn
|
||||
? "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3"
|
||||
: "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] md:gap-x-3";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
|
||||
<FormDescription className="mb-4">
|
||||
{t("roleMappingDescription")}
|
||||
</FormDescription>
|
||||
|
||||
<RadioGroup
|
||||
value={roleMappingMode}
|
||||
onValueChange={onRoleMappingModeChange}
|
||||
className="flex flex-wrap gap-x-6 gap-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="fixedRoles" id={fixedRadioId} />
|
||||
<label
|
||||
htmlFor={fixedRadioId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("roleMappingModeFixedRoles")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="mappingBuilder"
|
||||
id={builderRadioId}
|
||||
/>
|
||||
<label
|
||||
htmlFor={builderRadioId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("roleMappingModeMappingBuilder")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="rawExpression" id={rawRadioId} />
|
||||
<label
|
||||
htmlFor={rawRadioId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("roleMappingModeRawExpression")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{showSingleRoleDisclaimer && (
|
||||
<FormDescription className="mt-3">
|
||||
{build === "saas"
|
||||
? t("singleRolePerUserPlanNotice")
|
||||
: t("singleRolePerUserEditionNotice")}
|
||||
</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roleMappingMode === "fixedRoles" && (
|
||||
<div className="space-y-2 min-w-0 max-w-full">
|
||||
<TagInput
|
||||
tags={fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevTags = fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}));
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevTags)
|
||||
: nextTags;
|
||||
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
fixedRoleNames.length > 0
|
||||
) {
|
||||
onFixedRoleNamesChange([
|
||||
fixedRoleNames[
|
||||
fixedRoleNames.length - 1
|
||||
]!
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onFixedRoleNamesChange(names);
|
||||
}}
|
||||
activeTagIndex={activeFixedRoleTagIndex}
|
||||
setActiveTagIndex={setActiveFixedRoleTagIndex}
|
||||
placeholder={
|
||||
restrictToOrgRoles
|
||||
? t("roleMappingFixedRolesPlaceholderSelect")
|
||||
: t("roleMappingFixedRolesPlaceholderFreeform")
|
||||
}
|
||||
enableAutocomplete={restrictToOrgRoles}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
/>
|
||||
<FormDescription>
|
||||
{showFreeformRoleNamesHint
|
||||
? t("roleMappingFixedRolesDescriptionDefaultPolicy")
|
||||
: t("roleMappingFixedRolesDescriptionSameForAll")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleMappingMode === "mappingBuilder" && (
|
||||
<div className="space-y-4 min-w-0 max-w-full">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("roleMappingClaimPath")}</FormLabel>
|
||||
<Input
|
||||
value={mappingBuilderClaimPath}
|
||||
onChange={(e) =>
|
||||
onMappingBuilderClaimPathChange(e.target.value)
|
||||
}
|
||||
placeholder={t("roleMappingClaimPathPlaceholder")}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("roleMappingClaimPathDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={`hidden ${mappingRulesGridClass} md:items-end`}
|
||||
>
|
||||
<FormLabel className="min-w-0">
|
||||
{t("roleMappingMatchValue")}
|
||||
</FormLabel>
|
||||
<FormLabel className="min-w-0">
|
||||
{t("roleMappingAssignRoles")}
|
||||
</FormLabel>
|
||||
{mappingBuilderShowsRemoveColumn ? (
|
||||
<span aria-hidden className="min-w-0" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mappingBuilderRules.map((rule, index) => (
|
||||
<BuilderRuleRow
|
||||
key={rule.id ?? `mapping-rule-${index}`}
|
||||
mappingRulesGridClass={mappingRulesGridClass}
|
||||
fieldIdPrefix={`${fieldIdPrefix}-rule-${index}`}
|
||||
roleOptions={roleOptions}
|
||||
restrictToOrgRoles={restrictToOrgRoles}
|
||||
showFreeformRoleNamesHint={
|
||||
showFreeformRoleNamesHint
|
||||
}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
showRemoveButton={mappingBuilderShowsRemoveColumn}
|
||||
rule={rule}
|
||||
onChange={(nextRule) => {
|
||||
const nextRules = mappingBuilderRules.map(
|
||||
(row, i) =>
|
||||
i === index ? nextRule : row
|
||||
);
|
||||
onMappingBuilderRulesChange(nextRules);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const nextRules =
|
||||
mappingBuilderRules.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
onMappingBuilderRulesChange(
|
||||
nextRules.length
|
||||
? nextRules
|
||||
: [createMappingBuilderRule()]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{supportsMultipleRolesPerUser ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onMappingBuilderRulesChange([
|
||||
...mappingBuilderRules,
|
||||
createMappingBuilderRule()
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("roleMappingAddMappingRule")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleMappingMode === "rawExpression" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={rawExpression}
|
||||
onChange={(e) => onRawExpressionChange(e.target.value)}
|
||||
placeholder={t("roleMappingExpressionPlaceholder")}
|
||||
/>
|
||||
<FormDescription>
|
||||
{supportsMultipleRolesPerUser
|
||||
? t("roleMappingRawExpressionResultDescription")
|
||||
: t(
|
||||
"roleMappingRawExpressionResultDescriptionSingleRole"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderRuleRow({
|
||||
rule,
|
||||
roleOptions,
|
||||
restrictToOrgRoles,
|
||||
showFreeformRoleNamesHint,
|
||||
fieldIdPrefix,
|
||||
mappingRulesGridClass,
|
||||
supportsMultipleRolesPerUser,
|
||||
showRemoveButton,
|
||||
onChange,
|
||||
onRemove
|
||||
}: {
|
||||
rule: MappingBuilderRule;
|
||||
roleOptions: Tag[];
|
||||
restrictToOrgRoles: boolean;
|
||||
showFreeformRoleNamesHint: boolean;
|
||||
fieldIdPrefix: string;
|
||||
mappingRulesGridClass: string;
|
||||
supportsMultipleRolesPerUser: boolean;
|
||||
showRemoveButton: boolean;
|
||||
onChange: (rule: MappingBuilderRule) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-3 min-w-0 ${mappingRulesGridClass} md:items-start`}
|
||||
>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<FormLabel className="text-xs md:hidden">
|
||||
{t("roleMappingMatchValue")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${fieldIdPrefix}-match`}
|
||||
value={rule.matchValue}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...rule,
|
||||
matchValue: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder={t("roleMappingMatchValuePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 min-w-0 w-full max-w-full">
|
||||
<FormLabel className="text-xs md:hidden">
|
||||
{t("roleMappingAssignRoles")}
|
||||
</FormLabel>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<TagInput
|
||||
tags={rule.roleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevRoleTags = rule.roleNames.map(
|
||||
(name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
})
|
||||
);
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevRoleTags)
|
||||
: nextTags;
|
||||
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
rule.roleNames.length > 0
|
||||
) {
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: [
|
||||
rule.roleNames[
|
||||
rule.roleNames.length - 1
|
||||
]!
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: names
|
||||
});
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={
|
||||
restrictToOrgRoles
|
||||
? t("roleMappingAssignRoles")
|
||||
: t("roleMappingAssignRolesPlaceholderFreeform")
|
||||
}
|
||||
enableAutocomplete={restrictToOrgRoles}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
styleClasses={{
|
||||
inlineTagsContainer: "min-w-0 max-w-full"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showFreeformRoleNamesHint && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("roleMappingBuilderFreeformRowHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showRemoveButton ? (
|
||||
<div className="flex min-w-0 justify-end md:justify-start md:pt-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 shrink-0 px-2"
|
||||
onClick={onRemove}
|
||||
>
|
||||
{t("roleMappingRemoveRule")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/UserRoleBadges.tsx
Normal file
69
src/components/UserRoleBadges.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge, badgeVariants } from "@app/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const MAX_ROLE_BADGES = 3;
|
||||
|
||||
export default function UserRoleBadges({
|
||||
roleLabels
|
||||
}: {
|
||||
roleLabels: string[];
|
||||
}) {
|
||||
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
|
||||
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((label, i) => (
|
||||
<Badge key={`${label}-${i}`} variant="secondary">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<OverflowRolesPopover labels={overflow} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverflowRolesPopover({ labels }: { labels: string[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
badgeVariants({ variant: "secondary" }),
|
||||
"border-dashed"
|
||||
)}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
+{labels.length}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-auto max-w-xs p-2"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{labels.map((label, i) => (
|
||||
<li key={`${label}-${i}`}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "./IdpTypeBadge";
|
||||
import UserRoleBadges from "./UserRoleBadges";
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
@@ -36,7 +37,7 @@ export type UserRow = {
|
||||
type: string;
|
||||
idpVariant: string | null;
|
||||
status: string;
|
||||
role: string;
|
||||
roleLabels: string[];
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
@@ -124,7 +125,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
id: "role",
|
||||
accessorFn: (row) => row.roleLabels.join(", "),
|
||||
friendlyName: t("role"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
@@ -140,13 +142,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{userRow.role}</span>
|
||||
</div>
|
||||
);
|
||||
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
87
src/components/idp/OidcIdpProviderTypeSelect.tsx
Normal file
87
src/components/idp/OidcIdpProviderTypeSelect.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
StrategySelect,
|
||||
type StrategyOption
|
||||
} from "@app/components/StrategySelect";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
type Props = {
|
||||
value: IdpOidcProviderType;
|
||||
onTypeChange: (type: IdpOidcProviderType) => void;
|
||||
};
|
||||
|
||||
export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const hideTemplates = env.flags.disableEnterpriseFeatures;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideTemplates && (value === "google" || value === "azure")) {
|
||||
onTypeChange("oidc");
|
||||
}
|
||||
}, [hideTemplates, value, onTypeChange]);
|
||||
|
||||
const options: ReadonlyArray<StrategyOption<IdpOidcProviderType>> =
|
||||
useMemo(() => {
|
||||
const base: StrategyOption<IdpOidcProviderType>[] = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: t("idpOidcDescription")
|
||||
}
|
||||
];
|
||||
if (hideTemplates) {
|
||||
return base;
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
id: "google",
|
||||
title: t("idpGoogleTitle"),
|
||||
description: t("idpGoogleDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt={t("idpGoogleAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "azure",
|
||||
title: t("idpAzureTitle"),
|
||||
description: t("idpAzureDescription"),
|
||||
icon: (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt={t("idpAzureAlt")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [hideTemplates, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">{t("idpType")}</span>
|
||||
</div>
|
||||
<StrategySelect
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onTypeChange}
|
||||
cols={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "../ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "../ui/popover";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
type AutocompleteProps = {
|
||||
tags: TagType[];
|
||||
@@ -20,6 +33,8 @@ type AutocompleteProps = {
|
||||
inlineTags?: boolean;
|
||||
classStyleProps: TagInputStyleClassesProps["autoComplete"];
|
||||
usePortal?: boolean;
|
||||
/** Narrows the dropdown list from the main field (cmdk search filters further). */
|
||||
filterQuery?: string;
|
||||
};
|
||||
|
||||
export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
@@ -35,10 +50,10 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
inlineTags,
|
||||
children,
|
||||
classStyleProps,
|
||||
usePortal
|
||||
usePortal,
|
||||
filterQuery = ""
|
||||
}) => {
|
||||
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const t = useTranslations();
|
||||
@@ -46,17 +61,21 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
const [commandResetKey, setCommandResetKey] = useState(0);
|
||||
|
||||
// Dynamically calculate the top position for the popover content
|
||||
useEffect(() => {
|
||||
if (!triggerContainerRef.current || !triggerRef.current) return;
|
||||
setPopoverContentTop(
|
||||
triggerContainerRef.current?.getBoundingClientRect().bottom -
|
||||
triggerRef.current?.getBoundingClientRect().bottom
|
||||
const visibleOptions = useMemo(() => {
|
||||
const q = filterQuery.trim().toLowerCase();
|
||||
if (!q) return autocompleteOptions;
|
||||
return autocompleteOptions.filter((option) =>
|
||||
option.text.toLowerCase().includes(q)
|
||||
);
|
||||
}, [tags]);
|
||||
}, [autocompleteOptions, filterQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setCommandResetKey((k) => k + 1);
|
||||
}
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
// Close the popover when clicking outside of it
|
||||
useEffect(() => {
|
||||
@@ -135,36 +154,6 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
if (userOnBlur) userOnBlur(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isPopoverOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prevIndex) =>
|
||||
prevIndex <= 0
|
||||
? autocompleteOptions.length - 1
|
||||
: prevIndex - 1
|
||||
);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prevIndex) =>
|
||||
prevIndex === autocompleteOptions.length - 1
|
||||
? 0
|
||||
: prevIndex + 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (selectedIndex !== -1) {
|
||||
toggleTag(autocompleteOptions[selectedIndex]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (option: TagType) => {
|
||||
// Check if the tag already exists in the array
|
||||
const index = tags.findIndex((tag) => tag.text === option.text);
|
||||
@@ -197,18 +186,25 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const childrenWithProps = React.cloneElement(
|
||||
children as React.ReactElement<any>,
|
||||
{
|
||||
onKeyDown: handleKeyDown,
|
||||
onFocus: handleInputFocus,
|
||||
onBlur: handleInputBlur,
|
||||
ref: inputRef
|
||||
const child = children as React.ReactElement<
|
||||
React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
}
|
||||
);
|
||||
>;
|
||||
const userOnKeyDown = child.props.onKeyDown;
|
||||
|
||||
const childrenWithProps = React.cloneElement(child, {
|
||||
onKeyDown: userOnKeyDown,
|
||||
onFocus: handleInputFocus,
|
||||
onBlur: handleInputBlur,
|
||||
ref: inputRef
|
||||
} as Partial<
|
||||
React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
}
|
||||
>);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -222,132 +218,105 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
onOpenChange={handleOpenChange}
|
||||
modal={usePortal}
|
||||
>
|
||||
<div
|
||||
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||
ref={triggerContainerRef}
|
||||
>
|
||||
{childrenWithProps}
|
||||
<PopoverTrigger asChild ref={triggerRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
|
||||
classStyleProps?.popoverTrigger
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsPopoverOpen(!isPopoverOpen);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||
ref={triggerContainerRef}
|
||||
>
|
||||
{childrenWithProps}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
|
||||
classStyleProps?.popoverTrigger
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsPopoverOpen(!isPopoverOpen);
|
||||
}}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
ref={popoverContentRef}
|
||||
side="bottom"
|
||||
align="start"
|
||||
forceMount
|
||||
className={cn(
|
||||
`p-0 relative`,
|
||||
"p-0",
|
||||
classStyleProps?.popoverContent
|
||||
)}
|
||||
style={{
|
||||
top: `${popooverContentTop}px`,
|
||||
marginLeft: `calc(-${popoverWidth}px + 36px)`,
|
||||
width: `${popoverWidth}px`,
|
||||
minWidth: `${popoverWidth}px`,
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<Command
|
||||
key={commandResetKey}
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
classStyleProps?.commandList
|
||||
"rounded-lg border-0 shadow-none",
|
||||
classStyleProps?.command
|
||||
)}
|
||||
style={{
|
||||
minHeight: "68px"
|
||||
}}
|
||||
key={autocompleteOptions.length}
|
||||
>
|
||||
{autocompleteOptions.length > 0 ? (
|
||||
<div
|
||||
key={autocompleteOptions.length}
|
||||
role="group"
|
||||
className={cn(
|
||||
"overflow-y-auto overflow-hidden p-1 text-foreground",
|
||||
classStyleProps?.commandGroup
|
||||
)}
|
||||
style={{
|
||||
minHeight: "68px"
|
||||
}}
|
||||
<CommandInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList
|
||||
className={cn(
|
||||
"max-h-[300px]",
|
||||
classStyleProps?.commandList
|
||||
)}
|
||||
>
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
<CommandGroup
|
||||
className={classStyleProps?.commandGroup}
|
||||
>
|
||||
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
|
||||
Suggestions
|
||||
</span>
|
||||
<div role="separator" className="py-0.5" />
|
||||
{autocompleteOptions.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
{visibleOptions.map((option) => {
|
||||
const isChosen = tags.some(
|
||||
(tag) => tag.text === option.text
|
||||
);
|
||||
return (
|
||||
<div
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
|
||||
isSelected &&
|
||||
"bg-accent text-accent-foreground",
|
||||
classStyleProps?.commandItem
|
||||
)}
|
||||
data-value={option.text}
|
||||
onClick={() => toggleTag(option)}
|
||||
value={`${option.text} ${option.id}`}
|
||||
onSelect={() => toggleTag(option)}
|
||||
className={classStyleProps?.commandItem}
|
||||
>
|
||||
<div className="w-full flex items-center gap-2">
|
||||
{option.text}
|
||||
{tags.some(
|
||||
(tag) =>
|
||||
tag.text === option.text
|
||||
) && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-check"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
isChosen
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
{option.text}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm">
|
||||
{t("noResults")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
@@ -434,14 +434,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
// const filteredAutocompleteOptions = autocompleteFilter
|
||||
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
|
||||
// : autocompleteOptions;
|
||||
const filteredAutocompleteOptions = useMemo(() => {
|
||||
return (autocompleteOptions || []).filter((option) =>
|
||||
option.text
|
||||
.toLowerCase()
|
||||
.includes(inputValue ? inputValue.toLowerCase() : "")
|
||||
);
|
||||
}, [inputValue, autocompleteOptions]);
|
||||
|
||||
const displayedTags = sortTags ? [...tags].sort() : tags;
|
||||
|
||||
const truncatedTags = truncate
|
||||
@@ -571,9 +563,9 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
setInputValue={setInputValue}
|
||||
autocompleteOptions={
|
||||
filteredAutocompleteOptions as Tag[]
|
||||
}
|
||||
autocompleteOptions={(autocompleteOptions ||
|
||||
[]) as Tag[]}
|
||||
filterQuery={inputValue}
|
||||
setTagCount={setTagCount}
|
||||
maxTags={maxTags}
|
||||
onTagAdd={onTagAdd}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "../ui/popover";
|
||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||
import { TagList, TagListProps } from "./tag-list";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -33,33 +38,27 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
|
||||
...tagProps
|
||||
}) => {
|
||||
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [sideOffset, setSideOffset] = useState<number>(0);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (triggerContainerRef.current && triggerRef.current) {
|
||||
if (triggerContainerRef.current) {
|
||||
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||
setSideOffset(
|
||||
triggerContainerRef.current.offsetWidth -
|
||||
triggerRef?.current?.offsetWidth
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize(); // Call on mount and layout changes
|
||||
handleResize();
|
||||
|
||||
window.addEventListener("resize", handleResize); // Adjust on window resize
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [triggerContainerRef, triggerRef]);
|
||||
}, []);
|
||||
|
||||
// Close the popover when clicking outside of it
|
||||
useEffect(() => {
|
||||
@@ -135,52 +134,54 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
|
||||
onOpenChange={handleOpenChange}
|
||||
modal={usePortal}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||
ref={triggerContainerRef}
|
||||
>
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
onFocus: handleInputFocus,
|
||||
onBlur: handleInputBlur,
|
||||
ref: inputRef
|
||||
})}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
`hover:bg-transparent`,
|
||||
classStyleProps?.popoverClasses?.popoverTrigger
|
||||
)}
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||
ref={triggerContainerRef}
|
||||
>
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
onFocus: handleInputFocus,
|
||||
onBlur: handleInputBlur,
|
||||
ref: inputRef
|
||||
})}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
`hover:bg-transparent`,
|
||||
classStyleProps?.popoverClasses?.popoverTrigger
|
||||
)}
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
ref={popoverContentRef}
|
||||
align="start"
|
||||
side="bottom"
|
||||
className={cn(
|
||||
`w-full space-y-3`,
|
||||
classStyleProps?.popoverClasses?.popoverContent
|
||||
)}
|
||||
style={{
|
||||
marginLeft: `-${sideOffset}px`,
|
||||
width: `${popoverWidth}px`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
Reference in New Issue
Block a user