mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-12 23:20:56 +00:00
Merge pull request #2893 from Fredkiss3/feat/roles-and-user-multi-selectors
feat: roles & users selector
This commit is contained in:
12
docker-compose.mailpit.yml
Normal file
12
docker-compose.mailpit.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
mailer:
|
||||||
|
image: axllent/mailpit
|
||||||
|
ports:
|
||||||
|
- 8025:8025
|
||||||
|
- 1025:1025
|
||||||
|
volumes:
|
||||||
|
- mailpit-storage:/data
|
||||||
|
environment:
|
||||||
|
- MP_DATABASE=/data/mailpit.db
|
||||||
|
volumes:
|
||||||
|
mailpit-storage:
|
||||||
@@ -1356,7 +1356,7 @@
|
|||||||
"sidebarSites": "Nœuds",
|
"sidebarSites": "Nœuds",
|
||||||
"sidebarApprovals": "Demandes d'approbation",
|
"sidebarApprovals": "Demandes d'approbation",
|
||||||
"sidebarResources": "Ressource",
|
"sidebarResources": "Ressource",
|
||||||
"sidebarProxyResources": "Publique",
|
"sidebarProxyResources": "Publiques",
|
||||||
"sidebarClientResources": "Privé",
|
"sidebarClientResources": "Privé",
|
||||||
"sidebarAccessControl": "Contrôle d'accès",
|
"sidebarAccessControl": "Contrôle d'accès",
|
||||||
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
||||||
@@ -2458,8 +2458,8 @@
|
|||||||
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
||||||
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
||||||
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
||||||
"manageMachineClients": "Gérer les clients de la machine",
|
"manageMachineClients": "Gérer les machines",
|
||||||
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
|
"manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
|
||||||
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
||||||
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
||||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
@@ -3154,6 +3154,7 @@
|
|||||||
"healthCheckTabAdvanced": "Avancé",
|
"healthCheckTabAdvanced": "Avancé",
|
||||||
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
||||||
"uptime30d": "Disponibilité (30j)",
|
"uptime30d": "Disponibilité (30j)",
|
||||||
|
"uptimeNoData": "Aucune donnée",
|
||||||
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
||||||
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
||||||
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
||||||
|
|||||||
@@ -175,26 +175,6 @@ export default function GeneralPage() {
|
|||||||
}, [variant]);
|
}, [variant]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchRoles() {
|
|
||||||
const res = await api
|
|
||||||
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("accessRoleErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("accessRoleErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
|
||||||
setRoles(res.data.data.roles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadIdp = async (
|
const loadIdp = async (
|
||||||
availableRoles: { roleId: number; name: string }[]
|
availableRoles: { roleId: number; name: string }[]
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -1,44 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||||
|
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { build } from "@server/build";
|
||||||
import { useEffect, useState } from "react";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
|
||||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionFooter
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const accessControlsFormSchema = z.object({
|
const accessControlsFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -59,12 +55,6 @@ export default function AccessControlsPage() {
|
|||||||
|
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
|
||||||
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
||||||
@@ -97,44 +87,21 @@ export default function AccessControlsPage() {
|
|||||||
text: r.name
|
text: r.name
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}, [user.userId, currentRoleIds.join(",")]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchRoles() {
|
|
||||||
const res = await api
|
|
||||||
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("accessRoleErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("accessRoleErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
|
||||||
setRoles(res.data.data.roles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRoles();
|
|
||||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||||
}, []);
|
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
|
||||||
|
|
||||||
const allRoleOptions = roles.map((role) => ({
|
|
||||||
id: role.roleId.toString(),
|
|
||||||
text: role.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
const paywallMessage =
|
const paywallMessage =
|
||||||
build === "saas"
|
build === "saas"
|
||||||
? t("singleRolePerUserPlanNotice")
|
? t("singleRolePerUserPlanNotice")
|
||||||
: t("singleRolePerUserEditionNotice");
|
: t("singleRolePerUserEditionNotice");
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
|
const [, action, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
async function onSubmit() {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
|
||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -144,7 +111,6 @@ export default function AccessControlsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
const updateRoleRequest = supportsMultipleRolesPerUser
|
const updateRoleRequest = supportsMultipleRolesPerUser
|
||||||
@@ -184,7 +150,6 @@ export default function AccessControlsPage() {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,7 +168,7 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
action={action}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
@@ -226,9 +191,7 @@ export default function AccessControlsPage() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={form}
|
form={form}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t("accessRoleSelect2")}
|
|
||||||
allRoleOptions={allRoleOptions}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -236,9 +199,6 @@ export default function AccessControlsPage() {
|
|||||||
showMultiRolePaywallMessage
|
showMultiRolePaywallMessage
|
||||||
}
|
}
|
||||||
paywallMessage={paywallMessage}
|
paywallMessage={paywallMessage}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={activeRoleTagIndex}
|
|
||||||
setActiveTagIndex={setActiveRoleTagIndex}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.idpAutoProvision && (
|
{user.idpAutoProvision && (
|
||||||
@@ -277,8 +237,8 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={loading}
|
loading={isSubmitting}
|
||||||
disabled={loading}
|
disabled={isSubmitting}
|
||||||
form="access-controls-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t("accessControlsSubmit")}
|
{t("accessControlsSubmit")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
|||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -91,7 +91,7 @@ export default function Page() {
|
|||||||
"internal"
|
"internal"
|
||||||
);
|
);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||||
@@ -311,10 +311,29 @@ export default function Page() {
|
|||||||
setUserOptions(options);
|
setUserOptions(options);
|
||||||
}, [idps, t]);
|
}, [idps, t]);
|
||||||
|
|
||||||
async function onSubmitInternal(
|
const [, submitInternalAction, isSubmittingInternal] = useActionState(
|
||||||
values: z.infer<typeof internalFormSchema>
|
onSubmitInternal,
|
||||||
) {
|
null
|
||||||
setLoading(true);
|
);
|
||||||
|
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
|
||||||
|
onSubmitGoogleAzure,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
|
||||||
|
onSubmitGenericOidc,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
isSubmittingInternal ||
|
||||||
|
isSubmittingGoogleAzure ||
|
||||||
|
isSubmittingGenericOidc;
|
||||||
|
|
||||||
|
async function onSubmitInternal() {
|
||||||
|
const isValid = await internalForm.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = internalForm.getValues();
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
@@ -357,25 +376,24 @@ export default function Page() {
|
|||||||
|
|
||||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitGoogleAzure(
|
async function onSubmitGoogleAzure() {
|
||||||
values: z.infer<typeof googleAzureFormSchema>
|
const isValid = await googleAzureForm.trigger();
|
||||||
) {
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = googleAzureForm.getValues();
|
||||||
|
|
||||||
const selectedUserOption = userOptions.find(
|
const selectedUserOption = userOptions.find(
|
||||||
(opt) => opt.id === selectedOption
|
(opt) => opt.id === selectedOption
|
||||||
);
|
);
|
||||||
if (!selectedUserOption?.idpId) return;
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put(`/org/${orgId}/user`, {
|
.put(`/org/${orgId}/user`, {
|
||||||
username: values.email, // Use email as username for Google/Azure
|
username: values.email,
|
||||||
email: values.email || undefined,
|
email: values.email || undefined,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
@@ -401,20 +419,19 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
router.push(`/${orgId}/settings/access/users`);
|
router.push(`/${orgId}/settings/access/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitGenericOidc(
|
async function onSubmitGenericOidc() {
|
||||||
values: z.infer<typeof genericOidcFormSchema>
|
const isValid = await genericOidcForm.trigger();
|
||||||
) {
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = genericOidcForm.getValues();
|
||||||
|
|
||||||
const selectedUserOption = userOptions.find(
|
const selectedUserOption = userOptions.find(
|
||||||
(opt) => opt.id === selectedOption
|
(opt) => opt.id === selectedOption
|
||||||
);
|
);
|
||||||
if (!selectedUserOption?.idpId) return;
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -445,8 +462,6 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
router.push(`/${orgId}/settings/access/users`);
|
router.push(`/${orgId}/settings/access/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -513,9 +528,9 @@ export default function Page() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...internalForm}>
|
<Form {...internalForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={internalForm.handleSubmit(
|
action={
|
||||||
onSubmitInternal
|
submitInternalAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -595,13 +610,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={internalForm}
|
form={internalForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -611,13 +620,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeInviteRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveInviteRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env.email.emailEnabled && (
|
{env.email.emailEnabled && (
|
||||||
@@ -712,9 +714,9 @@ export default function Page() {
|
|||||||
})() && (
|
})() && (
|
||||||
<Form {...googleAzureForm}>
|
<Form {...googleAzureForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={googleAzureForm.handleSubmit(
|
action={
|
||||||
onSubmitGoogleAzure
|
submitGoogleAzureAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -763,13 +765,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={googleAzureForm}
|
form={googleAzureForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -779,13 +775,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -808,9 +797,9 @@ export default function Page() {
|
|||||||
})() && (
|
})() && (
|
||||||
<Form {...genericOidcForm}>
|
<Form {...genericOidcForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={genericOidcForm.handleSubmit(
|
action={
|
||||||
onSubmitGenericOidc
|
submitGenericOidcAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -888,13 +877,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={genericOidcForm}
|
form={genericOidcForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -904,13 +887,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RolesSelector } from "@app/components/roles-selector";
|
||||||
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
|
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
|
||||||
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
|
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import { UsersSelector } from "@app/components/users-selector";
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
@@ -180,13 +182,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
return [];
|
return [];
|
||||||
}, [orgIdps]);
|
}, [orgIdps]);
|
||||||
|
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -497,46 +492,27 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{t("roles")}
|
{t("roles")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
selectedRoles={
|
||||||
activeTagIndex={
|
field.value ??
|
||||||
activeRolesTagIndex
|
[]
|
||||||
}
|
}
|
||||||
setActiveTagIndex={
|
restrictAdminRole
|
||||||
setActiveRolesTagIndex
|
orgId={
|
||||||
|
org.org
|
||||||
|
.orgId
|
||||||
}
|
}
|
||||||
placeholder={t(
|
onSelectRoles={(
|
||||||
"accessRoleSelect2"
|
newUsers
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
tags={
|
|
||||||
usersRolesForm.getValues()
|
|
||||||
.roles
|
|
||||||
}
|
|
||||||
setTags={(
|
|
||||||
newRoles
|
|
||||||
) => {
|
) => {
|
||||||
usersRolesForm.setValue(
|
usersRolesForm.setValue(
|
||||||
"roles",
|
"roles",
|
||||||
newRoles as [
|
newUsers as [
|
||||||
Tag,
|
Tag,
|
||||||
...Tag[]
|
...Tag[]
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
autocompleteOptions={
|
|
||||||
allRoles
|
|
||||||
}
|
|
||||||
allowDuplicates={
|
|
||||||
false
|
|
||||||
}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -557,23 +533,16 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{t("users")}
|
{t("users")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<UsersSelector
|
||||||
{...field}
|
selectedUsers={
|
||||||
activeTagIndex={
|
field.value ??
|
||||||
activeUsersTagIndex
|
[]
|
||||||
}
|
}
|
||||||
setActiveTagIndex={
|
orgId={
|
||||||
setActiveUsersTagIndex
|
org.org
|
||||||
|
.orgId
|
||||||
}
|
}
|
||||||
placeholder={t(
|
onSelectUsers={(
|
||||||
"accessUserSelect"
|
|
||||||
)}
|
|
||||||
tags={
|
|
||||||
usersRolesForm.getValues()
|
|
||||||
.users
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
setTags={(
|
|
||||||
newUsers
|
newUsers
|
||||||
) => {
|
) => {
|
||||||
usersRolesForm.setValue(
|
usersRolesForm.setValue(
|
||||||
@@ -584,19 +553,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
autocompleteOptions={
|
|
||||||
allUsers
|
|
||||||
}
|
|
||||||
allowDuplicates={
|
|
||||||
false
|
|
||||||
}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ExternalLink
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -50,11 +55,13 @@ import {
|
|||||||
formatMultiSitesSelectorLabel
|
formatMultiSitesSelectorLabel
|
||||||
} from "./multi-site-selector";
|
} from "./multi-site-selector";
|
||||||
import type { Selectedsite } from "./site-selector";
|
import type { Selectedsite } from "./site-selector";
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
|
||||||
import { MachinesSelector } from "./machines-selector";
|
import { MachinesSelector } from "./machines-selector";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import CertificateStatus from "@app/components/CertificateStatus";
|
import CertificateStatus from "@app/components/CertificateStatus";
|
||||||
|
import { UsersSelector } from "./users-selector";
|
||||||
|
import { RolesSelector } from "./roles-selector";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// --- Helpers (shared) ---
|
// --- Helpers (shared) ---
|
||||||
@@ -1118,6 +1125,30 @@ export function InternalResourceForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="internal-resource-ssl"
|
||||||
|
label={t(enableSslLabelKey)}
|
||||||
|
description={t(
|
||||||
|
enableSslDescriptionKey
|
||||||
|
)}
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
httpSectionDisabled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -1484,40 +1515,22 @@ export function InternalResourceForm({
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
selectedRoles={
|
||||||
activeTagIndex={
|
field.value ?? []
|
||||||
activeRolesTagIndex
|
|
||||||
}
|
}
|
||||||
setActiveTagIndex={
|
orgId={orgId}
|
||||||
setActiveRolesTagIndex
|
onSelectRoles={(
|
||||||
}
|
newUsers
|
||||||
placeholder={t(
|
) => {
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
tags={
|
|
||||||
form.getValues()
|
|
||||||
.roles ?? []
|
|
||||||
}
|
|
||||||
setTags={(newRoles) =>
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"roles",
|
"roles",
|
||||||
newRoles as [
|
newUsers as [
|
||||||
Tag,
|
Tag,
|
||||||
...Tag[]
|
...Tag[]
|
||||||
]
|
]
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
enableAutocomplete
|
|
||||||
autocompleteOptions={
|
|
||||||
allRoles
|
|
||||||
}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1530,43 +1543,21 @@ export function InternalResourceForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
<FormControl>
|
<UsersSelector
|
||||||
<TagInput
|
selectedUsers={
|
||||||
{...field}
|
field.value ?? []
|
||||||
activeTagIndex={
|
}
|
||||||
activeUsersTagIndex
|
orgId={orgId}
|
||||||
}
|
onSelectUsers={(newUsers) => {
|
||||||
setActiveTagIndex={
|
form.setValue(
|
||||||
setActiveUsersTagIndex
|
"users",
|
||||||
}
|
newUsers as [
|
||||||
placeholder={t(
|
Tag,
|
||||||
"accessUserSelect"
|
...Tag[]
|
||||||
)}
|
]
|
||||||
tags={
|
);
|
||||||
form.getValues()
|
}}
|
||||||
.users ?? []
|
/>
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
setTags={(newUsers) =>
|
|
||||||
form.setValue(
|
|
||||||
"users",
|
|
||||||
newUsers as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={
|
|
||||||
allUsers
|
|
||||||
}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1580,73 +1571,20 @@ export function InternalResourceForm({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("machineClients")}
|
{t("machineClients")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<MachinesSelector
|
||||||
<PopoverTrigger asChild>
|
selectedMachines={
|
||||||
<FormControl>
|
field.value ?? []
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
orgId={orgId}
|
||||||
role="combobox"
|
onSelectMachines={(
|
||||||
className={cn(
|
machines
|
||||||
"justify-between w-full",
|
) => {
|
||||||
"text-muted-foreground pl-1.5"
|
form.setValue(
|
||||||
)}
|
"clients",
|
||||||
>
|
machines
|
||||||
<span
|
);
|
||||||
className={cn(
|
}}
|
||||||
"inline-flex items-center gap-1",
|
/>
|
||||||
"overflow-x-auto"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
field.value ??
|
|
||||||
[]
|
|
||||||
).map(
|
|
||||||
(
|
|
||||||
client
|
|
||||||
) => (
|
|
||||||
<span
|
|
||||||
key={
|
|
||||||
client.clientId
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
|
|
||||||
"py-1 px-1.5 text-xs"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
client.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="pl-1 font-normal">
|
|
||||||
{t(
|
|
||||||
"accessClientSelect"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0">
|
|
||||||
<MachinesSelector
|
|
||||||
selectedMachines={
|
|
||||||
field.value ??
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
orgId={orgId}
|
|
||||||
onSelectMachines={(
|
|
||||||
machines
|
|
||||||
) => {
|
|
||||||
form.setValue(
|
|
||||||
"clients",
|
|
||||||
machines
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,51 +8,42 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
|
||||||
|
|
||||||
export type RoleTag = {
|
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||||
id: string;
|
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||||
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
|
form: Pick<
|
||||||
|
UseFormReturn<TFieldValues>,
|
||||||
|
"control" | "getValues" | "setValue"
|
||||||
|
>;
|
||||||
|
orgId: string;
|
||||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||||
name?: Path<TFieldValues>;
|
name?: Path<TFieldValues>;
|
||||||
label: string;
|
label?: string;
|
||||||
placeholder: string;
|
|
||||||
allRoleOptions: Tag[];
|
|
||||||
supportsMultipleRolesPerUser: boolean;
|
supportsMultipleRolesPerUser: boolean;
|
||||||
showMultiRolePaywallMessage: boolean;
|
showMultiRolePaywallMessage: boolean;
|
||||||
paywallMessage: string;
|
paywallMessage: string;
|
||||||
loading?: boolean;
|
disabled?: boolean;
|
||||||
activeTagIndex: number | null;
|
|
||||||
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||||
form,
|
form,
|
||||||
name = "roles" as Path<TFieldValues>,
|
name = "roles" as Path<TFieldValues>,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
orgId,
|
||||||
allRoleOptions,
|
|
||||||
supportsMultipleRolesPerUser,
|
supportsMultipleRolesPerUser,
|
||||||
showMultiRolePaywallMessage,
|
showMultiRolePaywallMessage,
|
||||||
paywallMessage,
|
paywallMessage,
|
||||||
loading = false,
|
disabled
|
||||||
activeTagIndex,
|
|
||||||
setActiveTagIndex
|
|
||||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
|
function setRoleTags(nextValue: SelectedRole[]) {
|
||||||
const prev = form.getValues(name) as Tag[];
|
const prev = form.getValues(name) as SelectedRole[];
|
||||||
const nextValue =
|
|
||||||
typeof updater === "function" ? updater(prev) : updater;
|
|
||||||
const next = supportsMultipleRolesPerUser
|
const next = supportsMultipleRolesPerUser
|
||||||
? nextValue
|
? nextValue
|
||||||
: nextValue.length > 1
|
: nextValue.length > 1
|
||||||
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
orgId={orgId}
|
||||||
activeTagIndex={activeTagIndex}
|
selectedRoles={field.value ?? []}
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
onSelectRoles={setRoleTags}
|
||||||
placeholder={placeholder}
|
disabled={disabled}
|
||||||
size="sm"
|
|
||||||
tags={field.value}
|
|
||||||
setTags={setRoleTags}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allRoleOptions}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={true}
|
|
||||||
sortTags={true}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{showMultiRolePaywallMessage && (
|
{showMultiRolePaywallMessage && (
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { RolesSelector } from "./roles-selector";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
export type RoleMappingRoleOption = {
|
export type RoleMappingRoleOption = {
|
||||||
roleId: number;
|
roleId: number;
|
||||||
@@ -58,9 +60,8 @@ export default function RoleMappingConfigFields({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
|
|
||||||
number | null
|
const { orgId } = useParams();
|
||||||
>(null);
|
|
||||||
|
|
||||||
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
|
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
|
||||||
const showSingleRoleDisclaimer =
|
const showSingleRoleDisclaimer =
|
||||||
@@ -160,23 +161,16 @@ export default function RoleMappingConfigFields({
|
|||||||
|
|
||||||
{roleMappingMode === "fixedRoles" && (
|
{roleMappingMode === "fixedRoles" && (
|
||||||
<div className="space-y-2 min-w-0 max-w-full">
|
<div className="space-y-2 min-w-0 max-w-full">
|
||||||
<TagInput
|
<RolesSelector
|
||||||
tags={fixedRoleNames.map((name) => ({
|
selectedRoles={fixedRoleNames.map((name) => ({
|
||||||
id: name,
|
id: name,
|
||||||
text: name
|
text: name
|
||||||
}))}
|
}))}
|
||||||
setTags={(nextTags) => {
|
mapRolesByName
|
||||||
const prevTags = fixedRoleNames.map((name) => ({
|
orgId={orgId as string}
|
||||||
id: name,
|
onSelectRoles={(nextTags) => {
|
||||||
text: name
|
|
||||||
}));
|
|
||||||
const next =
|
|
||||||
typeof nextTags === "function"
|
|
||||||
? nextTags(prevTags)
|
|
||||||
: nextTags;
|
|
||||||
|
|
||||||
let names = [
|
let names = [
|
||||||
...new Set(next.map((tag) => tag.text))
|
...new Set(nextTags.map((tag) => tag.text))
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!supportsMultipleRolesPerUser) {
|
if (!supportsMultipleRolesPerUser) {
|
||||||
@@ -198,19 +192,6 @@ export default function RoleMappingConfigFields({
|
|||||||
|
|
||||||
onFixedRoleNamesChange(names);
|
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>
|
<FormDescription>
|
||||||
{showFreeformRoleNamesHint
|
{showFreeformRoleNamesHint
|
||||||
@@ -352,6 +333,7 @@ function BuilderRuleRow({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
|
const { orgId } = useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -378,67 +360,109 @@ function BuilderRuleRow({
|
|||||||
{t("roleMappingAssignRoles")}
|
{t("roleMappingAssignRoles")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="min-w-0 max-w-full">
|
<div className="min-w-0 max-w-full">
|
||||||
<TagInput
|
{restrictToOrgRoles ? (
|
||||||
tags={rule.roleNames.map((name) => ({
|
<RolesSelector
|
||||||
id: name,
|
selectedRoles={rule.roleNames.map((name) => ({
|
||||||
text: name
|
|
||||||
}))}
|
|
||||||
setTags={(nextTags) => {
|
|
||||||
const prevRoleTags = rule.roleNames.map((name) => ({
|
|
||||||
id: name,
|
id: name,
|
||||||
text: name
|
text: name
|
||||||
}));
|
}))}
|
||||||
const next =
|
buttonText={t("roleMappingAssignRoles")}
|
||||||
typeof nextTags === "function"
|
mapRolesByName
|
||||||
? nextTags(prevRoleTags)
|
orgId={orgId as string}
|
||||||
: nextTags;
|
onSelectRoles={(nextTags) => {
|
||||||
|
let names = [
|
||||||
|
...new Set(nextTags.map((tag) => tag.text))
|
||||||
|
];
|
||||||
|
|
||||||
let names = [
|
if (!supportsMultipleRolesPerUser) {
|
||||||
...new Set(next.map((tag) => tag.text))
|
if (
|
||||||
];
|
names.length === 0 &&
|
||||||
|
rule.roleNames.length > 0
|
||||||
if (!supportsMultipleRolesPerUser) {
|
) {
|
||||||
if (
|
onChange({
|
||||||
names.length === 0 &&
|
...rule,
|
||||||
rule.roleNames.length > 0
|
roleNames: [
|
||||||
) {
|
rule.roleNames[
|
||||||
onChange({
|
rule.roleNames.length - 1
|
||||||
...rule,
|
]!
|
||||||
roleNames: [
|
]
|
||||||
rule.roleNames[
|
});
|
||||||
rule.roleNames.length - 1
|
return;
|
||||||
]!
|
}
|
||||||
]
|
if (names.length > 1) {
|
||||||
});
|
names = [names[names.length - 1]!];
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
if (names.length > 1) {
|
|
||||||
names = [names[names.length - 1]!];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...rule,
|
...rule,
|
||||||
roleNames: names
|
roleNames: names
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
activeTagIndex={activeTagIndex}
|
/>
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
) : (
|
||||||
placeholder={
|
<TagInput
|
||||||
restrictToOrgRoles
|
tags={rule.roleNames.map((name) => ({
|
||||||
? t("roleMappingAssignRoles")
|
id: name,
|
||||||
: t("roleMappingAssignRolesPlaceholderFreeform")
|
text: name
|
||||||
}
|
}))}
|
||||||
enableAutocomplete={restrictToOrgRoles}
|
setTags={(nextTags) => {
|
||||||
autocompleteOptions={roleOptions}
|
const prevRoleTags = rule.roleNames.map(
|
||||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
(name) => ({
|
||||||
allowDuplicates={false}
|
id: name,
|
||||||
sortTags={true}
|
text: name
|
||||||
size="sm"
|
})
|
||||||
styleClasses={{
|
);
|
||||||
inlineTagsContainer: "min-w-0 max-w-full"
|
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={t(
|
||||||
|
"roleMappingAssignRolesPlaceholderFreeform"
|
||||||
|
)}
|
||||||
|
enableAutocomplete={false}
|
||||||
|
autocompleteOptions={roleOptions}
|
||||||
|
restrictTagsToAutocompleteOptions={false}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags={true}
|
||||||
|
size="sm"
|
||||||
|
styleClasses={{
|
||||||
|
inlineTagsContainer: "min-w-0 max-w-full"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showFreeformRoleNamesHint && (
|
{showFreeformRoleNamesHint && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { BellPlus, BellRing } from "lucide-react";
|
|
||||||
import {
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionBody
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import UptimeBar from "@app/components/UptimeBar";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -23,18 +10,32 @@ import {
|
|||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import UptimeBar from "@app/components/UptimeBar";
|
||||||
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { orgQueries } from "@app/lib/queries";
|
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { BellPlus, BellRing } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RolesSelector } from "./roles-selector";
|
||||||
|
import { UsersSelector } from "./users-selector";
|
||||||
|
|
||||||
interface UptimeAlertSectionProps {
|
interface UptimeAlertSectionProps {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -64,12 +65,7 @@ export default function UptimeAlertSection({
|
|||||||
const [userTags, setUserTags] = useState<Tag[]>([]);
|
const [userTags, setUserTags] = useState<Tag[]>([]);
|
||||||
const [roleTags, setRoleTags] = useState<Tag[]>([]);
|
const [roleTags, setRoleTags] = useState<Tag[]>([]);
|
||||||
const [emailTags, setEmailTags] = useState<Tag[]>([]);
|
const [emailTags, setEmailTags] = useState<Tag[]>([]);
|
||||||
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -80,27 +76,6 @@ export default function UptimeAlertSection({
|
|||||||
enabled: isPaid
|
enabled: isPaid
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
|
||||||
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
|
||||||
|
|
||||||
const allUsers = useMemo(
|
|
||||||
() =>
|
|
||||||
orgUsers.map((u) => ({
|
|
||||||
id: String(u.id),
|
|
||||||
text: getUserDisplayName({
|
|
||||||
email: u.email,
|
|
||||||
name: u.name,
|
|
||||||
username: u.username
|
|
||||||
})
|
|
||||||
})),
|
|
||||||
[orgUsers]
|
|
||||||
);
|
|
||||||
|
|
||||||
const allRoles = useMemo(
|
|
||||||
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
|
|
||||||
[orgRoles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasRules = (alertRules?.length ?? 0) > 0;
|
const hasRules = (alertRules?.length ?? 0) > 0;
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -227,10 +202,16 @@ export default function UptimeAlertSection({
|
|||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.alertingRules}
|
||||||
|
/>
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={!isPaid}
|
disabled={!isPaid}
|
||||||
className={!isPaid ? "opacity-50 pointer-events-none" : ""}
|
className={
|
||||||
|
!isPaid
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -240,65 +221,53 @@ export default function UptimeAlertSection({
|
|||||||
<Input
|
<Input
|
||||||
id="alert-name"
|
id="alert-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) =>
|
||||||
placeholder={t("uptimeAlertNamePlaceholder")}
|
setName(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"uptimeAlertNamePlaceholder"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("alertingNotifyUsers")}</Label>
|
<Label>
|
||||||
<TagInput
|
{t("alertingNotifyUsers")}
|
||||||
activeTagIndex={activeUserTagIndex}
|
</Label>
|
||||||
setActiveTagIndex={setActiveUserTagIndex}
|
<UsersSelector
|
||||||
placeholder={t("alertingSelectUsers")}
|
selectedUsers={userTags}
|
||||||
size="sm"
|
orgId={orgId}
|
||||||
tags={userTags}
|
onSelectUsers={setUserTags}
|
||||||
setTags={(newTags) => {
|
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(userTags)
|
|
||||||
: newTags;
|
|
||||||
setUserTags(next as Tag[]);
|
|
||||||
}}
|
|
||||||
enableAutocomplete
|
|
||||||
autocompleteOptions={allUsers}
|
|
||||||
restrictTagsToAutocompleteOptions
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("alertingNotifyRoles")}</Label>
|
<Label>
|
||||||
<TagInput
|
{t("alertingNotifyRoles")}
|
||||||
activeTagIndex={activeRoleTagIndex}
|
</Label>
|
||||||
setActiveTagIndex={setActiveRoleTagIndex}
|
<RolesSelector
|
||||||
placeholder={t("alertingSelectRoles")}
|
selectedRoles={roleTags}
|
||||||
size="sm"
|
restrictAdminRole
|
||||||
tags={roleTags}
|
orgId={orgId}
|
||||||
setTags={(newTags) => {
|
onSelectRoles={setRoleTags}
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(roleTags)
|
|
||||||
: newTags;
|
|
||||||
setRoleTags(next as Tag[]);
|
|
||||||
}}
|
|
||||||
enableAutocomplete
|
|
||||||
autocompleteOptions={allRoles}
|
|
||||||
restrictTagsToAutocompleteOptions
|
|
||||||
allowDuplicates={false}
|
|
||||||
sortTags
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("uptimeAdditionalEmails")}</Label>
|
<Label>
|
||||||
|
{t("uptimeAdditionalEmails")}
|
||||||
|
</Label>
|
||||||
<TagInput
|
<TagInput
|
||||||
activeTagIndex={activeEmailTagIndex}
|
activeTagIndex={activeEmailTagIndex}
|
||||||
setActiveTagIndex={setActiveEmailTagIndex}
|
setActiveTagIndex={
|
||||||
placeholder={t("alertingEmailPlaceholder")}
|
setActiveEmailTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"alertingEmailPlaceholder"
|
||||||
|
)}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={emailTags}
|
tags={emailTags}
|
||||||
setTags={(newTags) => {
|
setTags={(newTags) => {
|
||||||
const next =
|
const next =
|
||||||
typeof newTags === "function"
|
typeof newTags ===
|
||||||
|
"function"
|
||||||
? newTags(emailTags)
|
? newTags(emailTags)
|
||||||
: newTags;
|
: newTags;
|
||||||
setEmailTags(next as Tag[]);
|
setEmailTags(next as Tag[]);
|
||||||
@@ -306,7 +275,9 @@ export default function UptimeAlertSection({
|
|||||||
allowDuplicates={false}
|
allowDuplicates={false}
|
||||||
sortTags
|
sortTags
|
||||||
validateTag={(tag) =>
|
validateTag={(tag) =>
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
||||||
|
tag
|
||||||
|
)
|
||||||
}
|
}
|
||||||
delimiterList={[",", "Enter"]}
|
delimiterList={[",", "Enter"]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||||
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -21,11 +24,13 @@ import {
|
|||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { Textarea } from "@app/components/ui/textarea";
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -33,24 +38,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
|
||||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
import {
|
import {
|
||||||
type AlertRuleFormAction,
|
type AlertRuleFormAction,
|
||||||
type AlertRuleFormValues
|
type AlertRuleFormValues
|
||||||
} from "@app/lib/alertRuleForm";
|
} from "@app/lib/alertRuleForm";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
|
||||||
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Control, UseFormReturn } from "react-hook-form";
|
import type { Control, UseFormReturn } from "react-hook-form";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { RolesSelector } from "../roles-selector";
|
||||||
|
import { UsersSelector } from "../users-selector";
|
||||||
|
|
||||||
export function AddActionPanel({
|
export function AddActionPanel({
|
||||||
onAdd
|
onAdd
|
||||||
@@ -498,12 +500,6 @@ function NotifyActionFields({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
||||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
|
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
|
||||||
orgQueries.users({ orgId })
|
orgQueries.users({ orgId })
|
||||||
@@ -574,14 +570,6 @@ function NotifyActionFields({
|
|||||||
hasResolvedTagsRef.current = true;
|
hasResolvedTagsRef.current = true;
|
||||||
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
|
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
|
||||||
|
|
||||||
const userTags = (useWatch({
|
|
||||||
control,
|
|
||||||
name: `actions.${index}.userTags`
|
|
||||||
}) ?? []) as Tag[];
|
|
||||||
const roleTags = (useWatch({
|
|
||||||
control,
|
|
||||||
name: `actions.${index}.roleTags`
|
|
||||||
}) ?? []) as Tag[];
|
|
||||||
const emailTags = (useWatch({
|
const emailTags = (useWatch({
|
||||||
control,
|
control,
|
||||||
name: `actions.${index}.emailTags`
|
name: `actions.${index}.emailTags`
|
||||||
@@ -596,29 +584,16 @@ function NotifyActionFields({
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<UsersSelector
|
||||||
{...field}
|
selectedUsers={field.value ?? []}
|
||||||
activeTagIndex={activeUsersTagIndex}
|
orgId={orgId}
|
||||||
setActiveTagIndex={setActiveUsersTagIndex}
|
onSelectUsers={(newUsers) => {
|
||||||
placeholder={t("alertingSelectUsers")}
|
|
||||||
size="sm"
|
|
||||||
tags={userTags}
|
|
||||||
setTags={(newTags) => {
|
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(userTags)
|
|
||||||
: newTags;
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
`actions.${index}.userTags`,
|
`actions.${index}.userTags`,
|
||||||
next as Tag[],
|
newUsers as [Tag, ...Tag[]],
|
||||||
{ shouldDirty: true }
|
{ shouldDirty: true }
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allUsers}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={true}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -632,29 +607,17 @@ function NotifyActionFields({
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
selectedRoles={field.value ?? []}
|
||||||
activeTagIndex={activeRolesTagIndex}
|
restrictAdminRole
|
||||||
setActiveTagIndex={setActiveRolesTagIndex}
|
orgId={orgId}
|
||||||
placeholder={t("alertingSelectRoles")}
|
onSelectRoles={(newUsers) => {
|
||||||
size="sm"
|
|
||||||
tags={roleTags}
|
|
||||||
setTags={(newTags) => {
|
|
||||||
const next =
|
|
||||||
typeof newTags === "function"
|
|
||||||
? newTags(roleTags)
|
|
||||||
: newTags;
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
`actions.${index}.roleTags`,
|
`actions.${index}.roleTags`,
|
||||||
next as Tag[],
|
newUsers as [Tag, ...Tag[]],
|
||||||
{ shouldDirty: true }
|
{ shouldDirty: true }
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allRoles}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={true}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { MultiSelectTags } from "./multi-select-tags";
|
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||||
|
|
||||||
export type SelectedMachine = Pick<
|
export type SelectedMachine = Pick<
|
||||||
ListClientsResponse["clients"][number],
|
ListClientsResponse["clients"][number],
|
||||||
@@ -28,11 +28,13 @@ export function MachinesSelector({
|
|||||||
|
|
||||||
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
||||||
|
|
||||||
|
const perPage = 7;
|
||||||
|
|
||||||
const { data: machines = [] } = useQuery(
|
const { data: machines = [] } = useQuery(
|
||||||
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
|
orgQueries.machineClients({ orgId, perPage, query: debouncedValue })
|
||||||
);
|
);
|
||||||
|
|
||||||
// always include the selected machines in the list of machines shown (if the user isn't searching)
|
// always include the selected machines in the list (if the user isn't searching)
|
||||||
const machinesShown = useMemo(() => {
|
const machinesShown = useMemo(() => {
|
||||||
const allMachines: Array<SelectedMachine> = [...machines];
|
const allMachines: Array<SelectedMachine> = [...machines];
|
||||||
if (debouncedValue.trim().length === 0) {
|
if (debouncedValue.trim().length === 0) {
|
||||||
@@ -44,75 +46,32 @@ export function MachinesSelector({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allMachines;
|
return allMachines;
|
||||||
}, [machines, selectedMachines, debouncedValue]);
|
}, [machines, selectedMachines, debouncedValue]);
|
||||||
|
|
||||||
// const selectedMachinesIds = new Set(
|
|
||||||
// selectedMachines.map((m) => m.clientId)
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelectTags
|
<MultiSelectTagInput
|
||||||
|
buttonText={t("accessClientSelect")}
|
||||||
|
searchPlaceholder={t("search")}
|
||||||
emptyPlaceholder={t("machineNotFound")}
|
emptyPlaceholder={t("machineNotFound")}
|
||||||
searchPlaceholder={t("machineSearch")}
|
|
||||||
value={selectedMachines.map((m) => ({
|
|
||||||
...m,
|
|
||||||
text: m.name,
|
|
||||||
id: m.clientId.toString()
|
|
||||||
}))}
|
|
||||||
onChange={(values) => {
|
|
||||||
onSelectMachines(values);
|
|
||||||
}}
|
|
||||||
options={machinesShown.map((m) => ({
|
|
||||||
...m,
|
|
||||||
id: m.clientId.toString(),
|
|
||||||
text: m.name
|
|
||||||
}))}
|
|
||||||
onSearch={setMachineSearchQuery}
|
|
||||||
searchQuery={machineSearchQuery}
|
searchQuery={machineSearchQuery}
|
||||||
|
onSearch={setMachineSearchQuery}
|
||||||
|
options={machinesShown.map((mc) => ({
|
||||||
|
id: mc.clientId.toString(),
|
||||||
|
text: mc.name
|
||||||
|
}))}
|
||||||
|
value={selectedMachines.map((mc) => ({
|
||||||
|
id: mc.clientId.toString(),
|
||||||
|
text: mc.name
|
||||||
|
}))}
|
||||||
|
onChange={(newValues) => {
|
||||||
|
onSelectMachines(
|
||||||
|
newValues.map((v) => ({
|
||||||
|
clientId: Number(v.id),
|
||||||
|
name: v.text
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
// <Command shouldFilter={false}>
|
|
||||||
// <CommandInput
|
|
||||||
// placeholder={t("machineSearch")}
|
|
||||||
// value={machineSearchQuery}
|
|
||||||
// onValueChange={setMachineSearchQuery}
|
|
||||||
// />
|
|
||||||
// <CommandList>
|
|
||||||
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
|
|
||||||
// <CommandGroup>
|
|
||||||
// {machinesShown.map((m) => (
|
|
||||||
// <CommandItem
|
|
||||||
// value={`${m.name}:${m.clientId}`}
|
|
||||||
// key={m.clientId}
|
|
||||||
// onSelect={() => {
|
|
||||||
// let newMachineClients = [];
|
|
||||||
// if (selectedMachinesIds.has(m.clientId)) {
|
|
||||||
// newMachineClients = selectedMachines.filter(
|
|
||||||
// (mc) => mc.clientId !== m.clientId
|
|
||||||
// );
|
|
||||||
// } else {
|
|
||||||
// newMachineClients = [
|
|
||||||
// ...selectedMachines,
|
|
||||||
// m
|
|
||||||
// ];
|
|
||||||
// }
|
|
||||||
// onSelectMachines(newMachineClients);
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <CheckIcon
|
|
||||||
// className={cn(
|
|
||||||
// "mr-2 h-4 w-4",
|
|
||||||
// selectedMachinesIds.has(m.clientId)
|
|
||||||
// ? "opacity-100"
|
|
||||||
// : "opacity-0"
|
|
||||||
// )}
|
|
||||||
// />
|
|
||||||
// {`${m.name}`}
|
|
||||||
// </CommandItem>
|
|
||||||
// ))}
|
|
||||||
// </CommandGroup>
|
|
||||||
// </CommandList>
|
|
||||||
// </Command>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,26 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "./ui/command";
|
} from "../ui/command";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type TagValue = { text: string; id: string };
|
export type TagValue = { text: string; id: string };
|
||||||
|
|
||||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||||
emptyPlaceholder: string;
|
emptyPlaceholder?: string;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder?: string;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
options: Array<T>;
|
options: Array<T>;
|
||||||
value: Array<T>;
|
value: Array<T>;
|
||||||
onChange: (newValue: Array<T>) => void;
|
onChange: (newValue: Array<T>) => void;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
ref?: Ref<HTMLButtonElement>;
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MultiSelectTags<T extends TagValue>({
|
export function MultiSelectContent<T extends TagValue>({
|
||||||
emptyPlaceholder,
|
emptyPlaceholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -32,16 +34,19 @@ export function MultiSelectTags<T extends TagValue>({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onChange
|
onChange
|
||||||
}: MultiSelectTagsProps<T>) {
|
}: MultiSelectTagsProps<T>) {
|
||||||
|
const t = useTranslations();
|
||||||
const selectedValues = new Set(value.map((v) => v.id));
|
const selectedValues = new Set(value.map((v) => v.id));
|
||||||
return (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder ?? t("search")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={onSearch}
|
onValueChange={onSearch}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
<CommandEmpty className="text-muted-foreground">
|
||||||
|
{emptyPlaceholder ?? t("noResults")}
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
98
src/components/multi-select/multi-select-tag-input.tsx
Normal file
98
src/components/multi-select/multi-select-tag-input.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { buttonVariants } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
type MultiSelectTagsProps,
|
||||||
|
type TagValue,
|
||||||
|
MultiSelectContent
|
||||||
|
} from "./multi-select-content";
|
||||||
|
|
||||||
|
export interface MultiSelectInputProps<
|
||||||
|
T extends TagValue
|
||||||
|
> extends MultiSelectTagsProps<T> {
|
||||||
|
buttonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelectTagInput<T extends TagValue>({
|
||||||
|
buttonText,
|
||||||
|
...props
|
||||||
|
}: MultiSelectInputProps<T>) {
|
||||||
|
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
// clear input when popover is closed
|
||||||
|
props.onSearch("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: "outline"
|
||||||
|
}),
|
||||||
|
"justify-between w-full inline-flex",
|
||||||
|
"text-muted-foreground pl-1.5 cursor-text",
|
||||||
|
"hover:bg-transparent hover:text-muted-foreground",
|
||||||
|
props.disabled && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
"overflow-x-auto"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.value.map((option) => (
|
||||||
|
<span
|
||||||
|
key={option.id}
|
||||||
|
className={cn(
|
||||||
|
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||||
|
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
<button
|
||||||
|
className="p-0.5 flex-none cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let newValues = [];
|
||||||
|
if (selectedValues.has(option.id)) {
|
||||||
|
newValues = props.value.filter(
|
||||||
|
(v) => v.id !== option.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newValues = [
|
||||||
|
...props.value,
|
||||||
|
option
|
||||||
|
];
|
||||||
|
}
|
||||||
|
props.onChange(newValues);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="pl-1 font-normal">{buttonText}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<MultiSelectContent {...props} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/roles-selector.tsx
Normal file
81
src/components/roles-selector.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||||
|
|
||||||
|
export type SelectedRole = { id: string; text: string };
|
||||||
|
|
||||||
|
export type RolesSelectorProps = {
|
||||||
|
orgId: string;
|
||||||
|
selectedRoles?: SelectedRole[];
|
||||||
|
onSelectRoles: (roles: SelectedRole[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
restrictAdminRole?: boolean;
|
||||||
|
mapRolesByName?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RolesSelector({
|
||||||
|
orgId,
|
||||||
|
selectedRoles = [],
|
||||||
|
onSelectRoles,
|
||||||
|
disabled,
|
||||||
|
restrictAdminRole,
|
||||||
|
mapRolesByName,
|
||||||
|
buttonText
|
||||||
|
}: RolesSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
|
||||||
|
|
||||||
|
const { data: roles = [] } = useQuery(
|
||||||
|
orgQueries.roles({ orgId, perPage: 10, query: debouncedValue })
|
||||||
|
);
|
||||||
|
|
||||||
|
// always include the selected roles in the list (if the user isn't searching)
|
||||||
|
const rolesShown = useMemo(() => {
|
||||||
|
let allRoles: Array<SelectedRole & { isAdmin?: boolean }> = roles.map(
|
||||||
|
(r) => ({
|
||||||
|
id: mapRolesByName ? r.name : r.roleId.toString(),
|
||||||
|
text: r.name,
|
||||||
|
isAdmin: Boolean(r.isAdmin)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (debouncedValue.trim().length === 0) {
|
||||||
|
for (const role of selectedRoles) {
|
||||||
|
if (!allRoles.find((r) => r.id === role.id)) {
|
||||||
|
allRoles.unshift(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restrictAdminRole) {
|
||||||
|
allRoles = allRoles.filter((role) => !role.isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRoles;
|
||||||
|
}, [
|
||||||
|
roles,
|
||||||
|
selectedRoles,
|
||||||
|
debouncedValue,
|
||||||
|
restrictAdminRole,
|
||||||
|
mapRolesByName
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectTagInput
|
||||||
|
buttonText={buttonText ?? t("alertingSelectRoles")}
|
||||||
|
searchQuery={roleSearchQuery}
|
||||||
|
onSearch={setRoleSearchQuery}
|
||||||
|
options={rolesShown}
|
||||||
|
value={selectedRoles}
|
||||||
|
onChange={onSelectRoles}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -220,7 +226,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
|||||||
>
|
>
|
||||||
<PopoverAnchor asChild>
|
<PopoverAnchor asChild>
|
||||||
<div
|
<div
|
||||||
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
|
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-1"
|
||||||
ref={triggerContainerRef}
|
ref={triggerContainerRef}
|
||||||
>
|
>
|
||||||
{childrenWithProps}
|
{childrenWithProps}
|
||||||
@@ -260,10 +266,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
|||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
forceMount
|
forceMount
|
||||||
className={cn(
|
className={cn("p-0", classStyleProps?.popoverContent)}
|
||||||
"p-0",
|
|
||||||
classStyleProps?.popoverContent
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${popoverWidth}px`,
|
width: `${popoverWidth}px`,
|
||||||
minWidth: `${popoverWidth}px`,
|
minWidth: `${popoverWidth}px`,
|
||||||
@@ -300,7 +303,9 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
|||||||
key={option.id}
|
key={option.id}
|
||||||
value={`${option.text} ${option.id}`}
|
value={`${option.text} ${option.id}`}
|
||||||
onSelect={() => toggleTag(option)}
|
onSelect={() => toggleTag(option)}
|
||||||
className={classStyleProps?.commandItem}
|
className={
|
||||||
|
classStyleProps?.commandItem
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface TagInputProps
|
|||||||
autocompleteFilter?: (option: string) => boolean;
|
autocompleteFilter?: (option: string) => boolean;
|
||||||
direction?: "row" | "column";
|
direction?: "row" | "column";
|
||||||
onInputChange?: (value: string) => void;
|
onInputChange?: (value: string) => void;
|
||||||
|
searchQuery?: string;
|
||||||
|
onSearchQueryChange?: (value: string) => void;
|
||||||
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
||||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
@@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
usePortal = false,
|
usePortal = false,
|
||||||
addOnPaste = false,
|
addOnPaste = false,
|
||||||
generateTagId = uuid
|
generateTagId = uuid,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const isControlled = searchQuery !== undefined;
|
||||||
|
const effectiveQuery = isControlled ? searchQuery : inputValue;
|
||||||
|
|
||||||
|
const updateQuery = React.useCallback(
|
||||||
|
(action: React.SetStateAction<string>) => {
|
||||||
|
const resolved =
|
||||||
|
typeof action === "function" ? action(effectiveQuery) : action;
|
||||||
|
if (!isControlled) setInputValue(resolved);
|
||||||
|
onSearchQueryChange?.(resolved);
|
||||||
|
},
|
||||||
|
[isControlled, effectiveQuery, onSearchQueryChange]
|
||||||
|
);
|
||||||
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
|
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setInputValue("");
|
updateQuery("");
|
||||||
} else {
|
} else {
|
||||||
setInputValue(newValue);
|
updateQuery(newValue);
|
||||||
}
|
}
|
||||||
onInputChange?.(newValue);
|
onInputChange?.(newValue);
|
||||||
};
|
};
|
||||||
@@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||||
if (addTagsOnBlur && inputValue.trim()) {
|
if (addTagsOnBlur && effectiveQuery.trim()) {
|
||||||
const newTagText = inputValue.trim();
|
const newTagText = effectiveQuery.trim();
|
||||||
|
|
||||||
if (validateTag && !validateTag(newTagText)) {
|
if (validateTag && !validateTag(newTagText)) {
|
||||||
return;
|
return;
|
||||||
@@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
setTags([...tags, { id: newTagId, text: newTagText }]);
|
setTags([...tags, { id: newTagId, text: newTagText }]);
|
||||||
onTagAdd?.(newTagText);
|
onTagAdd?.(newTagText);
|
||||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||||
setInputValue("");
|
updateQuery("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
: e.key === delimiter || e.key === Delimiter.Enter
|
: e.key === delimiter || e.key === Delimiter.Enter
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newTagText = inputValue.trim();
|
const newTagText = effectiveQuery.trim();
|
||||||
|
|
||||||
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
||||||
if (
|
if (
|
||||||
@@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
onTagAdd?.(newTagText);
|
onTagAdd?.(newTagText);
|
||||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||||
}
|
}
|
||||||
setInputValue("");
|
updateQuery("");
|
||||||
} else {
|
} else {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Delete":
|
case "Delete":
|
||||||
@@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
onClearAll?.();
|
onClearAll?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// const filteredAutocompleteOptions = autocompleteFilter
|
|
||||||
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
|
|
||||||
// : autocompleteOptions;
|
|
||||||
const displayedTags = sortTags ? [...tags].sort() : tags;
|
const displayedTags = sortTags ? [...tags].sort() : tags;
|
||||||
|
|
||||||
const truncatedTags = truncate
|
const truncatedTags = truncate
|
||||||
@@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
|
className={cn(
|
||||||
|
`w-full flex`,
|
||||||
|
!inlineTags && tags.length > 0 && "gap-3",
|
||||||
inputFieldPosition === "bottom"
|
inputFieldPosition === "bottom"
|
||||||
? "flex-col"
|
? "flex-col"
|
||||||
: inputFieldPosition === "top"
|
: inputFieldPosition === "top"
|
||||||
? "flex-col-reverse"
|
? "flex-col-reverse"
|
||||||
: "flex-row"
|
: "flex-row"
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{!usePopoverForTags &&
|
{!usePopoverForTags &&
|
||||||
(!inlineTags ? (
|
(!inlineTags ? (
|
||||||
@@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||||
// className,
|
// className,
|
||||||
styleClasses?.input
|
styleClasses?.input
|
||||||
)}
|
)}
|
||||||
@@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{enableAutocomplete ? (
|
{enableAutocomplete ? (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
tags={tags}
|
tags={tags}
|
||||||
setTags={setTags}
|
setTags={setTags}
|
||||||
setInputValue={setInputValue}
|
setInputValue={updateQuery}
|
||||||
autocompleteOptions={
|
autocompleteOptions={
|
||||||
(autocompleteOptions || []) as Tag[]
|
(autocompleteOptions || []) as Tag[]
|
||||||
}
|
}
|
||||||
filterQuery={inputValue}
|
filterQuery={effectiveQuery}
|
||||||
setTagCount={setTagCount}
|
setTagCount={setTagCount}
|
||||||
maxTags={maxTags}
|
maxTags={maxTags}
|
||||||
onTagAdd={onTagAdd}
|
onTagAdd={onTagAdd}
|
||||||
@@ -579,7 +595,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
// <CommandInput
|
// <CommandInput
|
||||||
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
// ref={inputRef}
|
// ref={inputRef}
|
||||||
// value={inputValue}
|
// value={effectiveQuery}
|
||||||
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
// onChangeCapture={handleInputChange}
|
// onChangeCapture={handleInputChange}
|
||||||
// onKeyDown={handleKeyDown}
|
// onKeyDown={handleKeyDown}
|
||||||
@@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
"border-0 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||||
// className,
|
// className,
|
||||||
styleClasses?.input
|
styleClasses?.input
|
||||||
)}
|
)}
|
||||||
@@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
{/* <CommandInput
|
{/* <CommandInput
|
||||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
onChangeCapture={handleInputChange}
|
onChangeCapture={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||||
// className,
|
// className,
|
||||||
styleClasses?.input
|
styleClasses?.input
|
||||||
)}
|
)}
|
||||||
@@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
{/* <CommandInput
|
{/* <CommandInput
|
||||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
onChangeCapture={handleInputChange}
|
onChangeCapture={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||||
// className,
|
// className,
|
||||||
styleClasses?.input
|
styleClasses?.input
|
||||||
)}
|
)}
|
||||||
@@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
@@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
? placeholderWhenFull
|
? placeholderWhenFull
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
value={inputValue}
|
value={effectiveQuery}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function CommandList({
|
|||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
"max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -96,12 +96,13 @@ function CommandList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty
|
||||||
data-slot="command-empty"
|
data-slot="command-empty"
|
||||||
className="py-6 text-center text-sm"
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -115,7 +116,7 @@ function CommandGroup({
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{(table.getRowModel().rows ?? []).length > 0 ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
|
|||||||
63
src/components/users-selector.tsx
Normal file
63
src/components/users-selector.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||||
|
|
||||||
|
export type SelectedUser = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
ipdName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsersSelectorProps = {
|
||||||
|
orgId: string;
|
||||||
|
selectedUsers?: SelectedUser[];
|
||||||
|
onSelectUsers: (users: SelectedUser[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UsersSelector({
|
||||||
|
orgId,
|
||||||
|
selectedUsers = [],
|
||||||
|
onSelectUsers
|
||||||
|
}: UsersSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||||
|
|
||||||
|
const { data: users = [] } = useQuery(
|
||||||
|
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||||
|
);
|
||||||
|
|
||||||
|
// always include the selected users in the list (if the user isn't searching)
|
||||||
|
const usersShown = useMemo(() => {
|
||||||
|
const allUsers: Array<SelectedUser> = users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
text: getUserDisplayName(u)
|
||||||
|
}));
|
||||||
|
if (debouncedValue.trim().length === 0) {
|
||||||
|
for (const user of selectedUsers) {
|
||||||
|
if (!allUsers.find((u) => u.id === user.id)) {
|
||||||
|
allUsers.unshift(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allUsers;
|
||||||
|
}, [users, selectedUsers, debouncedValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectTagInput
|
||||||
|
buttonText={t("alertingSelectUsers")}
|
||||||
|
searchQuery={userSearchQuery}
|
||||||
|
onSearch={setUserSearchQuery}
|
||||||
|
options={usersShown}
|
||||||
|
value={selectedUsers}
|
||||||
|
onChange={onSelectUsers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ type UserDisplayNameInput =
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
idpName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,16 +22,25 @@ export function getUserDisplayName(input: UserDisplayNameInput): string {
|
|||||||
let email: string | null | undefined;
|
let email: string | null | undefined;
|
||||||
let name: string | null | undefined;
|
let name: string | null | undefined;
|
||||||
let username: string | null | undefined;
|
let username: string | null | undefined;
|
||||||
|
let idpName: string | null | undefined;
|
||||||
|
|
||||||
if ("user" in input) {
|
if ("user" in input) {
|
||||||
email = input.user.email;
|
email = input.user.email;
|
||||||
name = input.user.name;
|
name = input.user.name;
|
||||||
username = input.user.username;
|
username = input.user.username;
|
||||||
|
idpName = input.user.idpName;
|
||||||
} else {
|
} else {
|
||||||
email = input.email;
|
email = input.email;
|
||||||
name = input.name;
|
name = input.name;
|
||||||
username = input.username;
|
username = input.username;
|
||||||
|
idpName = input.idpName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return email || name || username || "";
|
let nameShown = email || name || username || "";
|
||||||
|
|
||||||
|
if (idpName) {
|
||||||
|
nameShown = `${nameShown} (${idpName})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameShown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,24 +125,56 @@ export const orgQueries = {
|
|||||||
return res.data.data.clients;
|
return res.data.data.clients;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
users: ({ orgId }: { orgId: string }) =>
|
users: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "USERS"] as const,
|
queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
pageSize: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListUsersResponse>
|
AxiosResponse<ListUsersResponse>
|
||||||
>(`/org/${orgId}/users`, { signal });
|
>(`/org/${orgId}/users?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
return res.data.data.users;
|
return res.data.data.users;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
roles: ({ orgId }: { orgId: string }) =>
|
roles: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "ROLES"] as const,
|
queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
pageSize: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListRolesResponse>
|
AxiosResponse<ListRolesResponse>
|
||||||
>(`/org/${orgId}/roles`, { signal });
|
>(`/org/${orgId}/roles?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
return res.data.data.roles;
|
return res.data.data.roles;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user