Compare commits

...

10 Commits

Author SHA1 Message Date
Owen
71273e1b1c Try to fix large query problem 2026-06-10 21:41:34 -07:00
Owen
02f6e2a8c3 Add ; fix lint 2026-06-10 20:56:26 -07:00
Owen
1d9c4dd9e2 Fix padding 2026-06-10 20:46:53 -07:00
Owen
b9dd0c8e43 Add advantech install link 2026-06-10 20:46:43 -07:00
Owen
cd052976eb Properly paywall the edit policy screen 2026-06-10 20:38:59 -07:00
Owen
cc498f0e33 Properly paywall ui for labels 2026-06-10 20:32:07 -07:00
Owen
1a942937e6 Remove precheck on websocket for now 2026-06-10 20:24:41 -07:00
Owen
d81d1a6b7f Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-10 20:24:22 -07:00
Owen
f64d04e827 Add loading back to create resource 2026-06-10 18:23:01 -07:00
miloschwartz
540aee3fe2 update docs links 2026-06-10 17:52:42 -07:00
18 changed files with 193 additions and 93 deletions

View File

@@ -214,6 +214,7 @@
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules", "resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.", "resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
"resourcePoliciesBannerButtonText": "Learn More",
"resourcePoliciesTitle": "Manage Public Resource Policies", "resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)", "resourcePoliciesAttachedResources": "{count} resource(s)",

View File

@@ -327,27 +327,6 @@ export async function listSites(
); );
} }
let accessibleSites;
if (req.user) {
accessibleSites = await db
.select({
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
})
.from(userSites)
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
.where(
or(
eq(userSites.userId, req.user!.userId),
inArray(roleSites.roleId, req.userOrgRoleIds!)
)
);
} else {
accessibleSites = await db
.select({ siteId: sites.siteId })
.from(sites)
.where(eq(sites.orgId, orgId));
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed( const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.labels tierMatrix.labels
@@ -364,15 +343,39 @@ export async function listSites(
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const conditions = [eq(sites.orgId, orgId)];
const conditions = [ if (req.user) {
and( const userAccessConditions = [
inArray(sites.siteId, accessibleSiteIds), inArray(
eq(sites.orgId, orgId) sites.siteId,
db
.select({ siteId: userSites.siteId })
.from(userSites)
.where(eq(userSites.userId, req.user.userId))
) )
]; ];
const roleIds = req.userOrgRoleIds ?? [];
if (roleIds.length > 0) {
userAccessConditions.push(
inArray(
sites.siteId,
db
.select({ siteId: roleSites.siteId })
.from(roleSites)
.where(inArray(roleSites.roleId, roleIds))
)
);
}
conditions.push(
userAccessConditions.length === 1
? userAccessConditions[0]
: or(...userAccessConditions)!
);
}
if (typeof online !== "undefined") { if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online)); conditions.push(eq(sites.online, online));
} }
@@ -418,17 +421,15 @@ export async function listSites(
) )
); );
} }
conditions.push(or(...queryList)); conditions.push(or(...queryList)!);
} }
const baseQuery = querySitesBase().where(and(...conditions)); const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery const countQuery = db
const countQuery = db.$count( .select({ count: sql<number>`count(*)` })
querySitesBase() .from(sites)
.where(and(...conditions)) .where(and(...conditions));
.as("filtered_sites")
);
const siteListQuery = baseQuery const siteListQuery = baseQuery
.limit(pageSize) .limit(pageSize)
@@ -441,11 +442,13 @@ export async function listSites(
: asc(sites.name) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [countRows, rows] = await Promise.all([
countQuery, countQuery,
siteListQuery siteListQuery
]); ]);
const totalCount = Number(countRows[0]?.count ?? 0);
// Get latest version asynchronously without blocking the response // Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion(); const latestNewtVersionPromise = getLatestNewtVersion();

View File

