mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-13 19:07:18 +00:00
Merge branch 'dev' into feat/labels-on-sites-and-resources
This commit is contained in:
@@ -134,7 +134,9 @@ export default function AlertingRulesTable({
|
||||
}: AlertingRulesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const envContext = useEnvContext();
|
||||
const api = createApiClient(envContext);
|
||||
const { env } = envContext;
|
||||
const [isRefreshing, startRefresh] = useTransition();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||
@@ -426,9 +428,15 @@ export default function AlertingRulesTable({
|
||||
searchQuery={query}
|
||||
manualFiltering
|
||||
manualSorting
|
||||
onAdd={() => {
|
||||
router.push(`/${orgId}/settings/alerting/create`);
|
||||
}}
|
||||
onAdd={
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
? () => {
|
||||
router.push(
|
||||
`/${orgId}/settings/alerting/create`
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRefresh={refreshList}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
addButtonText={t("alertingAddRule")}
|
||||
|
||||
@@ -47,6 +47,7 @@ type AutoProvisionConfigWidgetProps = {
|
||||
roleMappingFieldIdPrefix?: string;
|
||||
showFreeformRoleNamesHint?: boolean;
|
||||
autoProvisionSwitchId?: string;
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function AutoProvisionConfigWidget({
|
||||
@@ -67,7 +68,8 @@ export default function AutoProvisionConfigWidget({
|
||||
showAutoProvisionSwitch = true,
|
||||
roleMappingFieldIdPrefix = "org-idp-auto-provision",
|
||||
showFreeformRoleNamesHint = false,
|
||||
autoProvisionSwitchId = "auto-provision-toggle"
|
||||
autoProvisionSwitchId = "auto-provision-toggle",
|
||||
orgId
|
||||
}: AutoProvisionConfigWidgetProps) {
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
@@ -106,6 +108,7 @@ export default function AutoProvisionConfigWidget({
|
||||
showFreeformRoleNamesHint={
|
||||
showFreeformRoleNamesHint
|
||||
}
|
||||
orgId={orgId}
|
||||
roleMappingMode={roleMappingMode}
|
||||
onRoleMappingModeChange={onRoleMappingModeChange}
|
||||
roles={roles}
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDown,
|
||||
ExternalLink
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -50,11 +55,13 @@ import {
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { MachinesSelector } from "./machines-selector";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { UsersSelector } from "./users-selector";
|
||||
import { RolesSelector } from "./roles-selector";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
@@ -833,12 +840,16 @@ export function InternalResourceForm({
|
||||
modeCidrKey
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t(
|
||||
modeHttpKey
|
||||
)
|
||||
}
|
||||
...(!disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
value: "http" as const,
|
||||
label: t(
|
||||
modeHttpKey
|
||||
)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
@@ -1484,40 +1495,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 +1523,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 +1551,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>
|
||||
)}
|
||||
|
||||
@@ -129,9 +129,7 @@ export function LayoutSidebar({
|
||||
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
|
||||
|
||||
const showTrial =
|
||||
build === "saas" &&
|
||||
Boolean(orgId) &&
|
||||
subscriptionContext?.isTrial;
|
||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -240,11 +238,16 @@ export function LayoutSidebar({
|
||||
<div className="px-4">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
) : <div className="mt-0.2"></div>}
|
||||
) : (
|
||||
<div className="mt-0.2"></div>
|
||||
)}
|
||||
|
||||
{showTrial && (
|
||||
<div className="px-4">
|
||||
<ShowTrialCard isCollapsed={isSidebarCollapsed} />
|
||||
<ShowTrialCard
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
isOwner={Boolean(currentOrg?.isOwner)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
|
||||
// Update Resource type to include site information
|
||||
type Resource = {
|
||||
@@ -64,6 +65,8 @@ type SiteResource = {
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
@@ -123,6 +126,7 @@ const ResourceFavicon = ({
|
||||
|
||||
// Resource Info component
|
||||
const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
const t = useTranslations();
|
||||
const hasAuthMethods =
|
||||
resource.sso ||
|
||||
resource.password ||
|
||||
@@ -141,7 +145,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
{/* Site Information */}
|
||||
{resource.siteName && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-1.5">Site</div>
|
||||
<div className="text-xs font-medium mb-1.5">
|
||||
{t("site")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Combine className="h-4 w-4 text-foreground shrink-0" />
|
||||
<span className="text-sm">{resource.siteName}</span>
|
||||
@@ -157,7 +163,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
}
|
||||
>
|
||||
<div className="text-xs font-medium mb-1.5">
|
||||
Authentication Methods
|
||||
{t("memberPortalAuthMethods")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{resource.sso && (
|
||||
@@ -166,7 +172,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Single Sign-On (SSO)
|
||||
{t("memberPortalSso")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +182,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Password Protected
|
||||
{t("memberPortalPasswordProtected")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -185,7 +191,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
|
||||
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<span className="text-sm">PIN Code</span>
|
||||
<span className="text-sm">
|
||||
{t("memberPortalPinCode")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.whitelist && (
|
||||
@@ -193,7 +201,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
|
||||
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
|
||||
</div>
|
||||
<span className="text-sm">Email Whitelist</span>
|
||||
<span className="text-sm">
|
||||
{t("memberPortalEmailWhitelist")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,7 +218,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
Resource Disabled
|
||||
{t("memberPortalResourceDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,6 +243,7 @@ const PaginationControls = ({
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}) => {
|
||||
const t = useTranslations();
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
@@ -241,7 +252,11 @@ const PaginationControls = ({
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startItem}-{endItem} of {totalItems} resources
|
||||
{t("memberPortalShowingResources", {
|
||||
start: startItem,
|
||||
end: endItem,
|
||||
total: totalItems
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -253,7 +268,7 @@ const PaginationControls = ({
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
{t("memberPortalPrevious")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -309,7 +324,7 @@ const PaginationControls = ({
|
||||
disabled={currentPage === totalPages}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
{t("memberPortalNext")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -389,13 +404,11 @@ export default function MemberResourcesPortal({
|
||||
response.data.data.siteResources || []
|
||||
);
|
||||
} else {
|
||||
setError("Failed to load resources");
|
||||
setError(t("memberPortalFailedToLoad"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching user resources:", err);
|
||||
setError(
|
||||
"Failed to load resources. Please check your connection and try again."
|
||||
);
|
||||
setError(t("memberPortalFailedToLoadDescription"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
@@ -526,8 +539,8 @@ export default function MemberResourcesPortal({
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
title={t("memberPortalTitle")}
|
||||
description={t("memberPortalDescription")}
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls - Skeleton */}
|
||||
@@ -554,8 +567,8 @@ export default function MemberResourcesPortal({
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
title={t("memberPortalTitle")}
|
||||
description={t("memberPortalDescription")}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
@@ -563,7 +576,7 @@ export default function MemberResourcesPortal({
|
||||
<AlertCircle className="h-16 w-16 text-destructive/60" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">
|
||||
Unable to Load Resources
|
||||
{t("memberPortalUnableToLoad")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{error}
|
||||
@@ -574,7 +587,7 @@ export default function MemberResourcesPortal({
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
{t("memberPortalTryAgain")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -585,8 +598,8 @@ export default function MemberResourcesPortal({
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
title={t("memberPortalTitle")}
|
||||
description={t("memberPortalDescription")}
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls with Refresh */}
|
||||
@@ -595,7 +608,7 @@ export default function MemberResourcesPortal({
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 bg-card"
|
||||
@@ -607,26 +620,28 @@ export default function MemberResourcesPortal({
|
||||
<div className="w-full sm:w-36">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
<SelectValue
|
||||
placeholder={t("memberPortalSortBy")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name-asc">
|
||||
Name A-Z
|
||||
{t("memberPortalSortNameAsc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="name-desc">
|
||||
Name Z-A
|
||||
{t("memberPortalSortNameDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="domain-asc">
|
||||
Domain A-Z
|
||||
{t("memberPortalSortDomainAsc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="domain-desc">
|
||||
Domain Z-A
|
||||
{t("memberPortalSortDomainDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="status-enabled">
|
||||
Enabled First
|
||||
{t("memberPortalSortEnabledFirst")}
|
||||
</SelectItem>
|
||||
<SelectItem value="status-disabled">
|
||||
Disabled First
|
||||
{t("memberPortalSortDisabledFirst")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -644,7 +659,7 @@ export default function MemberResourcesPortal({
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
{t("memberPortalRefresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -663,13 +678,15 @@ export default function MemberResourcesPortal({
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-foreground mb-3">
|
||||
{searchQuery
|
||||
? "No Resources Found"
|
||||
: "No Resources Available"}
|
||||
? t("memberPortalNoResourcesFound")
|
||||
: t("memberPortalNoResourcesAvailable")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{searchQuery
|
||||
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
|
||||
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
|
||||
? t("memberPortalNoResourcesMatchSearch", {
|
||||
query: searchQuery
|
||||
})
|
||||
: t("memberPortalNoResourcesAccess")}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{searchQuery ? (
|
||||
@@ -678,7 +695,7 @@ export default function MemberResourcesPortal({
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
Clear Search
|
||||
{t("memberPortalClearSearch")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -690,7 +707,7 @@ export default function MemberResourcesPortal({
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh Resources
|
||||
{t("memberPortalRefreshResources")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -704,11 +721,12 @@ export default function MemberResourcesPortal({
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Public Resources
|
||||
{t("memberPortalPublicResources")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Web applications and services accessible via
|
||||
browser
|
||||
{t(
|
||||
"memberPortalPublicResourcesDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
@@ -768,9 +786,12 @@ export default function MemberResourcesPortal({
|
||||
resource.domain
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource URL has been copied to your clipboard.",
|
||||
title: t(
|
||||
"memberPortalCopiedToClipboard"
|
||||
),
|
||||
description: t(
|
||||
"memberPortalCopiedUrlDescription"
|
||||
),
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
@@ -791,7 +812,7 @@ export default function MemberResourcesPortal({
|
||||
disabled={!resource.enabled}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
Open Resource
|
||||
{t("memberPortalOpenResource")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -806,11 +827,12 @@ export default function MemberResourcesPortal({
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Combine className="h-5 w-5" />
|
||||
Private Resources
|
||||
{t("memberPortalPrivateResources")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Internal network resources accessible via
|
||||
client
|
||||
{t(
|
||||
"memberPortalPrivateResourcesDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
@@ -843,11 +865,16 @@ export default function MemberResourcesPortal({
|
||||
<InfoPopup>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="text-xs font-medium mb-1.5">
|
||||
Resource Details
|
||||
{t(
|
||||
"memberPortalResourceDetails"
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Mode:
|
||||
{t(
|
||||
"memberPortalMode"
|
||||
)}
|
||||
:
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground capitalize">
|
||||
{
|
||||
@@ -858,7 +885,10 @@ export default function MemberResourcesPortal({
|
||||
{siteResource.protocol && (
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Protocol:
|
||||
{t(
|
||||
"protocol"
|
||||
)}
|
||||
:
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground uppercase">
|
||||
{
|
||||
@@ -869,7 +899,10 @@ export default function MemberResourcesPortal({
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Destination:
|
||||
{t(
|
||||
"memberPortalDestination"
|
||||
)}
|
||||
:
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{
|
||||
@@ -880,7 +913,10 @@ export default function MemberResourcesPortal({
|
||||
{siteResource.alias && (
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Alias:
|
||||
{t(
|
||||
"memberPortalAlias"
|
||||
)}
|
||||
:
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{
|
||||
@@ -891,14 +927,21 @@ export default function MemberResourcesPortal({
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Status:
|
||||
{t(
|
||||
"status"
|
||||
)}
|
||||
:
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{siteResource.enabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
? t(
|
||||
"enabled"
|
||||
)
|
||||
: t(
|
||||
"disabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -907,7 +950,14 @@ export default function MemberResourcesPortal({
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{siteResource.alias ? (
|
||||
{siteResource.mode === "http" &&
|
||||
siteResource.fullDomain ? (
|
||||
/* HTTP mode - show as clickable link */
|
||||
<CopyToClipboard
|
||||
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`}
|
||||
isLink={true}
|
||||
/>
|
||||
) : siteResource.alias ? (
|
||||
<>
|
||||
{/* Alias as primary */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -925,9 +975,13 @@ export default function MemberResourcesPortal({
|
||||
siteResource.alias!
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
title: t(
|
||||
"memberPortalCopiedToClipboard"
|
||||
),
|
||||
description:
|
||||
"Resource alias has been copied to your clipboard.",
|
||||
t(
|
||||
"memberPortalCopiedAliasDescription"
|
||||
),
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
@@ -959,9 +1013,13 @@ export default function MemberResourcesPortal({
|
||||
siteResource.destination
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
title: t(
|
||||
"memberPortalCopiedToClipboard"
|
||||
),
|
||||
description:
|
||||
"Resource destination has been copied to your clipboard.",
|
||||
t(
|
||||
"memberPortalCopiedDestinationDescription"
|
||||
),
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
@@ -973,10 +1031,34 @@ export default function MemberResourcesPortal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<div className="p-6 pt-0 mt-auto space-y-2">
|
||||
{siteResource.mode === "http" &&
|
||||
siteResource.fullDomain ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`,
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
className="w-full h-9"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
!siteResource.enabled
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
{t(
|
||||
"memberPortalOpenResource"
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
<Combine className="h-3.5 w-3.5 mr-2" />
|
||||
Requires Client Connection
|
||||
{t(
|
||||
"memberPortalRequiresClientConnection"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -96,6 +96,7 @@ export type ResourceRow = {
|
||||
targets?: TargetHealth[];
|
||||
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
||||
sites: ResourceSiteRow[];
|
||||
wildcard?: boolean;
|
||||
};
|
||||
|
||||
function StatusIcon({
|
||||
@@ -570,10 +571,14 @@ export default function ProxyResourcesTable({
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
{!resourceRow.wildcard ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
) : (
|
||||
<span>{resourceRow.domain}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
|
||||
export type RoleMappingRoleOption = {
|
||||
roleId: number;
|
||||
@@ -38,6 +39,8 @@ export type RoleMappingConfigFieldsProps = {
|
||||
fieldIdPrefix?: string;
|
||||
/** When true, show extra hint for global default policies (no org role list). */
|
||||
showFreeformRoleNamesHint?: boolean;
|
||||
/** Org ID to use for role lookup. Falls back to URL params when not provided. */
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function RoleMappingConfigFields({
|
||||
@@ -53,14 +56,12 @@ export default function RoleMappingConfigFields({
|
||||
rawExpression,
|
||||
onRawExpressionChange,
|
||||
fieldIdPrefix = "role-mapping",
|
||||
showFreeformRoleNamesHint = false
|
||||
showFreeformRoleNamesHint = false,
|
||||
orgId
|
||||
}: RoleMappingConfigFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
|
||||
const showSingleRoleDisclaimer =
|
||||
@@ -94,6 +95,10 @@ export default function RoleMappingConfigFields({
|
||||
}
|
||||
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
|
||||
|
||||
const [fixedRolesActiveTagIndex, setFixedRolesActiveTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
|
||||
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
|
||||
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
|
||||
@@ -160,58 +165,94 @@ export default function RoleMappingConfigFields({
|
||||
|
||||
{roleMappingMode === "fixedRoles" && (
|
||||
<div className="space-y-2 min-w-0 max-w-full">
|
||||
<TagInput
|
||||
tags={fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prevTags = fixedRoleNames.map((name) => ({
|
||||
{restrictToOrgRoles ? (
|
||||
<RolesSelector
|
||||
selectedRoles={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(nextTags.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
fixedRoleNames.length > 0
|
||||
) {
|
||||
onFixedRoleNamesChange([
|
||||
fixedRoleNames[
|
||||
fixedRoleNames.length - 1
|
||||
]!
|
||||
]);
|
||||
return;
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
fixedRoleNames.length > 0
|
||||
) {
|
||||
onFixedRoleNamesChange([
|
||||
fixedRoleNames[
|
||||
fixedRoleNames.length - 1
|
||||
]!
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onFixedRoleNamesChange(names);
|
||||
}}
|
||||
activeTagIndex={activeFixedRoleTagIndex}
|
||||
setActiveTagIndex={setActiveFixedRoleTagIndex}
|
||||
placeholder={
|
||||
restrictToOrgRoles
|
||||
? t("roleMappingFixedRolesPlaceholderSelect")
|
||||
: t("roleMappingFixedRolesPlaceholderFreeform")
|
||||
}
|
||||
enableAutocomplete={restrictToOrgRoles}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
/>
|
||||
onFixedRoleNamesChange(names);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TagInput
|
||||
tags={fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}))}
|
||||
setTags={(nextTags) => {
|
||||
const prev = fixedRoleNames.map((name) => ({
|
||||
id: name,
|
||||
text: name
|
||||
}));
|
||||
const next =
|
||||
typeof nextTags === "function"
|
||||
? nextTags(prev)
|
||||
: nextTags;
|
||||
|
||||
let names = [
|
||||
...new Set(next.map((tag) => tag.text))
|
||||
];
|
||||
|
||||
if (!supportsMultipleRolesPerUser) {
|
||||
if (
|
||||
names.length === 0 &&
|
||||
fixedRoleNames.length > 0
|
||||
) {
|
||||
onFixedRoleNamesChange([
|
||||
fixedRoleNames[
|
||||
fixedRoleNames.length - 1
|
||||
]!
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (names.length > 1) {
|
||||
names = [names[names.length - 1]!];
|
||||
}
|
||||
}
|
||||
|
||||
onFixedRoleNamesChange(names);
|
||||
}}
|
||||
activeTagIndex={fixedRolesActiveTagIndex}
|
||||
setActiveTagIndex={setFixedRolesActiveTagIndex}
|
||||
placeholder={t(
|
||||
"roleMappingAssignRolesPlaceholderFreeform"
|
||||
)}
|
||||
enableAutocomplete={false}
|
||||
autocompleteOptions={roleOptions}
|
||||
restrictTagsToAutocompleteOptions={false}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
size="sm"
|
||||
styleClasses={{
|
||||
inlineTagsContainer: "min-w-0 max-w-full"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FormDescription>
|
||||
{showFreeformRoleNamesHint
|
||||
? t("roleMappingFixedRolesDescriptionDefaultPolicy")
|
||||
@@ -261,6 +302,7 @@ export default function RoleMappingConfigFields({
|
||||
showFreeformRoleNamesHint={
|
||||
showFreeformRoleNamesHint
|
||||
}
|
||||
orgId={orgId}
|
||||
supportsMultipleRolesPerUser={
|
||||
supportsMultipleRolesPerUser
|
||||
}
|
||||
@@ -337,7 +379,8 @@ function BuilderRuleRow({
|
||||
supportsMultipleRolesPerUser,
|
||||
showRemoveButton,
|
||||
onChange,
|
||||
onRemove
|
||||
onRemove,
|
||||
orgId
|
||||
}: {
|
||||
rule: MappingBuilderRule;
|
||||
roleOptions: Tag[];
|
||||
@@ -349,6 +392,7 @@ function BuilderRuleRow({
|
||||
showRemoveButton: boolean;
|
||||
onChange: (rule: MappingBuilderRule) => void;
|
||||
onRemove: () => void;
|
||||
orgId?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
@@ -378,67 +422,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">
|
||||
|
||||
@@ -17,9 +17,11 @@ import { useTranslations } from "next-intl";
|
||||
const TRIAL_DURATION_DAYS = 10;
|
||||
|
||||
export default function ShowTrialCard({
|
||||
isCollapsed
|
||||
isCollapsed,
|
||||
isOwner = false
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
isOwner?: boolean;
|
||||
}) {
|
||||
const context = useSubscriptionStatusContext();
|
||||
const params = useParams();
|
||||
@@ -32,53 +34,55 @@ export default function ShowTrialCard({
|
||||
|
||||
const now = Date.now();
|
||||
const remainingMs = trialExpiresAt - now;
|
||||
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
|
||||
const remainingDays = Math.max(
|
||||
0,
|
||||
Math.ceil(remainingMs / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100));
|
||||
const progressPct = Math.min(
|
||||
100,
|
||||
Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)
|
||||
);
|
||||
// Inverted: full bar at start, drains to empty as trial ends
|
||||
const displayPct = 100 - progressPct;
|
||||
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
const icon = (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={billingHref}
|
||||
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center rounded-md p-2 text-muted-foreground">
|
||||
<ClockIcon className="h-4 w-4 flex-none" />
|
||||
</Link>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>
|
||||
{remainingDays === 0
|
||||
? t("trialExpired")
|
||||
: t("trialDaysLeftShort", { days: remainingDays })}
|
||||
: t("trialDaysLeftShort", {
|
||||
days: remainingDays
|
||||
})}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
if (isOwner) {
|
||||
return <Link href={billingHref}>{icon}</Link>;
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={billingHref}
|
||||
className={cn(
|
||||
"group cursor-pointer block",
|
||||
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
|
||||
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
|
||||
)}
|
||||
>
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="flex-none size-4 text-muted-foreground" />
|
||||
<p className="font-medium flex-1 leading-tight">
|
||||
{remainingDays === 0
|
||||
? t("trialExpired")
|
||||
: t("trialActive")}
|
||||
{remainingDays === 0 ? t("trialExpired") : t("trialActive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -88,11 +92,37 @@ export default function ShowTrialCard({
|
||||
? t("trialHasEnded")
|
||||
: t("trialDaysRemaining", { count: remainingDays })}
|
||||
</small>
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<span>{t("trialGoToBilling")}</span>
|
||||
<ArrowRight className="flex-none size-3" />
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{t("trialGoToBilling")}</span>
|
||||
<ArrowRight className="flex-none size-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isOwner) {
|
||||
return (
|
||||
<Link
|
||||
href={billingHref}
|
||||
className={cn(
|
||||
"group cursor-pointer block",
|
||||
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function SmartLoginOrgSelector({
|
||||
const response = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
safeRedirect,
|
||||
orgId,
|
||||
undefined,
|
||||
forceLogin
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -52,10 +53,12 @@ export default function UptimeAlertSection({
|
||||
days = 90
|
||||
}: UptimeAlertSectionProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const envContext = useEnvContext();
|
||||
const api = createApiClient(envContext);
|
||||
const queryClient = useQueryClient();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isPaid = isPaidUser(tierMatrix.alertingRules);
|
||||
const { env } = envContext;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState(
|
||||
@@ -64,12 +67,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 +78,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() {
|
||||
@@ -201,7 +178,9 @@ export default function UptimeAlertSection({
|
||||
{t("uptimeSectionDescription", { days })}
|
||||
</SettingsSectionDescription>
|
||||
</div>
|
||||
{alertButton}
|
||||
{!env.flags.disableEnterpriseFeatures
|
||||
? alertButton
|
||||
: null}
|
||||
</div>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -227,10 +206,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 +225,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 +279,9 @@ export default function UptimeAlertSection({
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
validateTag={(tag) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
||||
tag
|
||||
)
|
||||
}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
@@ -21,11 +24,13 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,24 +38,21 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import {
|
||||
type AlertRuleFormAction,
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { RolesSelector } from "../roles-selector";
|
||||
import { UsersSelector } from "../users-selector";
|
||||
|
||||
export function AddActionPanel({
|
||||
onAdd
|
||||
@@ -498,12 +500,6 @@ function NotifyActionFields({
|
||||
const t = useTranslations();
|
||||
|
||||
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
|
||||
orgQueries.users({ orgId })
|
||||
@@ -574,14 +570,6 @@ function NotifyActionFields({
|
||||
hasResolvedTagsRef.current = true;
|
||||
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
|
||||
|
||||
const userTags = (useWatch({
|
||||
control,
|
||||
name: `actions.${index}.userTags`
|
||||
}) ?? []) as Tag[];
|
||||
const roleTags = (useWatch({
|
||||
control,
|
||||
name: `actions.${index}.roleTags`
|
||||
}) ?? []) as Tag[];
|
||||
const emailTags = (useWatch({
|
||||
control,
|
||||
name: `actions.${index}.emailTags`
|
||||
@@ -596,29 +584,16 @@ function NotifyActionFields({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={setActiveUsersTagIndex}
|
||||
placeholder={t("alertingSelectUsers")}
|
||||
size="sm"
|
||||
tags={userTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(userTags)
|
||||
: newTags;
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
form.setValue(
|
||||
`actions.${index}.userTags`,
|
||||
next as Tag[],
|
||||
newUsers as [Tag, ...Tag[]],
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -632,29 +607,17 @@ function NotifyActionFields({
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={setActiveRolesTagIndex}
|
||||
placeholder={t("alertingSelectRoles")}
|
||||
size="sm"
|
||||
tags={roleTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(roleTags)
|
||||
: newTags;
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
restrictAdminRole
|
||||
orgId={orgId}
|
||||
onSelectRoles={(newUsers) => {
|
||||
form.setValue(
|
||||
`actions.${index}.roleTags`,
|
||||
next as Tag[],
|
||||
newUsers as [Tag, ...Tag[]],
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MultiSelectTags } from "./multi-select-tags";
|
||||
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||
|
||||
export type SelectedMachine = Pick<
|
||||
ListClientsResponse["clients"][number],
|
||||
@@ -28,11 +28,13 @@ export function MachinesSelector({
|
||||
|
||||
const [debouncedValue] = useDebounce(machineSearchQuery, 150);
|
||||
|
||||
const perPage = 7;
|
||||
|
||||
const { data: machines = [] } = useQuery(
|
||||
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
|
||||
orgQueries.machineClients({ orgId, perPage, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected machines in the list of machines shown (if the user isn't searching)
|
||||
// always include the selected machines in the list (if the user isn't searching)
|
||||
const machinesShown = useMemo(() => {
|
||||
const allMachines: Array<SelectedMachine> = [...machines];
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
@@ -44,75 +46,32 @@ export function MachinesSelector({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allMachines;
|
||||
}, [machines, selectedMachines, debouncedValue]);
|
||||
|
||||
// const selectedMachinesIds = new Set(
|
||||
// selectedMachines.map((m) => m.clientId)
|
||||
// );
|
||||
|
||||
return (
|
||||
<MultiSelectTags
|
||||
<MultiSelectTagInput
|
||||
buttonText={t("accessClientSelect")}
|
||||
searchPlaceholder={t("search")}
|
||||
emptyPlaceholder={t("machineNotFound")}
|
||||
searchPlaceholder={t("machineSearch")}
|
||||
value={selectedMachines.map((m) => ({
|
||||
...m,
|
||||
text: m.name,
|
||||
id: m.clientId.toString()
|
||||
}))}
|
||||
onChange={(values) => {
|
||||
onSelectMachines(values);
|
||||
}}
|
||||
options={machinesShown.map((m) => ({
|
||||
...m,
|
||||
id: m.clientId.toString(),
|
||||
text: m.name
|
||||
}))}
|
||||
onSearch={setMachineSearchQuery}
|
||||
searchQuery={machineSearchQuery}
|
||||
onSearch={setMachineSearchQuery}
|
||||
options={machinesShown.map((mc) => ({
|
||||
id: mc.clientId.toString(),
|
||||
text: mc.name
|
||||
}))}
|
||||
value={selectedMachines.map((mc) => ({
|
||||
id: mc.clientId.toString(),
|
||||
text: mc.name
|
||||
}))}
|
||||
onChange={(newValues) => {
|
||||
onSelectMachines(
|
||||
newValues.map((v) => ({
|
||||
clientId: Number(v.id),
|
||||
name: v.text
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
// <Command shouldFilter={false}>
|
||||
// <CommandInput
|
||||
// placeholder={t("machineSearch")}
|
||||
// value={machineSearchQuery}
|
||||
// onValueChange={setMachineSearchQuery}
|
||||
// />
|
||||
// <CommandList>
|
||||
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
|
||||
// <CommandGroup>
|
||||
// {machinesShown.map((m) => (
|
||||
// <CommandItem
|
||||
// value={`${m.name}:${m.clientId}`}
|
||||
// key={m.clientId}
|
||||
// onSelect={() => {
|
||||
// let newMachineClients = [];
|
||||
// if (selectedMachinesIds.has(m.clientId)) {
|
||||
// newMachineClients = selectedMachines.filter(
|
||||
// (mc) => mc.clientId !== m.clientId
|
||||
// );
|
||||
// } else {
|
||||
// newMachineClients = [
|
||||
// ...selectedMachines,
|
||||
// m
|
||||
// ];
|
||||
// }
|
||||
// onSelectMachines(newMachineClients);
|
||||
// }}
|
||||
// >
|
||||
// <CheckIcon
|
||||
// className={cn(
|
||||
// "mr-2 h-4 w-4",
|
||||
// selectedMachinesIds.has(m.clientId)
|
||||
// ? "opacity-100"
|
||||
// : "opacity-0"
|
||||
// )}
|
||||
// />
|
||||
// {`${m.name}`}
|
||||
// </CommandItem>
|
||||
// ))}
|
||||
// </CommandGroup>
|
||||
// </CommandList>
|
||||
// </Command>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,24 +6,26 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
} from "../ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type TagValue = { text: string; id: string };
|
||||
|
||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
emptyPlaceholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyPlaceholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchQuery?: string;
|
||||
options: Array<T>;
|
||||
value: Array<T>;
|
||||
onChange: (newValue: Array<T>) => void;
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function MultiSelectTags<T extends TagValue>({
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
emptyPlaceholder,
|
||||
searchPlaceholder,
|
||||
searchQuery,
|
||||
@@ -32,16 +34,19 @@ export function MultiSelectTags<T extends TagValue>({
|
||||
onSearch,
|
||||
onChange
|
||||
}: MultiSelectTagsProps<T>) {
|
||||
const t = useTranslations();
|
||||
const selectedValues = new Set(value.map((v) => v.id));
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={searchPlaceholder ?? t("search")}
|
||||
value={searchQuery}
|
||||
onValueChange={onSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
||||
<CommandEmpty className="text-muted-foreground">
|
||||
{emptyPlaceholder ?? t("noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
98
src/components/multi-select/multi-select-tag-input.tsx
Normal file
98
src/components/multi-select/multi-select-tag-input.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { buttonVariants } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type MultiSelectTagsProps,
|
||||
type TagValue,
|
||||
MultiSelectContent
|
||||
} from "./multi-select-content";
|
||||
|
||||
export interface MultiSelectInputProps<
|
||||
T extends TagValue
|
||||
> extends MultiSelectTagsProps<T> {
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonText,
|
||||
...props
|
||||
}: MultiSelectInputProps<T>) {
|
||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// clear input when popover is closed
|
||||
props.onSearch("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
role="combobox"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline"
|
||||
}),
|
||||
"justify-between w-full inline-flex",
|
||||
"text-muted-foreground pl-1.5 cursor-text",
|
||||
"hover:bg-transparent hover:text-muted-foreground",
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
"overflow-x-auto"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{option.text}
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = props.value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span className="pl-1 font-normal">{buttonText}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MultiSelectContent {...props} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
81
src/components/roles-selector.tsx
Normal file
81
src/components/roles-selector.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||
|
||||
export type SelectedRole = { id: string; text: string };
|
||||
|
||||
export type RolesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedRoles?: SelectedRole[];
|
||||
onSelectRoles: (roles: SelectedRole[]) => void;
|
||||
disabled?: boolean;
|
||||
restrictAdminRole?: boolean;
|
||||
mapRolesByName?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export function RolesSelector({
|
||||
orgId,
|
||||
selectedRoles = [],
|
||||
onSelectRoles,
|
||||
disabled,
|
||||
restrictAdminRole,
|
||||
mapRolesByName,
|
||||
buttonText
|
||||
}: RolesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||
|
||||
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
|
||||
|
||||
const { data: roles = [] } = useQuery(
|
||||
orgQueries.roles({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected roles in the list (if the user isn't searching)
|
||||
const rolesShown = useMemo(() => {
|
||||
let allRoles: Array<SelectedRole & { isAdmin?: boolean }> = roles.map(
|
||||
(r) => ({
|
||||
id: mapRolesByName ? r.name : r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
})
|
||||
);
|
||||
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
for (const role of selectedRoles) {
|
||||
if (!allRoles.find((r) => r.id === role.id)) {
|
||||
allRoles.unshift(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (restrictAdminRole) {
|
||||
allRoles = allRoles.filter((role) => !role.isAdmin);
|
||||
}
|
||||
|
||||
return allRoles;
|
||||
}, [
|
||||
roles,
|
||||
selectedRoles,
|
||||
debouncedValue,
|
||||
restrictAdminRole,
|
||||
mapRolesByName
|
||||
]);
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={buttonText ?? t("alertingSelectRoles")}
|
||||
searchQuery={roleSearchQuery}
|
||||
onSearch={setRoleSearchQuery}
|
||||
options={rolesShown}
|
||||
value={selectedRoles}
|
||||
onChange={onSelectRoles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||
import {
|
||||
Command,
|
||||
@@ -220,7 +226,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-1"
|
||||
ref={triggerContainerRef}
|
||||
>
|
||||
{childrenWithProps}
|
||||
@@ -260,10 +266,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
side="bottom"
|
||||
align="start"
|
||||
forceMount
|
||||
className={cn(
|
||||
"p-0",
|
||||
classStyleProps?.popoverContent
|
||||
)}
|
||||
className={cn("p-0", classStyleProps?.popoverContent)}
|
||||
style={{
|
||||
width: `${popoverWidth}px`,
|
||||
minWidth: `${popoverWidth}px`,
|
||||
@@ -300,7 +303,9 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
key={option.id}
|
||||
value={`${option.text} ${option.id}`}
|
||||
onSelect={() => toggleTag(option)}
|
||||
className={classStyleProps?.commandItem}
|
||||
className={
|
||||
classStyleProps?.commandItem
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface TagInputProps
|
||||
autocompleteFilter?: (option: string) => boolean;
|
||||
direction?: "row" | "column";
|
||||
onInputChange?: (value: string) => void;
|
||||
searchQuery?: string;
|
||||
onSearchQueryChange?: (value: string) => void;
|
||||
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
@@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
disabled = false,
|
||||
usePortal = false,
|
||||
addOnPaste = false,
|
||||
generateTagId = uuid
|
||||
generateTagId = uuid,
|
||||
searchQuery,
|
||||
onSearchQueryChange
|
||||
} = props;
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const isControlled = searchQuery !== undefined;
|
||||
const effectiveQuery = isControlled ? searchQuery : inputValue;
|
||||
|
||||
const updateQuery = React.useCallback(
|
||||
(action: React.SetStateAction<string>) => {
|
||||
const resolved =
|
||||
typeof action === "function" ? action(effectiveQuery) : action;
|
||||
if (!isControlled) setInputValue(resolved);
|
||||
onSearchQueryChange?.(resolved);
|
||||
},
|
||||
[isControlled, effectiveQuery, onSearchQueryChange]
|
||||
);
|
||||
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
);
|
||||
}
|
||||
});
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
} else {
|
||||
setInputValue(newValue);
|
||||
updateQuery(newValue);
|
||||
}
|
||||
onInputChange?.(newValue);
|
||||
};
|
||||
@@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
};
|
||||
|
||||
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (addTagsOnBlur && inputValue.trim()) {
|
||||
const newTagText = inputValue.trim();
|
||||
if (addTagsOnBlur && effectiveQuery.trim()) {
|
||||
const newTagText = effectiveQuery.trim();
|
||||
|
||||
if (validateTag && !validateTag(newTagText)) {
|
||||
return;
|
||||
@@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
setTags([...tags, { id: newTagId, text: newTagText }]);
|
||||
onTagAdd?.(newTagText);
|
||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
: e.key === delimiter || e.key === Delimiter.Enter
|
||||
) {
|
||||
e.preventDefault();
|
||||
const newTagText = inputValue.trim();
|
||||
const newTagText = effectiveQuery.trim();
|
||||
|
||||
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
||||
if (
|
||||
@@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
onTagAdd?.(newTagText);
|
||||
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||
}
|
||||
setInputValue("");
|
||||
updateQuery("");
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case "Delete":
|
||||
@@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
onClearAll?.();
|
||||
};
|
||||
|
||||
// const filteredAutocompleteOptions = autocompleteFilter
|
||||
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
|
||||
// : autocompleteOptions;
|
||||
const displayedTags = sortTags ? [...tags].sort() : tags;
|
||||
|
||||
const truncatedTags = truncate
|
||||
@@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
|
||||
className={cn(
|
||||
`w-full flex`,
|
||||
!inlineTags && tags.length > 0 && "gap-3",
|
||||
inputFieldPosition === "bottom"
|
||||
? "flex-col"
|
||||
: inputFieldPosition === "top"
|
||||
? "flex-col-reverse"
|
||||
: "flex-row"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{!usePopoverForTags &&
|
||||
(!inlineTags ? (
|
||||
@@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
// className,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{enableAutocomplete ? (
|
||||
<div className="w-full">
|
||||
<Autocomplete
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
setInputValue={setInputValue}
|
||||
setInputValue={updateQuery}
|
||||
autocompleteOptions={
|
||||
(autocompleteOptions || []) as Tag[]
|
||||
}
|
||||
filterQuery={inputValue}
|
||||
filterQuery={effectiveQuery}
|
||||
setTagCount={setTagCount}
|
||||
maxTags={maxTags}
|
||||
onTagAdd={onTagAdd}
|
||||
@@ -579,7 +595,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
// <CommandInput
|
||||
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
// ref={inputRef}
|
||||
// value={inputValue}
|
||||
// value={effectiveQuery}
|
||||
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
// onChangeCapture={handleInputChange}
|
||||
// onKeyDown={handleKeyDown}
|
||||
@@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
"border-0 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
// className,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
{/* <CommandInput
|
||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
onChangeCapture={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
// className,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
{/* <CommandInput
|
||||
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||
onChangeCapture={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
"border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
||||
// className,
|
||||
styleClasses?.input
|
||||
)}
|
||||
@@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
@@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
||||
? placeholderWhenFull
|
||||
: placeholder
|
||||
}
|
||||
value={inputValue}
|
||||
value={effectiveQuery}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
|
||||
@@ -87,7 +87,7 @@ function CommandList({
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
"max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -96,12 +96,13 @@ function CommandList({
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -115,7 +116,7 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
{(table.getRowModel().rows ?? []).length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
|
||||
63
src/components/users-selector.tsx
Normal file
63
src/components/users-selector.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||
|
||||
export type SelectedUser = {
|
||||
id: string;
|
||||
text: string;
|
||||
ipdName?: string | null;
|
||||
};
|
||||
|
||||
export type UsersSelectorProps = {
|
||||
orgId: string;
|
||||
selectedUsers?: SelectedUser[];
|
||||
onSelectUsers: (users: SelectedUser[]) => void;
|
||||
};
|
||||
|
||||
export function UsersSelector({
|
||||
orgId,
|
||||
selectedUsers = [],
|
||||
onSelectUsers
|
||||
}: UsersSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||
|
||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||
|
||||
const { data: users = [] } = useQuery(
|
||||
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
// always include the selected users in the list (if the user isn't searching)
|
||||
const usersShown = useMemo(() => {
|
||||
const allUsers: Array<SelectedUser> = users.map((u) => ({
|
||||
id: u.id,
|
||||
text: getUserDisplayName(u)
|
||||
}));
|
||||
if (debouncedValue.trim().length === 0) {
|
||||
for (const user of selectedUsers) {
|
||||
if (!allUsers.find((u) => u.id === user.id)) {
|
||||
allUsers.unshift(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allUsers;
|
||||
}, [users, selectedUsers, debouncedValue]);
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={t("alertingSelectUsers")}
|
||||
searchQuery={userSearchQuery}
|
||||
onSearch={setUserSearchQuery}
|
||||
options={usersShown}
|
||||
value={selectedUsers}
|
||||
onChange={onSelectUsers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user