Merge branch 'dev' into resource-policies

This commit is contained in:
Owen
2026-05-05 18:02:27 -07:00
27 changed files with 1932 additions and 787 deletions

View 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:

View File

@@ -1356,7 +1356,7 @@
"sidebarSites": "Nœuds",
"sidebarApprovals": "Demandes d'approbation",
"sidebarResources": "Ressource",
"sidebarProxyResources": "Publique",
"sidebarProxyResources": "Publiques",
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès",
"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",
"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.",
"manageMachineClients": "Gérer les clients de la machine",
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
"manageMachineClients": "Gérer les machines",
"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",
"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",
@@ -3154,6 +3154,7 @@
"healthCheckTabAdvanced": "Avancé",
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
"uptime30d": "Disponibilité (30j)",
"uptimeNoData": "Aucune donnée",
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
"idpImportDialogTitle": "Importer le fournisseur d'identité",

View File

@@ -28,6 +28,159 @@ export async function calculateUserClientsForOrgs(
trx?: Transaction
): Promise<void> {
const execute = async (transaction: Transaction) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
@@ -54,7 +207,9 @@ export async function calculateUserClientsForOrgs(
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
@@ -64,6 +219,13 @@ export async function calculateUserClientsForOrgs(
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
@@ -71,10 +233,7 @@ export async function calculateUserClientsForOrgs(
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
const org = await getOrg(orgId);
if (!org) {
logger.warn(
@@ -91,11 +250,7 @@ export async function calculateUserClientsForOrgs(
}
// Get admin role for this org (needed for access grants)
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
@@ -105,64 +260,50 @@ export async function calculateUserClientsForOrgs(
}
// Check if a client already exists for this OLM+user+org combination
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olm.olmId)
)
)
.limit(1);
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (existingClient) {
// Ensure admin role has access to the client
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, adminRole.roleId),
eq(
roleClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!existingRoleClient) {
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, userId),
eq(
userClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!existingUserClient) {
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
@@ -175,7 +316,7 @@ export async function calculateUserClientsForOrgs(
}
// Get exit nodes for this org
const exitNodesList = await listExitNodes(orgId);
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
@@ -206,14 +347,11 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await isLicensedOrSubscribed(
userOrg.orgId,
tierMatrix.deviceApprovals
);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
@@ -232,6 +370,10 @@ export async function calculateUserClientsForOrgs(
.insert(clients)
.values(newClientData)
.returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
@@ -257,12 +399,20 @@ export async function calculateUserClientsForOrgs(
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`

View File

@@ -500,7 +500,30 @@ function findAcmeJsonFiles(dirPath: string): string[] {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
results.push(...findAcmeJsonFiles(fullPath));
} else if (entry.isFile() && entry.name === "acme.json") {
} else if (entry.isFile()) {
// check if it is a json file
if (entry.name.endsWith(".json")) {
let raw: string;
try {
raw = fs.readFileSync(fullPath, "utf8");
} catch (err) {
logger.warn(
`acmeCertSync: could not read file "${fullPath}": ${err}`
);
continue;
}
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
);
continue;
}
}
results.push(fullPath);
}
}

View File

@@ -333,23 +333,16 @@ export async function validateOidcCallback(
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
// for (const org of allOrgs) {
// const subscribed = await isSubscribed(
// org.orgId,
// tierMatrix.autoProvisioning
// );
// if (!subscribed) {
// // filter out the org
// allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
// // return next(
// // createHttpError(
// // HttpCode.FORBIDDEN,
// // "This organization's current plan does not support this feature."
// // )
// // );
// }
// }
for (const org of allOrgs) {
const subscribed = await isSubscribed(
org.orgId,
tierMatrix.autoProvisioning
);
if (!subscribed) {
// filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
}
}
} else {
allOrgs = await db.select().from(orgs);
}
@@ -490,7 +483,14 @@ export async function validateOidcCallback(
}
}
await calculateUserClientsForOrgs(existingUser.userId);
calculateUserClientsForOrgs(existingUser.userId).catch(
(err) => {
logger.error(
"Error calculating user clients after removing all orgs for user with no valid IdP mappings",
{ error: err }
);
}
);
return next(
createHttpError(
@@ -512,10 +512,9 @@ export async function validateOidcCallback(
const orgUserCounts: { orgId: string; userCount: number }[] = [];
let userId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
@@ -645,8 +644,15 @@ export async function validateOidcCallback(
userCount: userCount.length
});
}
});
db.transaction(async (trx) => {
await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err }
);
});
for (const orgCount of orgUserCounts) {

View File

@@ -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 }[]
) => {

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -147,7 +147,7 @@ export default function SmartLoginOrgSelector({
const response = await generateOidcUrlProxy(
idpId,
safeRedirect,
orgId,
undefined,
forceLogin
);

View File

@@ -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"]}
/>

View File

@@ -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 />

View File

@@ -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>
);
}

View File

@@ -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

View 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>
);
}

View 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}
/>
);
}

View File

@@ -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(

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View 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}
/>
);
}

View File

@@ -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;
}

View File

@@ -131,24 +131,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;
}