@@ -282,7 +282,7 @@ function GeneralSectionForm({ org }: SectionFormProps) {
<FormDescription> <FormDescription>
{t("newtAutoUpdateDescription")}{" "} {t("newtAutoUpdateDescription")}{" "}
<a <a
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update" href="https://docs.pangolin.net/manage/sites/auto-update"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -229,7 +229,7 @@ function RdpServerForm({
sitesField="selectedSites" sitesField="selectedSites"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp" learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
defaultPort={3389} defaultPort={3389}
/> />
</SettingsSectionForm> </SettingsSectionForm>

View File

@@ -467,7 +467,7 @@ function SshServerForm({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "} {t("sshDaemonDisclaimer")}{" "}
<a <a
href="https://docs.pangolin.net/manage/resources/public/ssh" href="https://docs.pangolin.net/manage/ssh"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
@@ -589,7 +589,7 @@ function SshServerForm({
sitesField="selectedSites" sitesField="selectedSites"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22} defaultPort={22}
/> />
</SettingsFormCell> </SettingsFormCell>
@@ -602,7 +602,7 @@ function SshServerForm({
siteField="selectedSite" siteField="selectedSite"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22} defaultPort={22}
/> />
</SettingsFormCell> </SettingsFormCell>

View File

@@ -80,7 +80,6 @@ import { toASCII } from "punycode";
import { import {
useMemo, useMemo,
useState, useState,
useTransition,
useEffect useEffect
} from "react"; } from "react";
import { useForm, type Resolver } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
@@ -229,7 +228,7 @@ export default function Page() {
>([]); >([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
const [createLoading, startTransition] = useTransition(); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>(""); const [niceId, setNiceId] = useState<string>("");
@@ -461,6 +460,7 @@ export default function Page() {
}; };
async function onSubmit() { async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues(); const baseData = baseForm.getValues();
try { try {
@@ -707,6 +707,8 @@ export default function Page() {
t("resourceErrorCreateMessageDescription") t("resourceErrorCreateMessageDescription")
) )
}); });
} finally {
setCreateLoading(false);
} }
} }
@@ -762,7 +764,7 @@ export default function Page() {
ssh: "SSH", ssh: "SSH",
rdp: "RDP", rdp: "RDP",
vnc: "VNC", vnc: "VNC",
} };
} }
const typeOptions: OptionSelectOption<NewResourceType>[] = const typeOptions: OptionSelectOption<NewResourceType>[] =
@@ -1097,7 +1099,7 @@ export default function Page() {
"sshDaemonDisclaimer" "sshDaemonDisclaimer"
)}{" "} )}{" "}
<a <a
href="https://docs.pangolin.net/manage/resources/public/ssh" href="https://docs.pangolin.net/manage/ssh"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
@@ -1235,7 +1237,7 @@ export default function Page() {
sitesField="selectedSites" sitesField="selectedSites"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22} defaultPort={22}
/> />
</Form> </Form>
@@ -1256,7 +1258,7 @@ export default function Page() {
siteField="selectedSite" siteField="selectedSite"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22} defaultPort={22}
/> />
</Form> </Form>
@@ -1306,7 +1308,7 @@ export default function Page() {
sitesField="selectedSites" sitesField="selectedSites"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp" learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
defaultPort={3389} defaultPort={3389}
/> />
</Form> </Form>
@@ -1353,7 +1355,7 @@ export default function Page() {
sitesField="selectedSites" sitesField="selectedSites"
destinationField="destination" destinationField="destination"
destinationPortField="destinationPort" destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc" learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc#site-and-host-configuration"
defaultPort={5900} defaultPort={5900}
/> />
</Form> </Form>
@@ -1427,7 +1429,7 @@ export default function Page() {
} }
}} }}
loading={createLoading} loading={createLoading}
disabled={!areAllTargetsValid() || browserGatewayDisabled} disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
> >
{t("resourceCreate")} {t("resourceCreate")}
</Button> </Button>

View File

