mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-08 05:17:23 +00:00
Merge pull request #2893 from Fredkiss3/feat/roles-and-user-multi-selectors
feat: roles & users selector
This commit is contained in:
@@ -175,26 +175,6 @@ export default function GeneralPage() {
|
||||
}, [variant]);
|
||||
|
||||
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 (
|
||||
availableRoles: { roleId: number; name: string }[]
|
||||
) => {
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
"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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
FormLabel
|
||||
} from "@app/components/ui/form";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { build } from "@server/build";
|
||||
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 { 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({
|
||||
username: z.string(),
|
||||
@@ -59,12 +55,6 @@ export default function AccessControlsPage() {
|
||||
|
||||
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 { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
||||
@@ -97,44 +87,21 @@ export default function AccessControlsPage() {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const allRoleOptions = roles.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}));
|
||||
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
|
||||
|
||||
const paywallMessage =
|
||||
build === "saas"
|
||||
? t("singleRolePerUserPlanNotice")
|
||||
: 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) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -144,7 +111,6 @@ export default function AccessControlsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
const updateRoleRequest = supportsMultipleRolesPerUser
|
||||
@@ -184,7 +150,6 @@ export default function AccessControlsPage() {
|
||||
)
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -203,7 +168,7 @@ export default function AccessControlsPage() {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
action={action}
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
@@ -226,9 +191,7 @@ export default function AccessControlsPage() {
|
||||
<OrgRolesTagField
|
||||
form={form}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
allRoleOptions={allRoleOptions}
|
||||
orgId={orgId as string}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
@@ -236,9 +199,6 @@ export default function AccessControlsPage() {
|
||||
showMultiRolePaywallMessage
|
||||
}
|
||||
paywallMessage={paywallMessage}
|
||||
loading={loading}
|
||||
activeTagIndex={activeRoleTagIndex}
|
||||
setActiveTagIndex={setActiveRoleTagIndex}
|
||||
/>
|
||||
|
||||
{user.idpAutoProvision && (
|
||||
@@ -277,8 +237,8 @@ export default function AccessControlsPage() {
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
form="access-controls-form"
|
||||
>
|
||||
{t("accessControlsSubmit")}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -91,7 +91,7 @@ export default function Page() {
|
||||
"internal"
|
||||
);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||
@@ -311,10 +311,29 @@ export default function Page() {
|
||||
setUserOptions(options);
|
||||
}, [idps, t]);
|
||||
|
||||
async function onSubmitInternal(
|
||||
values: z.infer<typeof internalFormSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
const [, submitInternalAction, isSubmittingInternal] = useActionState(
|
||||
onSubmitInternal,
|
||||
null
|
||||
);
|
||||
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));
|
||||
|
||||
@@ -357,25 +376,24 @@ export default function Page() {
|
||||
|
||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitGoogleAzure(
|
||||
values: z.infer<typeof googleAzureFormSchema>
|
||||
) {
|
||||
async function onSubmitGoogleAzure() {
|
||||
const isValid = await googleAzureForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const values = googleAzureForm.getValues();
|
||||
|
||||
const selectedUserOption = userOptions.find(
|
||||
(opt) => opt.id === selectedOption
|
||||
);
|
||||
if (!selectedUserOption?.idpId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api
|
||||
.put(`/org/${orgId}/user`, {
|
||||
username: values.email, // Use email as username for Google/Azure
|
||||
username: values.email,
|
||||
email: values.email || undefined,
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
@@ -401,20 +419,19 @@ export default function Page() {
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitGenericOidc(
|
||||
values: z.infer<typeof genericOidcFormSchema>
|
||||
) {
|
||||
async function onSubmitGenericOidc() {
|
||||
const isValid = await genericOidcForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const values = genericOidcForm.getValues();
|
||||
|
||||
const selectedUserOption = userOptions.find(
|
||||
(opt) => opt.id === selectedOption
|
||||
);
|
||||
if (!selectedUserOption?.idpId) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||
|
||||
const res = await api
|
||||
@@ -445,8 +462,6 @@ export default function Page() {
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -513,9 +528,9 @@ export default function Page() {
|
||||
<SettingsSectionForm>
|
||||
<Form {...internalForm}>
|
||||
<form
|
||||
onSubmit={internalForm.handleSubmit(
|
||||
onSubmitInternal
|
||||
)}
|
||||
action={
|
||||
submitInternalAction
|
||||
}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
@@ -595,13 +610,7 @@ export default function Page() {
|
||||
<OrgRolesTagField
|
||||
form={internalForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
orgId={orgId as string}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
@@ -611,13 +620,6 @@ export default function Page() {
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeInviteRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveInviteRoleTagIndex
|
||||
}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
@@ -712,9 +714,9 @@ export default function Page() {
|
||||
})() && (
|
||||
<Form {...googleAzureForm}>
|
||||
<form
|
||||
onSubmit={googleAzureForm.handleSubmit(
|
||||
onSubmitGoogleAzure
|
||||
)}
|
||||
action={
|
||||
submitGoogleAzureAction
|
||||
}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
@@ -763,13 +765,7 @@ export default function Page() {
|
||||
<OrgRolesTagField
|
||||
form={googleAzureForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
orgId={orgId as string}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
@@ -779,13 +775,6 @@ export default function Page() {
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeOidcRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveOidcRoleTagIndex
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -808,9 +797,9 @@ export default function Page() {
|
||||
})() && (
|
||||
<Form {...genericOidcForm}>
|
||||
<form
|
||||
onSubmit={genericOidcForm.handleSubmit(
|
||||
onSubmitGenericOidc
|
||||
)}
|
||||
action={
|
||||
submitGenericOidcAction
|
||||
}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
@@ -888,13 +877,7 @@ export default function Page() {
|
||||
<OrgRolesTagField
|
||||
form={genericOidcForm}
|
||||
name="roles"
|
||||
label={t("roles")}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
allRoleOptions={
|
||||
allRoleOptions
|
||||
}
|
||||
orgId={orgId as string}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
@@ -904,13 +887,6 @@ export default function Page() {
|
||||
paywallMessage={
|
||||
invitePaywallMessage
|
||||
}
|
||||
loading={loading}
|
||||
activeTagIndex={
|
||||
activeOidcRoleTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveOidcRoleTagIndex
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
|
||||
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
@@ -180,13 +182,6 @@ export default function ResourceAuthenticationPage() {
|
||||
return [];
|
||||
}, [orgIdps]);
|
||||
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -497,46 +492,27 @@ export default function ResourceAuthenticationPage() {
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
<RolesSelector
|
||||
selectedRoles={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
restrictAdminRole
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.roles
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
onSelectRoles={(
|
||||
newUsers
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -557,23 +533,16 @@ export default function ResourceAuthenticationPage() {
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
<UsersSelector
|
||||
selectedUsers={
|
||||
field.value ??
|
||||
[]
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
}
|
||||
size="sm"
|
||||
setTags={(
|
||||
onSelectUsers={(
|
||||
newUsers
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
@@ -584,19 +553,6 @@ export default function ResourceAuthenticationPage() {
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -50,11 +55,13 @@ import {
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { MachinesSelector } from "./machines-selector";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { UsersSelector } from "./users-selector";
|
||||
import { RolesSelector } from "./roles-selector";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
@@ -1118,6 +1125,30 @@ export function InternalResourceForm({
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -1484,40 +1515,22 @@ export function InternalResourceForm({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
<RolesSelector
|
||||
selectedRoles={
|
||||
field.value ?? []
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.roles ?? []
|
||||
}
|
||||
setTags={(newRoles) =>
|
||||
orgId={orgId}
|
||||
onSelectRoles={(
|
||||
newUsers
|
||||
) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
)
|
||||
}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -1530,43 +1543,21 @@ export function InternalResourceForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
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>
|
||||
<UsersSelector
|
||||
selectedUsers={
|
||||
field.value ?? []
|
||||
}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1580,73 +1571,20 @@ export function InternalResourceForm({
|
||||
<FormLabel>
|
||||
{t("machineClients")}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between w-full",
|
||||
"text-muted-foreground pl-1.5"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<MachinesSelector
|
||||
selectedMachines={
|
||||
field.value ?? []
|
||||
}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(
|
||||
machines
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
machines
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -8,51 +8,42 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type RoleTag = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||
|
||||
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"`. */
|
||||
name?: Path<TFieldValues>;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
allRoleOptions: Tag[];
|
||||
label?: string;
|
||||
supportsMultipleRolesPerUser: boolean;
|
||||
showMultiRolePaywallMessage: boolean;
|
||||
paywallMessage: string;
|
||||
loading?: boolean;
|
||||
activeTagIndex: number | null;
|
||||
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
form,
|
||||
name = "roles" as Path<TFieldValues>,
|
||||
label,
|
||||
placeholder,
|
||||
allRoleOptions,
|
||||
orgId,
|
||||
supportsMultipleRolesPerUser,
|
||||
showMultiRolePaywallMessage,
|
||||
paywallMessage,
|
||||
loading = false,
|
||||
activeTagIndex,
|
||||
setActiveTagIndex
|
||||
disabled
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
|
||||
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
|
||||
const prev = form.getValues(name) as Tag[];
|
||||
const nextValue =
|
||||
typeof updater === "function" ? updater(prev) : updater;
|
||||
function setRoleTags(nextValue: SelectedRole[]) {
|
||||
const prev = form.getValues(name) as SelectedRole[];
|
||||
const next = supportsMultipleRolesPerUser
|
||||
? nextValue
|
||||
: nextValue.length > 1
|
||||
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
tags={field.value}
|
||||
setTags={setRoleTags}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoleOptions}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
disabled={loading}
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value ?? []}
|
||||
onSelectRoles={setRoleTags}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMultiRolePaywallMessage && (
|
||||
|
||||
@@ -16,6 +16,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
import { RolesSelector } from "./roles-selector";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export type RoleMappingRoleOption = {
|
||||
roleId: number;
|
||||
@@ -58,9 +60,8 @@ export default function RoleMappingConfigFields({
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const { orgId } = useParams();
|
||||
|
||||
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
|
||||
const showSingleRoleDisclaimer =
|
||||
@@ -160,23 +161,16 @@ export default function RoleMappingConfigFields({
|
||||
|
||||
{roleMappingMode === "fixedRoles" && (
|
||||
<div className="space-y-2 min-w-0 max-w-full">
|
||||
<TagInput
|
||||
tags={fixedRoleNames.map((name) => ({
|
||||
<RolesSelector
|
||||
selectedRoles={fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevTags = fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}));
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevTags)
|
||||
: nextTags;
|
||||
|
||||
mapRolesByName
|
||||
orgId={orgId as string}
|
||||
onSelectRoles={(nextTags) => {
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
...new Set(nextTags.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
@@ -198,19 +192,6 @@ export default function RoleMappingConfigFields({
|
||||
|
||||
onFixedRoleNamesChange(names);
|
||||
}}
|
||||
activeTagIndex={activeFixedRoleTagIndex}
|
||||
setActiveTagIndex={setActiveFixedRoleTagIndex}
|
||||
placeholder={
|
||||
restrictToOrgRoles
|
||||
? t("roleMappingFixedRolesPlaceholderSelect")
|
||||
: t("roleMappingFixedRolesPlaceholderFreeform")
|
||||
}
|
||||
enableAutocomplete={restrictToOrgRoles}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
/>
|
||||
<FormDescription>
|
||||
{showFreeformRoleNamesHint
|
||||
@@ -352,6 +333,7 @@ function BuilderRuleRow({
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const { orgId } = useParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -378,67 +360,109 @@ function BuilderRuleRow({
|
||||
{t("roleMappingAssignRoles")}
|
||||
</FormLabel>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<TagInput
|
||||
tags={rule.roleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevRoleTags = rule.roleNames.map((name) => ({
|
||||
{restrictToOrgRoles ? (
|
||||
<RolesSelector
|
||||
selectedRoles={rule.roleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}));
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevRoleTags)
|
||||
: nextTags;
|
||||
}))}
|
||||
buttonText={t("roleMappingAssignRoles")}
|
||||
mapRolesByName
|
||||
orgId={orgId as string}
|
||||
onSelectRoles={(nextTags) => {
|
||||
let names = [
|
||||
...new Set(nextTags.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
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 (!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]!];
|
||||
}
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: names
|
||||
});
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={
|
||||
restrictToOrgRoles
|
||||
? t("roleMappingAssignRoles")
|
||||
: t("roleMappingAssignRolesPlaceholderFreeform")
|
||||
}
|
||||
enableAutocomplete={restrictToOrgRoles}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
styleClasses={{
|
||||
inlineTagsContainer: "min-w-0 max-w-full"
|
||||
}}
|
||||
/>
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: names
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TagInput
|
||||
tags={rule.roleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevRoleTags = rule.roleNames.map(
|
||||
(name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
})
|
||||
);
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prevRoleTags)
|
||||
: nextTags;
|
||||
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
rule.roleNames.length > 0
|
||||
) {
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: [
|
||||
rule.roleNames[
|
||||
rule.roleNames.length - 1
|
||||
]!
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
...rule,
|
||||
roleNames: names
|
||||
});
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
placeholder={t(
|
||||
"roleMappingAssignRolesPlaceholderFreeform"
|
||||
)}
|
||||
enableAutocomplete={false}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={false}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
styleClasses={{
|
||||
inlineTagsContainer: "min-w-0 max-w-full"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showFreeformRoleNamesHint && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
"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 {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -23,18 +10,32 @@ import {
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} 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 { 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 { toast } from "@app/hooks/useToast";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BellPlus, BellRing } from "lucide-react";
|
||||
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 {
|
||||
orgId: string;
|
||||
@@ -64,12 +65,7 @@ export default function UptimeAlertSection({
|
||||
const [userTags, setUserTags] = useState<Tag[]>([]);
|
||||
const [roleTags, setRoleTags] = 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<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -80,27 +76,6 @@ export default function UptimeAlertSection({
|
||||
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;
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -227,10 +202,16 @@ export default function UptimeAlertSection({
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.alertingRules}
|
||||
/>
|
||||
<fieldset
|
||||
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-2">
|
||||
@@ -240,65 +221,53 @@ export default function UptimeAlertSection({
|
||||
<Input
|
||||
id="alert-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("uptimeAlertNamePlaceholder")}
|
||||
onChange={(e) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
placeholder={t(
|
||||
"uptimeAlertNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("alertingNotifyUsers")}</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeUserTagIndex}
|
||||
setActiveTagIndex={setActiveUserTagIndex}
|
||||
placeholder={t("alertingSelectUsers")}
|
||||
size="sm"
|
||||
tags={userTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(userTags)
|
||||
: newTags;
|
||||
setUserTags(next as Tag[]);
|
||||
}}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={allUsers}
|
||||
restrictTagsToAutocompleteOptions
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
<Label>
|
||||
{t("alertingNotifyUsers")}
|
||||
</Label>
|
||||
<UsersSelector
|
||||
selectedUsers={userTags}
|
||||
orgId={orgId}
|
||||
onSelectUsers={setUserTags}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("alertingNotifyRoles")}</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeRoleTagIndex}
|
||||
setActiveTagIndex={setActiveRoleTagIndex}
|
||||
placeholder={t("alertingSelectRoles")}
|
||||
size="sm"
|
||||
tags={roleTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(roleTags)
|
||||
: newTags;
|
||||
setRoleTags(next as Tag[]);
|
||||
}}
|
||||
enableAutocomplete
|
||||
autocompleteOptions={allRoles}
|
||||
restrictTagsToAutocompleteOptions
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
<Label>
|
||||
{t("alertingNotifyRoles")}
|
||||
</Label>
|
||||
<RolesSelector
|
||||
selectedRoles={roleTags}
|
||||
restrictAdminRole
|
||||
orgId={orgId}
|
||||
onSelectRoles={setRoleTags}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("uptimeAdditionalEmails")}</Label>
|
||||
<Label>
|
||||
{t("uptimeAdditionalEmails")}
|
||||
</Label>
|
||||
<TagInput
|
||||
activeTagIndex={activeEmailTagIndex}
|
||||
setActiveTagIndex={setActiveEmailTagIndex}
|
||||
placeholder={t("alertingEmailPlaceholder")}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"alertingEmailPlaceholder"
|
||||
)}
|
||||
size="sm"
|
||||
tags={emailTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
typeof newTags ===
|
||||
"function"
|
||||
? newTags(emailTags)
|
||||
: newTags;
|
||||
setEmailTags(next as Tag[]);
|
||||
@@ -306,7 +275,9 @@ export default function UptimeAlertSection({
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
validateTag={(tag) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
||||
tag
|
||||
)
|
||||
}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"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 { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
@@ -21,11 +24,13 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,24 +38,21 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} 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 {
|
||||
type AlertRuleFormAction,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { RolesSelector } from "../roles-selector";
|
||||
import { UsersSelector } from "../users-selector";
|
||||
|
||||
export function AddActionPanel({
|
||||
onAdd
|
||||
@@ -498,12 +500,6 @@ function NotifyActionFields({
|
||||
const t = useTranslations();
|
||||
|
||||
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(
|
||||
orgQueries.users({ orgId })
|
||||
@@ -574,14 +570,6 @@ function NotifyActionFields({
|
||||
hasResolvedTagsRef.current = true;
|
||||
}, [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({
|
||||
control,
|
||||
name: `actions.${index}.emailTags`
|
||||
@@ -596,29 +584,16 @@ function NotifyActionFields({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={setActiveUsersTagIndex}
|
||||
placeholder={t("alertingSelectUsers")}
|
||||
size="sm"
|
||||
tags={userTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(userTags)
|
||||
: newTags;
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
form.setValue(
|
||||
`actions.${index}.userTags`,
|
||||
next as Tag[],
|
||||
newUsers as [Tag, ...Tag[]],
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -632,29 +607,17 @@ function NotifyActionFields({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={setActiveRolesTagIndex}
|
||||
placeholder={t("alertingSelectRoles")}
|
||||
size="sm"
|
||||
tags={roleTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(roleTags)
|
||||
: newTags;
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
restrictAdminRole
|
||||
orgId={orgId}
|
||||
onSelectRoles={(newUsers) => {
|
||||
form.setValue(
|
||||
`actions.${index}.roleTags`,
|
||||
next as Tag[],
|
||||
newUsers as [Tag, ...Tag[]],
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MultiSelectTags } from "./multi-select-tags";
|
||||
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||
|
||||
export type SelectedMachine = Pick<
|
||||
ListClientsResponse["clients"][number],
|
||||
@@ -28,11 +28,13 @@ export function MachinesSelector({
|
||||
|
||||
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
||||
|
||||
const perPage = 7;
|
||||
|
||||
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 allMachines: Array<SelectedMachine> = [...machines];
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
@@ -44,75 +46,32 @@ export function MachinesSelector({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allMachines;
|
||||
}, [machines, selectedMachines, debouncedValue]);
|
||||
|
||||
// const selectedMachinesIds = new Set(
|
||||
// selectedMachines.map((m) => m.clientId)
|
||||
// );
|
||||
|
||||
return (
|
||||
<MultiSelectTags
|
||||
<MultiSelectTagInput
|
||||
buttonText={t("accessClientSelect")}
|
||||
searchPlaceholder={t("search")}
|
||||
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}
|
||||
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,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
} from "../ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type TagValue = { text: string; id: string };
|
||||
|
||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
emptyPlaceholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyPlaceholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchQuery?: string;
|
||||
options: Array<T>;
|
||||
value: Array<T>;
|
||||
onChange: (newValue: Array<T>) => void;
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function MultiSelectTags<T extends TagValue>({
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
emptyPlaceholder,
|
||||
searchPlaceholder,
|
||||
searchQuery,
|
||||
@@ -32,16 +34,19 @@ export function MultiSelectTags<T extends TagValue>({
|
||||
onSearch,
|
||||
onChange
|
||||
}: MultiSelectTagsProps<T>) {
|
||||
const t = useTranslations();
|
||||
const selectedValues = new Set(value.map((v) => v.id));
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={searchPlaceholder ?? t("search")}
|
||||
value={searchQuery}
|
||||
onValueChange={onSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
||||
<CommandEmpty className="text-muted-foreground">
|
||||
{emptyPlaceholder ?? t("noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<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 {
|
||||
Command,
|
||||
@@ -220,7 +226,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<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}
|
||||
>
|
||||
{childrenWithProps}
|
||||
@@ -260,10 +266,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
side="bottom"
|
||||
align="start"
|
||||
forceMount
|
||||
className={cn(
|
||||
"p-0",
|
||||
classStyleProps?.popoverContent
|
||||
)}
|
||||
className={cn("p-0", classStyleProps?.popoverContent)}
|
||||
style={{
|
||||
width: `${popoverWidth}px`,
|
||||
minWidth: `${popoverWidth}px`,
|
||||
@@ -300,7 +303,9 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
key={option.id}
|
||||
value={`${option.text} ${option.id}`}
|
||||
onSelect={() => toggleTag(option)}
|
||||
className={classStyleProps?.commandItem}
|
||||
className={
|
||||
classStyleProps?.commandItem
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface TagInputProps
|
||||
autocompleteFilter?: (option: string) => boolean;
|
||||
direction?: "row" | "column";
|
||||
onInputChange?: (value: string) => void;
|
||||
searchQuery?: string;
|
||||
onSearchQueryChange?: (value: string) => void;
|
||||
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
@@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
disabled = false,
|
||||
usePortal = false,
|
||||
addOnPaste = false,
|
||||
generateTagId = uuid
|
||||
generateTagId = uuid,
|
||||
searchQuery,
|
||||
onSearchQueryChange
|
||||
} = props;
|
||||
|
||||
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 inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
);
|
||||
}
|
||||
});
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
} else {
|
||||
setInputValue(newValue);
|
||||
updateQuery(newValue);
|
||||
}
|
||||
onInputChange?.(newValue);
|
||||
};
|
||||
@@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
};
|
||||
|
||||
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (addTagsOnBlur && inputValue.trim()) {
|
||||
const newTagText = inputValue.trim();
|
||||
if (addTagsOnBlur && effectiveQuery.trim()) {
|
||||
const newTagText = effectiveQuery.trim();
|
||||
|
||||
if (validateTag && !validateTag(newTagText)) {
|
||||
return;
|
||||
@@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
setTags([...tags, { id: newTagId, text: newTagText }]);
|
||||
onTagAdd?.(newTagText);
|
||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
: e.key === delimiter || e.key === Delimiter.Enter
|
||||
) {
|
||||
e.preventDefault();
|
||||
const newTagText = inputValue.trim();
|
||||
const newTagText = effectiveQuery.trim();
|
||||
|
||||
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
||||
if (
|
||||
@@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
onTagAdd?.(newTagText);
|
||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||
}
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case "Delete":
|
||||
@@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
onClearAll?.();
|
||||
};
|
||||
|
||||
// const filteredAutocompleteOptions = autocompleteFilter
|
||||
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
|
||||
// : autocompleteOptions;
|
||||
const displayedTags = sortTags ? [...tags].sort() : tags;
|
||||
|
||||
const truncatedTags = truncate
|
||||
@@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
|
||||
className={cn(
|
||||
`w-full flex`,
|
||||
!inlineTags && tags.length > 0 && "gap-3",
|
||||
inputFieldPosition === "bottom"
|
||||
? "flex-col"
|
||||
: inputFieldPosition === "top"
|
||||
? "flex-col-reverse"
|
||||
: "flex-row"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{!usePopoverForTags &&
|
||||
(!inlineTags ? (
|
||||
@@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
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,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{enableAutocomplete ? (
|
||||
<div className="w-full">
|
||||
<Autocomplete
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
setInputValue={setInputValue}
|
||||
setInputValue={updateQuery}
|
||||
autocompleteOptions={
|
||||
(autocompleteOptions || []) as Tag[]
|
||||
}
|
||||
filterQuery={inputValue}
|
||||
filterQuery={effectiveQuery}
|
||||
setTagCount={setTagCount}
|
||||
maxTags={maxTags}
|
||||
onTagAdd={onTagAdd}
|
||||
@@ -579,7 +595,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
// <CommandInput
|
||||
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
// ref={inputRef}
|
||||
// value={inputValue}
|
||||
// value={effectiveQuery}
|
||||
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
// onChangeCapture={handleInputChange}
|
||||
// onKeyDown={handleKeyDown}
|
||||
@@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
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,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
{/* <CommandInput
|
||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
onChangeCapture={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
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,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
{/* <CommandInput
|
||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
onChangeCapture={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
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,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
@@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
|
||||
@@ -87,7 +87,7 @@ function CommandList({
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -96,12 +96,13 @@ function CommandList({
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -115,7 +116,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{(table.getRowModel().rows ?? []).length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
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;
|
||||
name?: string | null;
|
||||
username?: string | null;
|
||||
idpName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -21,16 +22,25 @@ export function getUserDisplayName(input: UserDisplayNameInput): string {
|
||||
let email: string | null | undefined;
|
||||
let name: string | null | undefined;
|
||||
let username: string | null | undefined;
|
||||
let idpName: string | null | undefined;
|
||||
|
||||
if ("user" in input) {
|
||||
email = input.user.email;
|
||||
name = input.user.name;
|
||||
username = input.user.username;
|
||||
idpName = input.user.idpName;
|
||||
} else {
|
||||
email = input.email;
|
||||
name = input.name;
|
||||
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;
|
||||
}
|
||||
}),
|
||||
users: ({ orgId }: { orgId: string }) =>
|
||||
users: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "USERS"] as const,
|
||||
queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListUsersResponse>
|
||||
>(`/org/${orgId}/users`, { signal });
|
||||
>(`/org/${orgId}/users?${sp.toString()}`, { signal });
|
||||
|
||||
return res.data.data.users;
|
||||
}
|
||||
}),
|
||||
roles: ({ orgId }: { orgId: string }) =>
|
||||
roles: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "ROLES"] as const,
|
||||
queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListRolesResponse>
|
||||
>(`/org/${orgId}/roles`, { signal });
|
||||
>(`/org/${orgId}/roles?${sp.toString()}`, { signal });
|
||||
|
||||
return res.data.data.roles;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user