diff --git a/docker-compose.mailpit.yml b/docker-compose.mailpit.yml new file mode 100644 index 000000000..b801ec735 --- /dev/null +++ b/docker-compose.mailpit.yml @@ -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: diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 58eb7d628..b492a71ef 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -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é", diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 02ac0c417..7d8f41a1e 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -28,6 +28,159 @@ export async function calculateUserClientsForOrgs( trx?: Transaction ): Promise { const execute = async (transaction: Transaction) => { + const orgCache = new Map(); + const adminRoleCache = new Map< + string, + typeof roles.$inferSelect | null + >(); + const exitNodesCache = new Map< + string, + Awaited> + >(); + const isOrgLicensedCache = new Map(); + const existingClientCache = new Map< + string, + typeof clients.$inferSelect | null + >(); + const roleClientAccessCache = new Map(); + const userClientAccessCache = new Map(); + + 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(); + 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 = { 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` diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index adf87eed8..03051b11d 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -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); } } diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index fc8e9b3da..b5415c52d 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -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) { diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 90b89f76f..69d57345c 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -175,26 +175,6 @@ export default function GeneralPage() { }, [variant]); useEffect(() => { - async function fetchRoles() { - const res = await api - .get>(`/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 }[] ) => { diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 9ab9e93fa..717d7f211 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -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( - 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>(`/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) { + 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() {
@@ -226,9 +191,7 @@ export default function AccessControlsPage() { {user.idpAutoProvision && ( @@ -277,8 +237,8 @@ export default function AccessControlsPage() { + + + + + + + {t("resourceAuthMethods")} + + + {t("resourceAuthMethodsDescriptions")} + + + + + {/* Password Protection */} +
+
+ + + {t("resourcePasswordProtection", { + status: authInfo.password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* PIN Code Protection */} +
+
+ + + {t("resourcePincodeProtection", { + status: authInfo.pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header Authentication Protection */} +
+
+ + + {authInfo.headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + + {selectedResourceType === "inline" ? ( @@ -344,3 +1020,216 @@ export default function ResourceAuthenticationPage() { ); } + +type OneTimePasswordFormSectionProps = Pick< + ResourceContextType, + "resource" | "updateResource" +> & { + whitelist: Array<{ email: string }>; + isLoadingWhiteList: boolean; +}; + +function OneTimePasswordFormSection({ + resource, + updateResource, + whitelist, + isLoadingWhiteList +}: OneTimePasswordFormSectionProps) { + const { env } = useEnvContext(); + const [whitelistEnabled, setWhitelistEnabled] = useState( + resource.emailWhitelistEnabled ?? false + ); + + useEffect(() => { + setWhitelistEnabled(resource.emailWhitelistEnabled); + }, [resource.emailWhitelistEnabled]); + + const queryClient = useQueryClient(); + + const [loadingSaveWhitelist, startTransition] = useTransition(); + const whitelistForm = useForm({ + resolver: zodResolver(whitelistSchema), + defaultValues: { emails: [] } + }); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + useEffect(() => { + if (isLoadingWhiteList) return; + + whitelistForm.setValue( + "emails", + whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + }, [isLoadingWhiteList, whitelist, whitelistForm]); + + async function saveWhitelist() { + try { + await api.post(`/resource/${resource.resourceId}`, { + emailWhitelistEnabled: whitelistEnabled + }); + + if (whitelistEnabled) { + await api.post(`/resource/${resource.resourceId}/whitelist`, { + emails: whitelistForm.getValues().emails.map((i) => i.text) + }); + } + + updateResource({ + emailWhitelistEnabled: whitelistEnabled + }); + + toast({ + title: t("resourceWhitelistSave"), + description: t("resourceWhitelistSaveDescription") + }); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) + ); + } catch (e) { + console.error(e); + toast({ + variant: "destructive", + title: t("resourceErrorWhitelistSave"), + description: formatAxiosError( + e, + t("resourceErrorWhitelistSaveDescription") + ) + }); + } + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + + + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + whitelistForm.getValues() + .emails + } + setTags={(newRoles) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + + + )} +
+
+ + + +
+ ); +} diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 73d2d5141..9df1ab6f4 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => { return ( + ( + + + + + + )} + />
{t("roles")} - + orgId={orgId} + onSelectRoles={( + newUsers + ) => { form.setValue( "roles", - newRoles as [ + newUsers as [ Tag, ...Tag[] ] - ) - } - enableAutocomplete - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} + ); + }} /> @@ -1530,43 +1543,21 @@ export function InternalResourceForm({ render={({ field }) => ( {t("users")} - - - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ) - } - enableAutocomplete={true} - autocompleteOptions={ - allUsers - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + /> )} @@ -1580,73 +1571,20 @@ export function InternalResourceForm({ {t("machineClients")} - - - - - - - - { - form.setValue( - "clients", - machines - ); - }} - /> - - + { + form.setValue( + "clients", + machines + ); + }} + /> )} diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index dcd679663..bc8e5a0b5 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -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 = { - form: Pick, "control" | "getValues" | "setValue">; + form: Pick< + UseFormReturn, + "control" | "getValues" | "setValue" + >; + orgId: string; /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ name?: Path; - label: string; - placeholder: string; - allRoleOptions: Tag[]; + label?: string; supportsMultipleRolesPerUser: boolean; showMultiRolePaywallMessage: boolean; paywallMessage: string; - loading?: boolean; - activeTagIndex: number | null; - setActiveTagIndex: Dispatch>; + disabled?: boolean; }; export default function OrgRolesTagField({ form, name = "roles" as Path, label, - placeholder, - allRoleOptions, + orgId, supportsMultipleRolesPerUser, showMultiRolePaywallMessage, paywallMessage, - loading = false, - activeTagIndex, - setActiveTagIndex + disabled }: OrgRolesTagFieldProps) { 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({ name={name} render={({ field }) => ( - {label} + {label ?? t("roles")} - {showMultiRolePaywallMessage && ( diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index d62b7f9e8..906f85f62 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -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" && (
- ({ + ({ 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" /> {showFreeformRoleNamesHint @@ -352,6 +333,7 @@ function BuilderRuleRow({ }) { const t = useTranslations(); const [activeTagIndex, setActiveTagIndex] = useState(null); + const { orgId } = useParams(); return (
- ({ - id: name, - text: name - }))} - setTags={(nextTags) => { - const prevRoleTags = rule.roleNames.map((name) => ({ + {restrictToOrgRoles ? ( + ({ 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 + }); + }} + /> + ) : ( + ({ + 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" + }} + /> + )}
{showFreeformRoleNamesHint && (

diff --git a/src/components/SmartLoginOrgSelector.tsx b/src/components/SmartLoginOrgSelector.tsx index 656cb1ca6..79a43782e 100644 --- a/src/components/SmartLoginOrgSelector.tsx +++ b/src/components/SmartLoginOrgSelector.tsx @@ -147,7 +147,7 @@ export default function SmartLoginOrgSelector({ const response = await generateOidcUrlProxy( idpId, safeRedirect, - orgId, + undefined, forceLogin ); diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index 791bb9ddd..6c9edc923 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -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([]); const [roleTags, setRoleTags] = useState([]); const [emailTags, setEmailTags] = useState([]); - const [activeUserTagIndex, setActiveUserTagIndex] = useState( - null - ); - const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( - 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({

- +
@@ -240,65 +221,53 @@ export default function UptimeAlertSection({ setName(e.target.value)} - placeholder={t("uptimeAlertNamePlaceholder")} + onChange={(e) => + setName(e.target.value) + } + placeholder={t( + "uptimeAlertNamePlaceholder" + )} />
- - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; - setUserTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allUsers} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; - setRoleTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allRoles} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- + { 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"]} /> diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index b374df5f8..d787595ed 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -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(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({ {t("alertingNotifyUsers")} - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; + { form.setValue( `actions.${index}.userTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> @@ -632,29 +607,17 @@ function NotifyActionFields({ {t("alertingNotifyRoles")} - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; + { form.setValue( `actions.${index}.roleTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 99515135e..cfae4c2d8 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -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 = [...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 ( - ({ - ...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 + })) + ); + }} /> - // - // - // - // {t("machineNotFound")} - // - // {machinesShown.map((m) => ( - // { - // let newMachineClients = []; - // if (selectedMachinesIds.has(m.clientId)) { - // newMachineClients = selectedMachines.filter( - // (mc) => mc.clientId !== m.clientId - // ); - // } else { - // newMachineClients = [ - // ...selectedMachines, - // m - // ]; - // } - // onSelectMachines(newMachineClients); - // }} - // > - // - // {`${m.name}`} - // - // ))} - // - // - // ); } diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select/multi-select-content.tsx similarity index 83% rename from src/components/multi-select-tags.tsx rename to src/components/multi-select/multi-select-content.tsx index 2fb9b097d..9f49b41ca 100644 --- a/src/components/multi-select-tags.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -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 = { - emptyPlaceholder: string; - searchPlaceholder: string; + emptyPlaceholder?: string; + searchPlaceholder?: string; searchQuery?: string; options: Array; value: Array; onChange: (newValue: Array) => void; onSearch: (query: string) => void; ref?: Ref; + disabled?: boolean; }; -export function MultiSelectTags({ +export function MultiSelectContent({ emptyPlaceholder, searchPlaceholder, searchQuery, @@ -32,16 +34,19 @@ export function MultiSelectTags({ onSearch, onChange }: MultiSelectTagsProps) { + const t = useTranslations(); const selectedValues = new Set(value.map((v) => v.id)); return ( - {emptyPlaceholder} + + {emptyPlaceholder ?? t("noResults")} + {options.map((option) => ( extends MultiSelectTagsProps { + buttonText?: string; +} + +export function MultiSelectTagInput({ + buttonText, + ...props +}: MultiSelectInputProps) { + const selectedValues = new Set(props.value.map((v) => v.id)); + + return ( + { + if (!open) { + // clear input when popover is closed + props.onSearch(""); + } + }} + > + +
+ + {props.value.map((option) => ( + e.stopPropagation()} + > + {option.text} + + + ))} + {buttonText} + + +
+
+ + + +
+ ); +} diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx new file mode 100644 index 000000000..7f1b62e60 --- /dev/null +++ b/src/components/roles-selector.tsx @@ -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 = 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 ( + + ); +} diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index 916e7aeed..938853a1d 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -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 = ({ >
{childrenWithProps} @@ -260,10 +266,7 @@ export const Autocomplete: React.FC = ({ 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 = ({ key={option.id} value={`${option.text} ${option.id}`} onSelect={() => toggleTag(option)} - className={classStyleProps?.commandItem} + className={ + classStyleProps?.commandItem + } > boolean; direction?: "row" | "column"; onInputChange?: (value: string) => void; + searchQuery?: string; + onSearchQueryChange?: (value: string) => void; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -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) => { + 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(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) => { - 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 (
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) {
) ))} + {enableAutocomplete ? (
= 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) { {/* = 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) { {/* = 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} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8b2b6748a..eab0f517a 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -87,7 +87,7 @@ function CommandList({ ) { return ( ); @@ -115,7 +116,7 @@ function CommandGroup({ ({ ))} - {table.getRowModel().rows?.length ? ( + {(table.getRowModel().rows ?? []).length > 0 ? ( table.getRowModel().rows.map((row) => ( 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 = 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 ( + + ); +} diff --git a/src/lib/getUserDisplayName.ts b/src/lib/getUserDisplayName.ts index e95096c16..508b198c3 100644 --- a/src/lib/getUserDisplayName.ts +++ b/src/lib/getUserDisplayName.ts @@ -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; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index bd6bfae8c..5b783bcff 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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 - >(`/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 - >(`/org/${orgId}/roles`, { signal }); + >(`/org/${orgId}/roles?${sp.toString()}`, { signal }); return res.data.data.roles; }