@@ -317,7 +317,7 @@ export default function GeneralPage() {
"siteAutoUpdateDescription" "siteAutoUpdateDescription"
)}{" "} )}{" "}
<a <a
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update" href="https://docs.pangolin.net/manage/sites/auto-update"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -596,7 +596,7 @@ export default function SshClient({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "} {t("sshPrivateKeyDisclaimer")}{" "}
<Link <Link
href="https://docs.pangolin.net/" href="https://docs.pangolin.net/manage/ssh#authentication-method"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -124,21 +124,21 @@ export default function VncClient({
authToken: target.authToken authToken: target.authToken
}); });
try { // try {
const checkParams = new URLSearchParams(params); // const checkParams = new URLSearchParams(params);
checkParams.set("checkOnly", "1"); // checkParams.set("checkOnly", "1");
const response = await fetch(`${base}?${checkParams.toString()}`); // const response = await fetch(`${base}?${checkParams.toString()}`);
if (!response.ok) { // if (!response.ok) {
const detail = (await response.text()).trim(); // const detail = (await response.text()).trim();
setConnectError(detail || t("sshErrorConnectionClosed")); // setConnectError(detail || t("sshErrorConnectionClosed"));
setConnecting(false); // setConnecting(false);
return; // return;
} // }
} catch { // } catch {
setConnectError(t("sshErrorWebSocket")); // setConnectError(t("sshErrorWebSocket"));
setConnecting(false); // setConnecting(false);
return; // return;
} // }
let RFB: new ( let RFB: new (
target: HTMLElement, target: HTMLElement,

View File

@@ -222,7 +222,7 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
<a <a
href={ href={
props.learnMoreHref ?? props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh" "https://docs.pangolin.net/manage/resources/private/multi-site-routing"
} }
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle CredenzaTitle
} from "./Credenza"; } from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm"; import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = { export type CreateOrgLabelDialogProps = {
@@ -35,6 +38,8 @@ export function CreateOrgLabelDialog({
}: CreateOrgLabelDialogProps) { }: CreateOrgLabelDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) { async function createOrgLabel(data: { name: string; color: string }) {
@@ -79,8 +84,11 @@ export function CreateOrgLabelDialog({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm <OrgLabelForm
disabled={!canManageLabels}
onSubmit={(data) => { onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => createOrgLabel(data)); startTransition(async () => createOrgLabel(data));
}} }}
/> />
@@ -98,7 +106,7 @@ export function CreateOrgLabelDialog({
<Button <Button
type="submit" type="submit"
form="org-label-form" form="org-label-form"
disabled={isSubmitting} disabled={isSubmitting || !canManageLabels}
loading={isSubmitting} loading={isSubmitting}
> >
{t("labelCreate")} {t("labelCreate")}

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle CredenzaTitle
} from "./Credenza"; } from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm"; import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = { export type EditOrgLabelDialogProps = {
@@ -41,6 +44,8 @@ export function EditOrgLabelDialog({
}: EditOrgLabelDialogProps) { }: EditOrgLabelDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) { async function editOrgLabel(data: { name: string; color: string }) {
@@ -85,9 +90,12 @@ export function EditOrgLabelDialog({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm <OrgLabelForm
disabled={!canManageLabels}
defaultValue={label} defaultValue={label}
onSubmit={(data) => { onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => editOrgLabel(data)); startTransition(async () => editOrgLabel(data));
}} }}
/> />
@@ -105,7 +113,7 @@ export function EditOrgLabelDialog({
<Button <Button
type="submit" type="submit"
form="org-label-form" form="org-label-form"
disabled={isSubmitting} disabled={isSubmitting || !canManageLabels}
loading={isSubmitting} loading={isSubmitting}
> >
{t("labelEdit")} {t("labelEdit")}

View File

@@ -35,9 +35,14 @@ export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = { export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void; onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData; defaultValue?: LabelFormData;
disabled?: boolean;
}; };
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) { export function OrgLabelForm({
onSubmit,
defaultValue,
disabled = false
}: OrgLabelFormProps) {
const t = useTranslations(); const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS); const colorValues = Object.values(LABEL_COLORS);
@@ -70,9 +75,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<FormItem> <FormItem>
<FormLabel>{t("labelNameField")}</FormLabel> <FormLabel>{t("labelNameField")}</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} disabled={disabled} />
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -88,6 +91,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
disabled={disabled}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue <SelectValue
@@ -110,7 +114,9 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
}} }}
/> />
<span data-name> <span data-name>
{color.charAt(0).toUpperCase() + {color
.charAt(0)
.toUpperCase() +
color.slice(1)} color.slice(1)}
</span> </span>
</SelectItem> </SelectItem>

