Merge branch 'dev' into feat/paginate-user-roles-table

This commit is contained in:
miloschwartz
2026-04-20 21:27:51 -07:00
362 changed files with 13561 additions and 4286 deletions

View File

@@ -1,19 +1,33 @@
"use client";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { FormDescription } from "@app/components/ui/form";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import { useTranslations } from "next-intl";
import type { Control } from "react-hook-form";
type Role = {
roleId: number;
name: string;
};
export type IdpOrgMappingFieldBinding = {
control: unknown;
name: string;
labelKey?: string;
};
type AutoProvisionConfigWidgetProps = {
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
@@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = {
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
rawExpression: string;
onRawExpressionChange: (expression: string) => void;
orgMappingField: IdpOrgMappingFieldBinding;
showAutoProvisionSwitch?: boolean;
roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string;
};
export default function AutoProvisionConfigWidget({
@@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({
mappingBuilderRules,
onMappingBuilderRulesChange,
rawExpression,
onRawExpressionChange
onRawExpressionChange,
orgMappingField,
showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle"
}: AutoProvisionConfigWidgetProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const showMappingTabs = showAutoProvisionSwitch === false || autoProvision;
const orgMappingLabelKey =
orgMappingField.labelKey ?? "orgMappingPathOptional";
return (
<div className="space-y-4">
<div className="mb-4">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
</div>
{showAutoProvisionSwitch && (
<div className="mb-4">
<SwitchInput
id={autoProvisionSwitchId}
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
</div>
)}
{autoProvision && (
<RoleMappingConfigFields
fieldIdPrefix="org-idp-auto-provision"
showFreeformRoleNamesHint={false}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={onFixedRoleNamesChange}
mappingBuilderClaimPath={mappingBuilderClaimPath}
onMappingBuilderClaimPathChange={
onMappingBuilderClaimPathChange
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={onMappingBuilderRulesChange}
rawExpression={rawExpression}
onRawExpressionChange={onRawExpressionChange}
/>
{showMappingTabs && (
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{ title: t("roleMapping"), href: "#" },
{ title: t("orgMapping"), href: "#" }
]}
>
<div className="space-y-4 mt-4 p-1">
<RoleMappingConfigFields
fieldIdPrefix={roleMappingFieldIdPrefix}
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={onFixedRoleNamesChange}
mappingBuilderClaimPath={mappingBuilderClaimPath}
onMappingBuilderClaimPathChange={
onMappingBuilderClaimPathChange
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={
onMappingBuilderRulesChange
}
rawExpression={rawExpression}
onRawExpressionChange={onRawExpressionChange}
/>
</div>
<div className="space-y-4 mt-4 p-1">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("defaultMappingsOrgDescription")}
</p>
<FormField
control={
orgMappingField.control as Control<any>
}
name={orgMappingField.name}
render={({ field }) => (
<FormItem>
<FormLabel>
{t(orgMappingLabelKey)}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g., ends_with(email, '@organization.com')"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</HorizontalTabs>
)}
</div>
);

View File

@@ -154,7 +154,7 @@ export default function CreateDomainForm({
const punycodePreview = useMemo(() => {
if (!baseDomain) return "";
const punycode = toPunycode(baseDomain);
const punycode = toPunycode(baseDomain.toLowerCase());
return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]);
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
{build != "oss" && env.flags.usePangolinDns ? (
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
) : null}
<FormField
control={form.control}
name="baseDomain"

View File

@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
<div className="flex justify-center">
<InputOTP
maxLength={9}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
{...field}
value={field.value
.replace(/-/g, "")

View File

@@ -0,0 +1,93 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { domainQueries } from "@app/lib/queries";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DomainInfoCard from "@app/components/DomainInfoCard";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import RestartDomainButton from "@app/components/RestartDomainButton";
import RefreshButton from "@app/components/RefreshButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface DomainPageClientProps {
initialDomain: GetDomainResponse;
initialDnsRecords: GetDNSRecordsResponse;
orgId: string;
domainId: string;
}
export default function DomainPageClient({
initialDomain,
initialDnsRecords,
orgId,
domainId
}: DomainPageClientProps) {
const t = useTranslations();
const { env } = useEnvContext();
const { data: domain, refetch: refetchDomain } = useQuery({
...domainQueries.getDomain({ orgId, domainId }),
initialData: initialDomain
});
const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({
...domainQueries.getDNSRecords({ orgId, domainId }),
initialData: initialDnsRecords
});
const refetchAll = () => {
refetchDomain();
refetchDnsRecords();
};
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
onSuccess={refetchAll}
/>
) : (
<RefreshButton onRefresh={refetchAll} />
)}
</div>
<div className="space-y-6">
{build !== "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable
records={dnsRecords.map((r) => ({
...r,
id: String(r.id)
}))}
type={domain.type}
/>
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Command,
CommandEmpty,
@@ -40,11 +41,15 @@ import {
Check,
CheckCircle2,
ChevronsUpDown,
KeyRound,
Zap
} from "lucide-react";
import { useTranslations } from "next-intl";
import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
type AvailableOption = {
domainNamespaceId: string;
@@ -93,8 +98,15 @@ export default function DomainPicker({
warnOnProvidedDomain = false
}: DomainPickerProps) {
const { env } = useEnvContext();
const { user } = useUserContext();
const api = createApiClient({ env });
const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus();
const requiresPaywall =
build === "saas" &&
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
new Date(user.dateCreated) > new Date("2026-04-13");
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
@@ -509,9 +521,11 @@ export default function DomainPicker({
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
{selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
</div>
) : (
t("domainPickerSelectBaseDomain")
@@ -574,14 +588,23 @@ export default function DomainPicker({
}
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
{orgDomain.type ===
"wildcard"
? t(
"domainPickerVerified"
"domainPickerManual"
)
: t(
"domainPickerUnverified"
: (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
@@ -640,6 +663,7 @@ export default function DomainPicker({
})
}
className="mx-2 rounded-md"
disabled={requiresPaywall}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
@@ -680,6 +704,19 @@ export default function DomainPicker({
</div>
</div>
{requiresPaywall && !hideFreeDomain && (
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" />
<span>
{t("domainPickerFreeDomainsPaidFeature")}
</span>
</div>
</CardContent>
</Card>
)}
{/*showProvidedDomainSearch && build === "saas" && (
<Alert>
<AlertCircle className="h-4 w-4" />

View File

@@ -10,13 +10,12 @@ import {
MoreHorizontal,
RefreshCw
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
@@ -34,6 +33,10 @@ import {
TooltipTrigger
} from "./ui/tooltip";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { toUnicode } from "punycode";
import { durationToMs } from "@app/lib/durationToMs";
export type DomainRow = {
domainId: string;
@@ -59,32 +62,32 @@ export default function DomainsTable({ domains, orgId }: Props) {
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
new Set()
);
const env = useEnvContext();
const api = createApiClient(env);
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const queryClient = useQueryClient();
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const { data: rawDomains, isRefetching, refetch } = useQuery({
...orgQueries.domains({ orgId }),
initialData: domains as any,
refetchInterval: durationToMs(10, "seconds")
});
const tableData = useMemo(
() =>
(rawDomains ?? []).map((d) => ({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
} as DomainRow)),
[rawDomains]
);
const deleteDomain = async (domainId: string) => {
try {
@@ -94,7 +97,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
refetch();
} catch (e) {
toast({
title: t("error"),
@@ -114,7 +117,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
fallback: "Domain verification restarted successfully"
})
});
refreshData();
refetch();
} catch (e) {
toast({
title: t("error"),
@@ -361,16 +364,16 @@ export default function DomainsTable({ domains, orgId }: Props) {
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
refetch();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
data={tableData}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onRefresh={refetch}
isRefreshing={isRefetching}
/>
</>
);

View File

@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard";
import { Badge } from "./ui/badge";
import moment from "moment";
@@ -16,6 +16,12 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import NewPricingLicenseForm from "./NewPricingLicenseForm";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[];
@@ -44,6 +50,7 @@ export default function GenerateLicenseKeysTable({
const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = useState(false);
const [isClearingInstanceName, setIsClearingInstanceName] = useState(false);
useEffect(() => {
if (searchParams.get(GENERATE_QUERY) !== null) {
@@ -63,6 +70,28 @@ export default function GenerateLicenseKeysTable({
refreshData();
};
const clearInstanceName = async (licenseKey: string) => {
setIsClearingInstanceName(true);
try {
await api.post(
`/org/${orgId}/license/${encodeURIComponent(licenseKey)}/clear-instance-name`
);
toast({
title: t("success"),
description: "Instance name cleared successfully"
});
await refreshData();
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error, "Failed to clear instance name"),
variant: "destructive"
});
} finally {
setIsClearingInstanceName(false);
}
};
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
@@ -236,6 +265,39 @@ export default function GenerateLicenseKeysTable({
const termianteAt = row.original.expiresAt;
return moment(termianteAt).format("lll");
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const key = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={
!key.instanceName ||
isClearingInstanceName
}
onClick={() =>
clearInstanceName(key.licenseKey)
}
>
Clear Instance Name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
}
];
@@ -254,6 +316,7 @@ export default function GenerateLicenseKeysTable({
onAdd={() => {
setShowGenerateForm(true);
}}
stickyRightColumn="actions"
/>
<NewPricingLicenseForm

View File

@@ -0,0 +1,773 @@
"use client";
import { useState, useEffect } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { Switch } from "@app/components/ui/switch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Textarea } from "@app/components/ui/textarea";
import { Checkbox } from "@app/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
// ── Types ──────────────────────────────────────────────────────────────────────
export type AuthType = "none" | "bearer" | "basic" | "custom";
export type PayloadFormat = "json_array" | "ndjson" | "json_single";
export interface HttpConfig {
name: string;
url: string;
authType: AuthType;
bearerToken?: string;
basicCredentials?: string;
customHeaderName?: string;
customHeaderValue?: string;
headers: Array<{ key: string; value: string }>;
format: PayloadFormat;
useBodyTemplate: boolean;
bodyTemplate?: string;
}
export interface Destination {
destinationId: number;
orgId: string;
type: string;
config: string;
enabled: boolean;
sendAccessLogs: boolean;
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
createdAt: number;
updatedAt: number;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export const defaultHttpConfig = (): HttpConfig => ({
name: "",
url: "",
authType: "none",
bearerToken: "",
basicCredentials: "",
customHeaderName: "",
customHeaderValue: "",
headers: [],
format: "json_array",
useBodyTemplate: false,
bodyTemplate: ""
});
export function parseHttpConfig(raw: string): HttpConfig {
try {
return { ...defaultHttpConfig(), ...JSON.parse(raw) };
} catch {
return defaultHttpConfig();
}
}
// ── Headers editor ─────────────────────────────────────────────────────────────
interface HeadersEditorProps {
headers: Array<{ key: string; value: string }>;
onChange: (headers: Array<{ key: string; value: string }>) => void;
}
function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
const t = useTranslations();
const addRow = () => onChange([...headers, { key: "", value: "" }]);
const removeRow = (i: number) =>
onChange(headers.filter((_, idx) => idx !== i));
const updateRow = (i: number, field: "key" | "value", val: string) => {
const next = [...headers];
next[i] = { ...next[i], [field]: val };
onChange(next);
};
return (
<div className="space-y-3">
{headers.length === 0 && (
<p className="text-xs text-muted-foreground">
{t("httpDestNoHeadersConfigured")}
</p>
)}
{headers.map((h, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
value={h.key}
onChange={(e) => updateRow(i, "key", e.target.value)}
placeholder={t("httpDestHeaderNamePlaceholder")}
className="flex-1"
/>
<Input
value={h.value}
onChange={(e) =>
updateRow(i, "value", e.target.value)
}
placeholder={t("httpDestHeaderValuePlaceholder")}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRow(i)}
className="shrink-0 h-9 w-9"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{t("httpDestAddHeader")}
</Button>
</div>
);
}
// ── Component ──────────────────────────────────────────────────────────────────
export interface HttpDestinationCredenzaProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Destination | null;
orgId: string;
onSaved: () => void;
}
export function HttpDestinationCredenza({
open,
onOpenChange,
editing,
orgId,
onSaved
}: HttpDestinationCredenzaProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [saving, setSaving] = useState(false);
const [cfg, setCfg] = useState<HttpConfig>(defaultHttpConfig());
const [sendAccessLogs, setSendAccessLogs] = useState(false);
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
useEffect(() => {
if (open) {
setCfg(
editing ? parseHttpConfig(editing.config) : defaultHttpConfig()
);
setSendAccessLogs(editing?.sendAccessLogs ?? false);
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
}
}, [open, editing]);
const update = (patch: Partial<HttpConfig>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const urlError: string | null = (() => {
const raw = cfg.url.trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
if (
parsed.protocol !== "http:" &&
parsed.protocol !== "https:"
) {
return t("httpDestUrlErrorHttpRequired");
}
if (build === "saas" && parsed.protocol !== "https:") {
return t("httpDestUrlErrorHttpsRequired");
}
return null;
} catch {
return t("httpDestUrlErrorInvalid");
}
})();
const isValid =
cfg.name.trim() !== "" &&
cfg.url.trim() !== "" &&
urlError === null;
async function handleSave() {
if (!isValid) return;
setSaving(true);
try {
const payload = {
type: "http",
config: JSON.stringify(cfg),
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
};
if (editing) {
await api.post(
`/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
payload
);
toast({ title: t("httpDestUpdatedSuccess") });
} else {
await api.put(
`/org/${orgId}/event-streaming-destination`,
payload
);
toast({ title: t("httpDestCreatedSuccess") });
}
onSaved();
onOpenChange(false);
} catch (e) {
toast({
variant: "destructive",
title: editing
? t("httpDestUpdateFailed")
: t("httpDestCreateFailed"),
description: formatAxiosError(
e,
t("streamingUnexpectedError")
)
});
} finally {
setSaving(false);
}
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{editing
? t("httpDestEditTitle")
: t("httpDestAddTitle")}
</CredenzaTitle>
<CredenzaDescription>
{editing
? t("httpDestEditDescription")
: t("httpDestAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<HorizontalTabs
clientSide
items={[
{ title: t("httpDestTabSettings"), href: "" },
{ title: t("httpDestTabHeaders"), href: "" },
{ title: t("httpDestTabBody"), href: "" },
{ title: t("httpDestTabLogs"), href: "" }
]}
>
{/* ── Settings tab ────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="dest-name">{t("name")}</Label>
<Input
id="dest-name"
placeholder={t("httpDestNamePlaceholder")}
value={cfg.name}
onChange={(e) =>
update({ name: e.target.value })
}
/>
</div>
{/* URL */}
<div className="space-y-2">
<Label htmlFor="dest-url">
{t("httpDestUrlLabel")}
</Label>
<Input
id="dest-url"
placeholder="https://example.com/webhook"
value={cfg.url}
onChange={(e) =>
update({ url: e.target.value })
}
/>
{urlError && (
<p className="text-xs text-destructive">
{urlError}
</p>
)}
</div>
{/* Authentication */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("httpDestAuthTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestAuthDescription")}
</p>
</div>
<RadioGroup
value={cfg.authType}
onValueChange={(v) =>
update({ authType: v as AuthType })
}
className="gap-2"
>
{/* None */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="none"
id="auth-none"
className="mt-0.5"
/>
<div>
<Label
htmlFor="auth-none"
className="cursor-pointer font-medium"
>
{t("httpDestAuthNoneTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthNoneDescription")}
</p>
</div>
</div>
{/* Bearer */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="bearer"
id="auth-bearer"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-bearer"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBearerTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBearerDescription")}
</p>
</div>
{cfg.authType === "bearer" && (
<Input
placeholder={t("httpDestAuthBearerPlaceholder")}
value={
cfg.bearerToken ?? ""
}
onChange={(e) =>
update({
bearerToken:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Basic */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="basic"
id="auth-basic"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-basic"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBasicTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBasicDescription")}
</p>
</div>
{cfg.authType === "basic" && (
<Input
placeholder={t("httpDestAuthBasicPlaceholder")}
value={
cfg.basicCredentials ??
""
}
onChange={(e) =>
update({
basicCredentials:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Custom */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="custom"
id="auth-custom"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-custom"
className="cursor-pointer font-medium"
>
{t("httpDestAuthCustomTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthCustomDescription")}
</p>
</div>
{cfg.authType === "custom" && (
<div className="flex gap-2">
<Input
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
value={
cfg.customHeaderName ??
""
}
onChange={(e) =>
update({
customHeaderName:
e.target
.value
})
}
className="flex-1"
/>
<Input
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
value={
cfg.customHeaderValue ??
""
}
onChange={(e) =>
update({
customHeaderValue:
e.target
.value
})
}
className="flex-1"
/>
</div>
)}
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Headers tab ──────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestCustomHeadersTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestCustomHeadersDescription")}
</p>
</div>
<HeadersEditor
headers={cfg.headers}
onChange={(headers) => update({ headers })}
/>
</div>
{/* ── Body tab ─────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestBodyTemplateTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestBodyTemplateDescription")}
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="use-body-template"
checked={cfg.useBodyTemplate}
onCheckedChange={(v) =>
update({ useBodyTemplate: v })
}
/>
<Label
htmlFor="use-body-template"
className="cursor-pointer"
>
{t("httpDestEnableBodyTemplate")}
</Label>
</div>
{cfg.useBodyTemplate && (
<div className="space-y-2">
<Label htmlFor="body-template">
{t("httpDestBodyTemplateLabel")}
</Label>
<Textarea
id="body-template"
placeholder={
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "data": {{data}}\n}'
}
value={cfg.bodyTemplate ?? ""}
onChange={(e) =>
update({
bodyTemplate: e.target.value
})
}
className="font-mono text-xs min-h-45 resize-y"
/>
<p className="text-xs text-muted-foreground">
{t("httpDestBodyTemplateHint")}
</p>
</div>
)}
{/* Payload Format */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("httpDestPayloadFormatTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestPayloadFormatDescription")}
</p>
</div>
<RadioGroup
value={cfg.format ?? "json_array"}
onValueChange={(v) =>
update({
format: v as PayloadFormat
})
}
className="gap-2"
>
{/* JSON Array */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_array"
id="fmt-json-array"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-array"
className="cursor-pointer font-medium"
>
{t("httpDestFormatJsonArrayTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatJsonArrayDescription")}
</p>
</div>
</div>
{/* NDJSON */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="ndjson"
id="fmt-ndjson"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-ndjson"
className="cursor-pointer font-medium"
>
{t("httpDestFormatNdjsonTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatNdjsonDescription")}
</p>
</div>
</div>
{/* Single event per request */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_single"
id="fmt-json-single"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-single"
className="cursor-pointer font-medium"
>
{t("httpDestFormatSingleTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatSingleDescription")}
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Logs tab ──────────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestLogTypesTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestLogTypesDescription")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-access"
checked={sendAccessLogs}
onCheckedChange={(v) =>
setSendAccessLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-access"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestAccessLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAccessLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-action"
checked={sendActionLogs}
onCheckedChange={(v) =>
setSendActionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-action"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestActionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestActionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-connection"
checked={sendConnectionLogs}
onCheckedChange={(v) =>
setSendConnectionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-connection"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestConnectionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestConnectionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-request"
checked={sendRequestLogs}
onCheckedChange={(v) =>
setSendRequestLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-request"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestRequestLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestRequestLogsDescription")}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
type="button"
variant="outline"
disabled={saving}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleSave}
loading={saving}
disabled={!isValid || saving}
>
{editing ? t("httpDestSaveChanges") : t("httpDestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -8,23 +8,25 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import type { Env } from "@app/lib/types/env";
export function isIdpGlobalModeBannerVisible(env: Env): boolean {
if (build === "saas") {
return false;
}
return env.app.identityProviderMode === undefined;
}
export function IdpGlobalModeBanner() {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser, hasEnterpriseLicense } = usePaidStatus();
const identityProviderModeUndefined =
env.app.identityProviderMode === undefined;
const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc);
const enterpriseUnlicensed =
build === "enterprise" && !hasEnterpriseLicense;
if (build === "saas") {
return null;
}
if (!identityProviderModeUndefined) {
if (!isIdpGlobalModeBannerVisible(env)) {
return null;
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import {
generateOidcUrlProxy,
type GenerateOidcUrlResponse
@@ -135,24 +135,7 @@ export default function IdpLoginButtons({
disabled={loading}
loading={loading}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { Badge } from "@app/components/ui/badge";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
type IdpTypeBadgeProps = {
type: string;
@@ -29,34 +29,8 @@ export default function IdpTypeBadge({
variant="secondary"
className="inline-flex items-center space-x-1 w-fit"
>
{effectiveType === "google" && (
<>
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "azure" && (
<>
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "oidc" && <span>{effectiveName}</span>}
{!["google", "azure", "oidc"].includes(effectiveType) && (
<span>{effectiveName}</span>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{effectiveName}</span>
</Badge>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@app/lib/cn";
import Image from "next/image";
import { ReactNode } from "react";
type Props = {
type?: string | null;
variant?: string | null;
size?: number;
className?: string;
alt?: string;
fallback?: ReactNode;
};
export default function IdpTypeIcon({
type,
variant,
size = 16,
className,
alt,
fallback = null
}: Props) {
const effectiveType = (variant || type || "").toLowerCase();
let src: string | null = null;
let defaultAlt = "";
if (effectiveType === "google") {
src = "/idp/google.png";
defaultAlt = "Google";
} else if (effectiveType === "azure") {
src = "/idp/azure.png";
defaultAlt = "Azure";
} else if (effectiveType === "oidc") {
src = "/idp/openid.png";
defaultAlt = "OAuth2/OIDC";
}
if (!src) {
return <>{fallback}</>;
}
return (
<Image
src={src}
alt={alt ?? defaultAlt}
width={size}
height={size}
className={cn("shrink-0 rounded", className)}
/>
);
}

View File

@@ -566,19 +566,21 @@ export function InternalResourceForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{variant === "edit" && (
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="siteId"
@@ -612,6 +614,7 @@ export function InternalResourceForm({
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
filterTypes={["newt"]}
onSelectSite={(site) => {
setSelectedSite(site);
field.onChange(site.siteId);
@@ -1173,7 +1176,7 @@ export function InternalResourceForm({
</span>
)
)}
<span className="pl-1">
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}

View File

@@ -39,7 +39,11 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [type, setType] = useState<
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
| "rejected"
| "wrong_user"
| "user_does_not_exist"
| "not_logged_in"
| "user_limit_exceeded"
>("rejected");
useEffect(() => {
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
if (!user && type === "user_does_not_exist") {
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else {
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
async function goToSignup() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
Cannot Accept Invite
</p>
<p className="text-center text-sm">
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
This organization has reached its user limit. Please
contact the organization administrator to upgrade their
plan before accepting this invite.
</p>
</div>
);

View File

@@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-background/83 backdrop-blur-sm" />
<div className="absolute inset-0 bg-background backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">

View File

@@ -24,10 +24,8 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import SidebarLicenseButton from "./SidebarLicenseButton";
import { SidebarSupportButton } from "./SidebarSupportButton";
import { is } from "drizzle-orm";
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
ssr: false
@@ -127,7 +125,7 @@ export function LayoutSidebar({
return (
<div
className={cn(
"hidden md:flex border-r bg-card flex-col h-full shrink-0 relative",
"hidden md:flex border-r bg-sidebar flex-col h-full shrink-0 relative",
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
@@ -156,7 +154,7 @@ export function LayoutSidebar({
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
@@ -193,7 +191,7 @@ export function LayoutSidebar({
/>
</div>
{/* Fade gradient at bottom to indicate scrollable content */}
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-sidebar to-transparent" />
</div>
{isSidebarCollapsed && (
@@ -208,7 +206,7 @@ export function LayoutSidebar({
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
@@ -222,12 +220,17 @@ export function LayoutSidebar({
</div>
)}
<div className="pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border">
{canShowProductUpdates && (
<div
className={cn(
"pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border",
isSidebarCollapsed && "pb-2"
)}
>
{canShowProductUpdates ? (
<div className="px-4">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
)}
) : <div className="mt-0.2"></div>}
{build === "enterprise" && (
<div className="px-4">
@@ -291,7 +294,6 @@ export function LayoutSidebar({
: build === "enterprise"
? t("enterpriseEdition")
: "Pangolin Cloud"}
<FaGithub size={12} />
</Link>
</div>
{build === "enterprise" &&

View File

@@ -53,7 +53,7 @@ export default function LocaleSwitcherSelect({
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<Languages className="text-muted-foreground h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>

View File

@@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { createApiClient } from "@app/lib/api";
import Link from "next/link";
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
@@ -37,6 +36,7 @@ import {
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
// @ts-ignore
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
@@ -393,24 +393,7 @@ export default function LoginForm({
loginWithIdp(idp.idpId);
}}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
);

View File

@@ -1,19 +1,26 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import {
DataTable,
type DataTableAddAction
} from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
}
export function IdpDataTable<TData, TValue>({
columns,
data,
onAdd
onAdd,
addActions,
addButtonDisabled
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -27,6 +34,8 @@ export function IdpDataTable<TData, TValue>({
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
addActions={addActions}
addButtonDisabled={addButtonDisabled}
enableColumnVisibility={true}
stickyRightColumn="actions"
/>

View File

@@ -4,13 +4,37 @@ import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
ArrowRight,
ArrowUpDown,
KeyRound,
MoreHorizontal
} from "lucide-react";
import { useMemo, useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
@@ -21,6 +45,14 @@ import {
import Link from "next/link";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { cn } from "@app/lib/cn";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
export type IdpRow = {
idpId: number;
@@ -29,6 +61,15 @@ export type IdpRow = {
variant?: string;
};
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
function IdpImportRowIcon({
type,
variant
}: Pick<AdminIdpRow, "type" | "variant">) {
return <IdpTypeIcon type={type} variant={variant} size={20} />;
}
type Props = {
idps: IdpRow[];
orgId: string;
@@ -37,10 +78,53 @@ type Props = {
export default function IdpTable({ idps, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false);
const [selectedUnassociateIdp, setSelectedUnassociateIdp] =
useState<IdpRow | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importSearchQuery, setImportSearchQuery] = useState("");
const [importSubmitting, setImportSubmitting] = useState(false);
const [debouncedImportSearch] = useDebounce(importSearchQuery, 150);
const envContext = useEnvContext();
const api = createApiClient(envContext);
const { user } = useUserContext();
const { isPaidUser } = usePaidStatus();
const router = useRouter();
const t = useTranslations();
const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc);
const addIdpDisabled = isIdpGlobalModeBannerVisible(envContext.env);
const { data: adminIdpsRaw = [] } = useQuery({
queryKey: ["admin-org-idps", user.userId],
queryFn: async () => {
const res = await api.get<{
data: ListUserAdminOrgIdpsResponse;
}>(`/user/${user.userId}/admin-org-idps`);
return res.data.data.idps;
},
enabled: importDialogOpen && !!user?.userId
});
const importableIdps = useMemo(() => {
const localIds = new Set(idps.map((i) => i.idpId));
return adminIdpsRaw.filter(
(row) => row.orgId !== orgId && !localIds.has(row.idpId)
);
}, [adminIdpsRaw, orgId, idps]);
const shownImportIdps = useMemo(() => {
const q = debouncedImportSearch.trim().toLowerCase();
if (!q) {
return importableIdps;
}
return importableIdps.filter((row) => {
const hay = `${row.orgName} ${row.name}`.toLowerCase();
return hay.includes(q);
});
}, [importableIdps, debouncedImportSearch]);
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}`);
@@ -59,24 +143,50 @@ export default function IdpTable({ idps, orgId }: Props) {
}
};
const importIdp = async (row: AdminIdpRow) => {
setImportSubmitting(true);
try {
await api.post(`/org/${orgId}/idp/${row.idpId}/import`, {
sourceOrgId: row.orgId
});
toast({
title: t("success"),
description: t("idpImportedDescription")
});
setImportDialogOpen(false);
setImportSearchQuery("");
router.refresh();
router.push(`/${orgId}/settings/idp/${row.idpId}/general`);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setImportSubmitting(false);
}
};
const unassociateIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}/association`);
toast({
title: t("success"),
description: t("idpUnassociatedDescription")
});
setIsUnassociateModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
@@ -142,6 +252,14 @@ export default function IdpTable({ idps, orgId }: Props) {
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedUnassociateIdp(siteRow);
setIsUnassociateModalOpen(true);
}}
>
{t("idpUnassociateMenu")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
@@ -149,7 +267,7 @@ export default function IdpTable({ idps, orgId }: Props) {
}}
>
<span className="text-red-500">
{t("delete")}
{t("idpDeleteAllOrgsMenu")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -179,8 +297,8 @@ export default function IdpTable({ idps, orgId }: Props) {
}}
dialog={
<div className="space-y-2">
<p>{t("idpQuestionRemove")}</p>
<p>{t("idpMessageRemove")}</p>
<p>{t("idpDeleteGlobalQuestion")}</p>
<p>{t("idpDeleteGlobalDescription")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
@@ -189,11 +307,127 @@ export default function IdpTable({ idps, orgId }: Props) {
title={t("idpDelete")}
/>
)}
{selectedUnassociateIdp && (
<ConfirmDeleteDialog
open={isUnassociateModalOpen}
setOpen={(val) => {
setIsUnassociateModalOpen(val);
setSelectedUnassociateIdp(null);
}}
dialog={
<div className="space-y-2">
<p>{t("idpUnassociateQuestion")}</p>
<p>{t("idpUnassociateDescription")}</p>
</div>
}
buttonText={t("idpUnassociateConfirm")}
onConfirm={async () =>
unassociateIdp(selectedUnassociateIdp.idpId)
}
string={selectedUnassociateIdp.name}
title={t("idpUnassociateTitle")}
warningText={t("idpUnassociateWarning")}
/>
)}
<Credenza
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open);
if (!open) {
setImportSearchQuery("");
}
}}
>
<CredenzaContent className="sm:max-w-lg">
<CredenzaHeader>
<CredenzaTitle>
{t("idpImportDialogTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("idpImportDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody
className={cn(
importSubmitting && "pointer-events-none opacity-60"
)}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("idpImportSearchPlaceholder")}
value={importSearchQuery}
onValueChange={setImportSearchQuery}
/>
<CommandList>
<CommandEmpty>
{t("idpImportEmpty")}
</CommandEmpty>
<CommandGroup>
{shownImportIdps.map((row) => (
<CommandItem
key={`${row.idpId}:${row.orgId}`}
className="items-start gap-3 py-2.5"
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`}
disabled={!canImportOrgOidcIdp}
onSelect={() => {
if (!canImportOrgOidcIdp) {
return;
}
void importIdp(row);
}}
>
<div className="mt-0.5 shrink-0">
<IdpImportRowIcon
type={row.type}
variant={row.variant}
/>
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium leading-tight">
{row.orgName}
</div>
<div className="truncate text-sm leading-tight text-muted-foreground">
{row.name}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
disabled={importSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
<IdpDataTable
columns={columns}
data={idps}
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
addButtonDisabled={addIdpDisabled}
addActions={[
{
label: t("idpAddActionCreateNew"),
onSelect: () => {
router.push(`/${orgId}/settings/idp/create`);
}
},
{
label: t("idpAddActionImportFromOrg"),
onSelect: () => {
setImportDialogOpen(true);
}
}
]}
/>
</>
);

View File

@@ -76,8 +76,8 @@ export function OrgSelector({
className={cn(
"cursor-pointer transition-colors",
isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-muted"
: "w-full px-5 py-4 hover:bg-muted"
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
: "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
)}
>
{isCollapsed ? (
@@ -172,7 +172,7 @@ export function OrgSelector({
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
className="w-full justify-start h-8 font-normal text-muted-foreground"
onClick={() => {
setOpen(false);
router.push("/setup");

View File

@@ -10,7 +10,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tier } from "@server/types/Tiers";
import { useParams } from "next/navigation";
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
// const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
const TIER_ORDER: Tier[] = ["tier2", "tier3", "enterprise"];
const TIER_TRANSLATION_KEYS: Record<
Tier,

View File

@@ -15,6 +15,8 @@ import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
@@ -63,6 +65,10 @@ export default function PendingSitesTable({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const canUseSiteProvisioning =
isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
build !== "oss";
const booleanSearchFilterSchema = z
.enum(["true", "false"])
@@ -327,7 +333,8 @@ export default function PendingSitesTable({
"jupiter",
"saturn",
"uranus",
"neptune"
"neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
@@ -450,6 +457,7 @@ export default function PendingSitesTable({
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
refreshButtonDisabled={!canUseSiteProvisioning}
rowCount={rowCount}
columnVisibility={{
niceId: false,

View File

@@ -103,29 +103,27 @@ export default function ProductUpdates({
)}
>
<div className="flex flex-col gap-1">
<small
className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2 empty:mt-0",
showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"
)}
>
{filteredUpdates.length > 1 && (
<>
<BellIcon className="flex-none size-3" />
<span>
{showNewVersionPopup
? t("productUpdateMoreInfo", {
noOfUpdates: filteredUpdates.length
})
: t("productUpdateInfo", {
noOfUpdates: filteredUpdates.length
})}
</span>
</>
)}
</small>
{filteredUpdates.length > 1 && (
<small
className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"
)}
>
<BellIcon className="flex-none size-3" />
<span>
{showNewVersionPopup
? t("productUpdateMoreInfo", {
noOfUpdates: filteredUpdates.length
})
: t("productUpdateInfo", {
noOfUpdates: filteredUpdates.length
})}
</span>
</small>
)}
<ProductUpdatesListPopup
updates={filteredUpdates}
show={filteredUpdates.length > 0}
@@ -378,7 +376,7 @@ function NewVersionAvailable({
<span>
{t("pangolinUpdateAvailableReleaseNotes")}
</span>
<ArrowRight className="flex-none size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-1" />
<ArrowRight className="flex-none size-3" />
</div>
</div>
</a>

View File

@@ -54,6 +54,7 @@ export type TargetHealth = {
port: number;
enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
};
export type ResourceRow = {
@@ -274,7 +275,9 @@ export default function ProxyResourcesTable({
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
@@ -301,7 +304,9 @@ export default function ProxyResourcesTable({
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled

View File

@@ -7,7 +7,11 @@ import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
import { toast } from "@app/hooks/useToast";
export default function RefreshButton() {
interface RefreshButtonProps {
onRefresh?: () => void;
}
export default function RefreshButton({ onRefresh }: RefreshButtonProps = {}) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const t = useTranslations();
@@ -16,7 +20,11 @@ export default function RefreshButton() {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
if (onRefresh) {
onRefresh();
} else {
router.refresh();
}
} catch {
toast({
title: t("error"),

View File

@@ -12,11 +12,13 @@ import { useTranslations } from "next-intl";
interface RestartDomainButtonProps {
orgId: string;
domainId: string;
onSuccess?: () => void;
}
export default function RestartDomainButton({
orgId,
domainId
domainId,
onSuccess
}: RestartDomainButtonProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
@@ -35,7 +37,11 @@ export default function RestartDomainButton({
});
// Wait a bit before refreshing to allow the restart to take effect
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.refresh();
}
} catch (e) {
toast({
title: t("error"),

View File

@@ -79,10 +79,7 @@ export default function RoleMappingConfigFields({
);
useEffect(() => {
if (
!supportsMultipleRolesPerUser &&
mappingBuilderRules.length > 1
) {
if (!supportsMultipleRolesPerUser && mappingBuilderRules.length > 1) {
onMappingBuilderRulesChange([mappingBuilderRules[0]]);
}
}, [
@@ -95,11 +92,7 @@ export default function RoleMappingConfigFields({
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
onFixedRoleNamesChange([fixedRoleNames[0]]);
}
}, [
supportsMultipleRolesPerUser,
fixedRoleNames,
onFixedRoleNamesChange
]);
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
@@ -116,7 +109,6 @@ export default function RoleMappingConfigFields({
return (
<div className="space-y-4">
<div>
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
<FormDescription className="mb-4">
{t("roleMappingDescription")}
</FormDescription>
@@ -272,7 +264,9 @@ export default function RoleMappingConfigFields({
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showRemoveButton={mappingBuilderShowsRemoveColumn}
showRemoveButton={
mappingBuilderShowsRemoveColumn
}
rule={rule}
onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map(
@@ -390,12 +384,10 @@ function BuilderRuleRow({
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map(
(name) => ({
id: name,
text: name
})
);
const prevRoleTags = rule.roleNames.map((name) => ({
id: name,
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prevRoleTags)

View File

@@ -10,6 +10,7 @@ import { Button } from "./ui/button";
import { TicketCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
import Link from "next/link";
interface SidebarLicenseButtonProps {
@@ -20,8 +21,11 @@ export default function SidebarLicenseButton({
isCollapsed = false
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const { user } = useUserContext();
const url = "https://docs.pangolin.net/self-host/enterprise-edition";
const url = user?.serverAdmin
? "/admin/license"
: "https://docs.pangolin.net/self-host/enterprise-edition";
const t = useTranslations();

View File

@@ -121,8 +121,8 @@ function CollapsibleNavItem({
"flex items-center w-full rounded-md transition-colors",
"px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
@@ -256,8 +256,8 @@ function CollapsedNavItemWithPopover({
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
@@ -308,8 +308,8 @@ function CollapsedNavItemWithPopover({
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
@@ -450,8 +450,8 @@ export function SidebarNav({
"flex items-center rounded-md transition-colors relative",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
onClick={(e) => {

View File

@@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSections cols={site.endpoint ? 4 : 3}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{site.niceId}</InfoSectionContent>
@@ -68,6 +68,18 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection>
{site.endpoint && (
<InfoSection>
<InfoSectionTitle>
{t("publicIpEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
{site.endpoint.includes(":")
? site.endpoint.substring(0, site.endpoint.lastIndexOf(":"))
: site.endpoint}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -311,6 +311,7 @@ export default function SiteProvisioningKeysTable({
addButtonDisabled={!canUseSiteProvisioning}
onRefresh={refreshData}
isRefreshing={isRefreshing}
refreshButtonDisabled={!canUseSiteProvisioning}
addButtonText={t("provisioningKeysAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"

View File

@@ -342,7 +342,8 @@ export default function SitesTable({
"jupiter",
"saturn",
"uranus",
"neptune"
"neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {

View File

@@ -388,7 +388,7 @@ export default function UserDevicesTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
friendlyName: t("connected"),
header: () => {
return (
<ColumnFilterButton
@@ -410,7 +410,7 @@ export default function UserDevicesTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
label={t("connected")}
className="p-3"
/>
);

View File

@@ -27,7 +27,7 @@ import {
TableHeader,
TableRow
} from "@app/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -58,7 +58,6 @@ export default function ViewDevicesDialog({
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
const fetchDevices = async () => {
setLoading(true);
@@ -177,34 +176,21 @@ export default function ViewDevicesDialog({
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "available" | "archived")
}
className="w-full"
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{
title: `${t("available") || "Available"} (${devices.filter((d) => !d.archived).length})`,
href: "#available"
},
{
title: `${t("archived") || "Archived"} (${devices.filter((d) => d.archived).length})`,
href: "#archived"
}
]}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="available">
{t("available") || "Available"} (
{
devices.filter(
(d) => !d.archived
).length
}
)
</TabsTrigger>
<TabsTrigger value="archived">
{t("archived") || "Archived"} (
{
devices.filter(
(d) => d.archived
).length
}
)
</TabsTrigger>
</TabsList>
<TabsContent value="available" className="mt-4">
<div>
{devices.filter((d) => !d.archived)
.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
@@ -271,8 +257,8 @@ export default function ViewDevicesDialog({
</Table>
</div>
)}
</TabsContent>
<TabsContent value="archived" className="mt-4">
</div>
<div>
{devices.filter((d) => d.archived)
.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
@@ -336,8 +322,8 @@ export default function ViewDevicesDialog({
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
</HorizontalTabs>
)}
</CredenzaBody>
<CredenzaFooter>

View File

@@ -1,6 +1,4 @@
/**
* Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx
*/
// Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx
import { cn } from "@app/lib/cn";
import worldJson from "visionscarto-world-atlas/world/110m.json";
import * as topojson from "topojson-client";
@@ -164,7 +162,7 @@ const countryClass = cn(
const highlightedCountryClass = cn(
sharedCountryClass,
"stroke-2",
"stroke-[3]",
"fill-[#f4f4f5]",
"stroke-[#f36117]",
"dark:fill-[#3f3f46]"
@@ -194,11 +192,20 @@ function drawInteractiveCountries(
const path = setupProjetionPath();
const data = parseWorldTopoJsonToGeoJsonFeatures();
const svg = d3.select(element);
const countriesLayer = svg.append("g");
const hoverLayer = svg.append("g").style("pointer-events", "none");
const hoverPath = hoverLayer
.append("path")
.datum(null)
.attr("class", highlightedCountryClass)
.style("display", "none");
svg.selectAll("path")
countriesLayer
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("data-country-path", "true")
.attr("class", countryClass)
.attr("d", path as never)
@@ -209,9 +216,10 @@ function drawInteractiveCountries(
y,
hoveredCountryAlpha3Code: country.properties.a3
});
// brings country to front
this.parentNode?.appendChild(this);
d3.select(this).attr("class", highlightedCountryClass);
hoverPath
.datum(country)
.attr("d", path(country) as string)
.style("display", null);
})
.on("mousemove", function (event) {
@@ -221,13 +229,13 @@ function drawInteractiveCountries(
.on("mouseout", function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
d3.select(this).attr("class", countryClass);
hoverPath.style("display", "none");
});
return svg;
}
type WorldJsonCountryData = { properties: { name: string; a3: string } };
type WorldJsonCountryData = d3.ExtendedFeature<d3.GeoGeometryObjects | null, { name: string; a3: string }>;
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
const collection = topojson.feature(
@@ -257,7 +265,7 @@ function colorInCountriesWithValues(
const svg = d3.select(element);
return svg
.selectAll("path")
.selectAll('path[data-country-path="true"]')
.style("fill", (countryPath) => {
const country = getCountryByCountryPath(countryPath);
if (!country?.count) {

View File

@@ -6,8 +6,8 @@ import {
} from "@app/components/StrategySelect";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useEffect, useMemo } from "react";
type Props = {
@@ -32,7 +32,8 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
description: t("idpOidcDescription"),
icon: <IdpTypeIcon type="oidc" size={24} />
}
];
if (hideTemplates) {
@@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
icon: <IdpTypeIcon type="google" size={24} />
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
icon: <IdpTypeIcon type="azure" size={24} />
}
];
}, [hideTemplates, t]);

View File

@@ -10,14 +10,14 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react";
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import { SiKubernetes, SiNixos } from "react-icons/si";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"linux",
"macos",
"docker",
"kubernetes",
"podman",
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
const t = useTranslations();
const [acceptClients, setAcceptClients] = useState(true);
const [platform, setPlatform] = useState<Platform>("unix");
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
);
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
: "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: {
All: [
linux: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
"Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("envFile"),
command: `# Create the directory and environment file
sudo install -d -m 0755 /etc/newt
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
NEWT_ID=${id}
NEWT_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
DISABLE_CLIENTS=true` : ""}
EOF
sudo chmod 600 /etc/newt/newt.env`
},
{
title: t("serviceFile"),
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
[Unit]
Description=Newt
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/newt/newt.env
ExecStart=/usr/local/bin/newt
Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF`
},
{
title: t("enableAndStart"),
command: `sudo systemctl daemon-reload
sudo systemctl enable --now newt`
}
]
},
macos: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
@@ -131,7 +191,7 @@ WantedBy=default.target`
]
},
nixos: {
All: [
Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
}
@@ -172,9 +232,9 @@ WantedBy=default.target`
<OptionSelect<string>
label={
["docker", "podman"].includes(platform)
? t("method")
: t("architecture")
platform === "windows"
? t("architecture")
: t("method")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "unix":
return <Terminal className="h-4 w-4 mr-2" />;
case "linux":
return <FaLinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
return <FaLinux className="h-4 w-4 mr-2" />;
}
}
@@ -280,8 +342,10 @@ function getPlatformName(platformName: Platform) {
switch (platformName) {
case "windows":
return "Windows";
case "unix":
return "Unix & macOS";
case "linux":
return "Linux";
case "macos":
return "macOS";
case "docker":
return "Docker";
case "kubernetes":
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
case "nixos":
return "NixOS";
default:
return "Unix / macOS";
return "Linux";
}
}
function getArchitectures(platform: Platform) {
switch (platform) {
case "unix":
return ["All"];
case "linux":
return ["Run", "Systemd Service"];
case "macos":
return ["Run"];
case "windows":
return ["x64"];
case "docker":
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
case "podman":
return ["Podman Quadlet", "Podman Run"];
case "nixos":
return ["All"];
return ["Flake"];
default:
return ["x64"];
return ["Run"];
}
}

View File

@@ -24,12 +24,14 @@ export type SitesSelectorProps = {
orgId: string;
selectedSite?: Selectedsite | null;
onSelectSite: (selected: Selectedsite) => void;
filterTypes?: string[];
};
export function SitesSelector({
orgId,
selectedSite,
onSelectSite
onSelectSite,
filterTypes
}: SitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -45,7 +47,9 @@ export function SitesSelector({
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
const allSites: Array<Selectedsite> = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
@@ -54,7 +58,7 @@ export function SitesSelector({
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
}, [debouncedQuery, sites, selectedSite, filterTypes]);
return (
<Command shouldFilter={false}>

View File

@@ -18,12 +18,14 @@ import {
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
import type { DataTableAddAction } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
@@ -31,7 +33,14 @@ import {
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
import {
ChevronDown,
Columns,
Filter,
Plus,
RefreshCw,
Search
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
@@ -67,8 +76,11 @@ type ControlledDataTableProps<TData, TValue> = {
tableId: string;
addButtonText?: string;
onAdd?: () => void;
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
@@ -89,8 +101,11 @@ export function ControlledDataTable<TData, TValue>({
rows,
addButtonText,
onAdd,
addActions,
addButtonDisabled = false,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
@@ -335,7 +350,7 @@ export function ControlledDataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
@@ -346,16 +361,49 @@ export function ControlledDataTable<TData, TValue>({
</Button>
</div>
)}
{onAdd && addButtonText && (
{addActions && addActions.length > 0 && addButtonText ? (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={
addButtonDisabled ||
isNavigatingToAddPage
}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() =>
action.onSelect()
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
onAdd &&
addButtonText && (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)}
</div>
</CardHeader>

View File

@@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import {
Card,
CardContent,
@@ -46,6 +46,7 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
@@ -165,15 +166,24 @@ export type DataTablePaginationState = PaginationState & {
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
/** When set (non-empty), replaces the single add button with a dropdown; `onAdd` is not used. */
export type DataTableAddAction = {
label: string;
onSelect: () => void;
};
type DataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
onAdd?: () => void;
/** Prefer over `onAdd` when non-empty. */
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
@@ -204,9 +214,11 @@ export function DataTable<TData, TValue>({
title,
addButtonText,
onAdd,
addActions,
addButtonDisabled = false,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort,
@@ -624,7 +636,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
@@ -635,13 +647,45 @@ export function DataTable<TData, TValue>({
</Button>
</div>
)}
{onAdd && addButtonText && (
{addActions && addActions.length > 0 && addButtonText ? (
<div>
<Button onClick={onAdd} disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() =>
action.onSelect()
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
onAdd &&
addButtonText && (
<div>
<Button
onClick={onAdd}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)}
</div>
</CardHeader>