View File

@@ -2079,7 +2079,7 @@ export function PrivateResourceForm({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "} {t("sshDaemonDisclaimer")}{" "}
<a <a
href="https://docs.pangolin.net/manage/resources/private/ssh" href="https://docs.pangolin.net/manage/ssh"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -767,7 +767,7 @@ function TargetStatusCell({
if (!targets || targets.length === 0) { if (!targets || targets.length === 0) {
return ( return (
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-0">
<StatusIcon status="unknown" /> <StatusIcon status="unknown" />
<span className="text-sm">{t("resourcesTableNoTargets")}</span> <span className="text-sm">{t("resourcesTableNoTargets")}</span>
</div> </div>

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { Shield } from "lucide-react"; import { Button } from "@app/components/ui/button";
import { Shield, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner"; import DismissableBanner from "./DismissableBanner";
export const ResourcePoliciesBanner = () => { export const ResourcePoliciesBanner = () => {
@@ -14,7 +16,22 @@ export const ResourcePoliciesBanner = () => {
title={t("resourcePoliciesBannerTitle")} title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />} titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")} description={t("resourcePoliciesBannerDescription")}
/> >
<Link
href="https://docs.pangolin.net/manage/resources/public/resource-policies"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("resourcePoliciesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
); );
}; };

View File

@@ -354,7 +354,7 @@ WantedBy=default.target`
{t.rich("siteInstallAdvantechDocsDescription", { {t.rich("siteInstallAdvantechDocsDescription", {
docsLink: (chunks) => ( docsLink: (chunks) => (
<a <a
href="https://docs.pangolin.net/manage/sites/install-advantech" href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -8,13 +8,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export type EditPolicyFormSection = "general" | "authentication" | "rules"; export type EditPolicyFormSection = "general" | "authentication" | "rules";
@@ -71,13 +72,17 @@ export function EditPolicyForm({
return <></>; return <></>;
} }
const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
const isDisabled = !isPaidUser(policyTiers);
const effectiveReadonly = readonly || isDisabled;
const authSection = ( const authSection = (
<PolicyAuthStackSection <PolicyAuthStackSection
mode="edit" mode="edit"
orgId={org.org.orgId} orgId={org.org.orgId}
allIdps={allIdps} allIdps={allIdps}
emailEnabled={env.email.emailEnabled} emailEnabled={env.email.emailEnabled}
readonly={readonly} readonly={effectiveReadonly}
resourceId={resourceId} resourceId={resourceId}
/> />
); );
@@ -87,32 +92,82 @@ export function EditPolicyForm({
mode="edit" mode="edit"
isMaxmindAvailable={isMaxmindAvailable} isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindASNAvailable} isMaxmindAsnAvailable={isMaxmindASNAvailable}
readonly={readonly} readonly={effectiveReadonly}
resourceId={resourceId} resourceId={resourceId}
/> />
); );
if (section === "general") { if (section === "general") {
return <EditPolicyNameSectionForm readonly={readonly} />; return (
<>
<PaidFeaturesAlert tiers={policyTiers} />
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
<EditPolicyNameSectionForm readonly={effectiveReadonly} />
</div>
</>
);
} }
if (section === "authentication") { if (section === "authentication") {
return authSection; return (
<>
<PaidFeaturesAlert tiers={policyTiers} />
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{authSection}
</div>
</>
);
} }
if (section === "rules") { if (section === "rules") {
return rulesSection; return (
<>
<PaidFeaturesAlert tiers={policyTiers} />
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{rulesSection}
</div>
</>
);
} }
return ( return (
<>
<PaidFeaturesAlert tiers={policyTiers} />
<div
className={
isDisabled ? "pointer-events-none opacity-50" : undefined
}
>
<SettingsContainer> <SettingsContainer>
{!hidePolicyNameForm && !isOverlay && ( {!hidePolicyNameForm && !isOverlay && (
<EditPolicyNameSectionForm readonly={readonly} /> <EditPolicyNameSectionForm
readonly={effectiveReadonly}
/>
)} )}
{authSection} {authSection}
{rulesSection} {rulesSection}
</SettingsContainer> </SettingsContainer>
</div>
</>
); );
} }