mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-17 21:59:52 +00:00
Merge branch 'dev' into feat/resource-policies
This commit is contained in:
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type BillingSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -17,6 +18,9 @@ export default async function BillingSettingsPage({
|
||||
params
|
||||
}: BillingSettingsProps) {
|
||||
const { orgId } = await params;
|
||||
if (build !== "saas") {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
const user = await verifySession();
|
||||
|
||||
@@ -40,6 +44,10 @@ export default async function BillingSettingsPage({
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
|
||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||
|
||||
type PlanOption = {
|
||||
id: PlanId;
|
||||
@@ -73,8 +73,8 @@ type PlanOption = {
|
||||
|
||||
const planOptions: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
id: "basic",
|
||||
name: "Basic",
|
||||
price: "Free",
|
||||
tierType: null
|
||||
},
|
||||
@@ -109,38 +109,43 @@ const planOptions: PlanOption[] = [
|
||||
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier | "starter",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number }
|
||||
Tier | "basic",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
||||
> = {
|
||||
starter: {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier1: {
|
||||
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier2: {
|
||||
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier3: {
|
||||
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
enterprise: {
|
||||
users: 0, // Custom for enterprise
|
||||
sites: 0, // Custom for enterprise
|
||||
domains: 0, // Custom for enterprise
|
||||
remoteNodes: 0 // Custom for enterprise
|
||||
remoteNodes: 0, // Custom for enterprise
|
||||
organizations: 0 // Custom for enterprise
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,11 +184,12 @@ export default function BillingPage() {
|
||||
const SITES = "sites";
|
||||
const DOMAINS = "domains";
|
||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||
const ORGINIZATIONS = "organizations";
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingTier, setPendingTier] = useState<{
|
||||
tier: Tier | "starter";
|
||||
tier: Tier | "basic";
|
||||
action: "upgrade" | "downgrade";
|
||||
planName: string;
|
||||
price: string;
|
||||
@@ -204,7 +210,8 @@ export default function BillingPage() {
|
||||
({ subscription }) =>
|
||||
subscription?.type === "tier1" ||
|
||||
subscription?.type === "tier2" ||
|
||||
subscription?.type === "tier3"
|
||||
subscription?.type === "tier3" ||
|
||||
subscription?.type === "enterprise"
|
||||
);
|
||||
setTierSubscription(tierSub || null);
|
||||
|
||||
@@ -402,8 +409,8 @@ export default function BillingPage() {
|
||||
pendingTier.action === "upgrade" ||
|
||||
pendingTier.action === "downgrade"
|
||||
) {
|
||||
// If downgrading to starter (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "starter") {
|
||||
// If downgrading to basic (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "basic") {
|
||||
handleModifySubscription();
|
||||
} else if (hasSubscription) {
|
||||
handleChangeTier(pendingTier.tier);
|
||||
@@ -417,7 +424,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const showTierConfirmation = (
|
||||
tier: Tier | "starter",
|
||||
tier: Tier | "basic",
|
||||
action: "upgrade" | "downgrade",
|
||||
planName: string,
|
||||
price: string
|
||||
@@ -432,13 +439,63 @@ export default function BillingPage() {
|
||||
|
||||
// Get current plan ID from tier
|
||||
const getCurrentPlanId = (): PlanId => {
|
||||
if (!hasSubscription || !currentTier) return "starter";
|
||||
if (!hasSubscription || !currentTier) return "basic";
|
||||
// Handle enterprise subscription type directly
|
||||
if (currentTier === "enterprise") return "enterprise";
|
||||
const plan = planOptions.find((p) => p.tierType === currentTier);
|
||||
return plan?.id || "starter";
|
||||
return plan?.id || "basic";
|
||||
};
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
|
||||
// Check if subscription is in a problematic state that requires attention
|
||||
const hasProblematicSubscription = (): boolean => {
|
||||
if (!tierSubscription?.subscription) return false;
|
||||
const status = tierSubscription.subscription.status;
|
||||
return (
|
||||
status === "past_due" ||
|
||||
status === "unpaid" ||
|
||||
status === "incomplete" ||
|
||||
status === "incomplete_expired"
|
||||
);
|
||||
};
|
||||
|
||||
const isProblematicState = hasProblematicSubscription();
|
||||
|
||||
// Get user-friendly subscription status message
|
||||
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
||||
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||
|
||||
const status = tierSubscription.subscription.status;
|
||||
|
||||
switch (status) {
|
||||
case "past_due":
|
||||
return {
|
||||
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||
};
|
||||
case "unpaid":
|
||||
return {
|
||||
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||
};
|
||||
case "incomplete":
|
||||
return {
|
||||
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||
};
|
||||
case "incomplete_expired":
|
||||
return {
|
||||
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusMessage = getSubscriptionStatusMessage();
|
||||
|
||||
// Get button label and action for each plan
|
||||
const getPlanAction = (plan: PlanOption) => {
|
||||
if (plan.id === "enterprise") {
|
||||
@@ -451,8 +508,8 @@ export default function BillingPage() {
|
||||
}
|
||||
|
||||
if (plan.id === currentPlanId) {
|
||||
// If it's the starter plan (starter with no subscription), show as current but disabled
|
||||
if (plan.id === "starter" && !hasSubscription) {
|
||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
||||
return {
|
||||
label: "Current Plan",
|
||||
action: () => {},
|
||||
@@ -460,8 +517,17 @@ export default function BillingPage() {
|
||||
disabled: true
|
||||
};
|
||||
}
|
||||
// If on free tier but has a problematic subscription, allow them to manage it
|
||||
if (plan.id === "basic" && isProblematicState) {
|
||||
return {
|
||||
label: "Manage Subscription",
|
||||
action: handleModifySubscription,
|
||||
variant: "default" as const,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Modify Current Plan",
|
||||
label: "Manage Current Plan",
|
||||
action: handleModifySubscription,
|
||||
variant: "default" as const,
|
||||
disabled: false
|
||||
@@ -484,10 +550,10 @@ export default function BillingPage() {
|
||||
plan.name,
|
||||
plan.price + (" " + plan.priceDetail || "")
|
||||
);
|
||||
} else if (plan.id === "starter") {
|
||||
// Show confirmation for downgrading to starter (free tier)
|
||||
} else if (plan.id === "basic") {
|
||||
// Show confirmation for downgrading to basic (free tier)
|
||||
showTierConfirmation(
|
||||
"starter",
|
||||
"basic",
|
||||
"downgrade",
|
||||
plan.name,
|
||||
plan.price
|
||||
@@ -497,7 +563,7 @@ export default function BillingPage() {
|
||||
}
|
||||
},
|
||||
variant: "outline" as const,
|
||||
disabled: false
|
||||
disabled: isProblematicState
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,7 +582,7 @@ export default function BillingPage() {
|
||||
}
|
||||
},
|
||||
variant: "outline" as const,
|
||||
disabled: false
|
||||
disabled: isProblematicState
|
||||
};
|
||||
};
|
||||
|
||||
@@ -566,7 +632,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
|
||||
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
|
||||
feature: string;
|
||||
currentUsage: number;
|
||||
newLimit: number;
|
||||
@@ -619,6 +685,16 @@ export default function BillingPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check organizations
|
||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
||||
violations.push({
|
||||
feature: "Organizations",
|
||||
currentUsage: organizationsUsage,
|
||||
newLimit: limits.organizations
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
};
|
||||
|
||||
@@ -632,6 +708,26 @@ export default function BillingPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/* Subscription Status Alert */}
|
||||
{isProblematicState && statusMessage && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{statusMessage.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{statusMessage.description}
|
||||
{" "}
|
||||
<button
|
||||
onClick={handleModifySubscription}
|
||||
className="underline font-semibold hover:no-underline"
|
||||
>
|
||||
{t("billingManageSubscription") || "Manage your subscription"}
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Your Plan Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -676,22 +772,50 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -752,7 +876,7 @@ export default function BillingPage() {
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||
</div>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={5}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingUsers") || "Users"}
|
||||
@@ -855,6 +979,41 @@ export default function BillingPage() {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(ORGINIZATIONS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingRemoteNodes") ||
|
||||
@@ -872,7 +1031,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -885,7 +1044,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1016,6 +1175,17 @@ export default function BillingPage() {
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.organizations
|
||||
}{" "}
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { build } from "@server/build";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
|
||||
type LicensesSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let orgUser = null;
|
||||
try {
|
||||
const res = await getCachedOrgUser(orgId, user.userId);
|
||||
orgUser = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!org?.org?.isBillingOrg || !orgUser?.isOwner) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
/>
|
||||
|
||||
<ClientProvider client={client}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<ClientInfoCard />
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
|
||||
description={t("orgSettingsDescription")}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<OrgInfoCard />
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
|
||||
@@ -3,11 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
useState,
|
||||
useTransition,
|
||||
useActionState
|
||||
} from "react";
|
||||
import { useState, useTransition, useActionState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -54,7 +50,7 @@ export default function GeneralPage() {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<GeneralSectionForm org={org.org} />
|
||||
{build !== "saas" && <DeleteForm org={org.org} />}
|
||||
{!org.org.isBillingOrg && <DeleteForm org={org.org} />}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,12 +72,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg;
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={params.orgId}
|
||||
orgs={orgs}
|
||||
navItems={orgNavSections(env)}
|
||||
navItems={orgNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Layout>
|
||||
|
||||
@@ -74,7 +74,9 @@ export default async function ClientResourcesPage(
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -187,7 +187,11 @@ export default function ResourceAuthenticationPage() {
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
|
||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setSsoEnabled(resource.sso ?? false);
|
||||
}, [resource.sso]);
|
||||
|
||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||
resource.skipToIdpId || null
|
||||
@@ -472,7 +476,7 @@ export default function ResourceAuthenticationPage() {
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={resource.sso}
|
||||
checked={ssoEnabled}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
|
||||
@@ -800,8 +804,13 @@ function OneTimePasswordFormSection({
|
||||
}: OneTimePasswordFormSectionProps) {
|
||||
const { env } = useEnvContext();
|
||||
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
||||
resource.emailWhitelistEnabled
|
||||
resource.emailWhitelistEnabled ?? false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setWhitelistEnabled(resource.emailWhitelistEnabled);
|
||||
}, [resource.emailWhitelistEnabled]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loadingSaveWhitelist, startTransition] = useTransition();
|
||||
@@ -894,7 +903,7 @@ function OneTimePasswordFormSection({
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
checked={whitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
/>
|
||||
|
||||
@@ -113,7 +113,12 @@ export default function ResourceRules(props: {
|
||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesEnabled(resource.applyRules);
|
||||
}, [resource.applyRules]);
|
||||
|
||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
@@ -836,7 +841,7 @@ export default function ResourceRules(props: {
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t("rulesEnable")}
|
||||
defaultChecked={rulesEnabled}
|
||||
checked={rulesEnabled}
|
||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
@@ -187,21 +187,22 @@ export default function GeneralPage() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"enableDockerSocketDescription"
|
||||
)}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
"enableDockerSocketLink"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{t.rich(
|
||||
"enableDockerSocketDescription",
|
||||
{
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
/>
|
||||
|
||||
<SiteProvider site={site}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<SiteInfoCard />
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,6 @@ import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { id } from "date-fns/locale";
|
||||
|
||||
type SiteType = "newt" | "wireguard" | "local";
|
||||
|
||||
|
||||
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
|
||||
type DeleteAccountClientProps = {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export default function DeleteAccountClient({
|
||||
displayName
|
||||
}: DeleteAccountClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
function handleUseDifferentAccount() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push(
|
||||
"/auth/login?internal_redirect=/auth/delete-account"
|
||||
);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserProfileCard
|
||||
identifier={displayName}
|
||||
description={t("signingAs")}
|
||||
onUseDifferentAccount={handleUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteAccountConfirmDialog
|
||||
open={isDialogOpen}
|
||||
setOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/auth/delete-account/page.tsx
Normal file
28
src/app/auth/delete-account/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { build } from "@server/build";
|
||||
import { cache } from "react";
|
||||
import DeleteAccountClient from "./DeleteAccountClient";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeleteAccountPage() {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const displayName = getUserDisplayName({ user });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
|
||||
<DeleteAccountClient displayName={displayName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
|
||||
import DeviceLoginForm from "@/components/DeviceLoginForm";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { cache } from "react";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ code?: string; user?: string }>;
|
||||
searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
|
||||
};
|
||||
|
||||
function deviceRedirectSearchParams(params: {
|
||||
@@ -30,11 +31,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
|
||||
|
||||
if (!user) {
|
||||
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
|
||||
const loginUrl = new URL("/auth/login", "http://x");
|
||||
const authPath = cleanRedirect(params.authPath || "/auth/login");
|
||||
const loginUrl = new URL(authPath, "http://x");
|
||||
loginUrl.searchParams.set("forceLogin", "true");
|
||||
loginUrl.searchParams.set("redirect", redirectDestination);
|
||||
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
|
||||
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
|
||||
redirect(loginUrl.pathname + loginUrl.search);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export default async function Page(props: {
|
||||
redirect: string | undefined;
|
||||
email: string | undefined;
|
||||
fromSmartLogin: string | undefined;
|
||||
skipVerificationEmail: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
@@ -75,6 +76,10 @@ export default async function Page(props: {
|
||||
inviteId={inviteId}
|
||||
emailParam={searchParams.email}
|
||||
fromSmartLogin={searchParams.fromSmartLogin === "true"}
|
||||
skipVerificationEmail={
|
||||
searchParams.skipVerificationEmail === "true" ||
|
||||
searchParams.skipVerificationEmail === "1"
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist, Inter, Manrope, Open_Sans } from "next/font/google";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
@@ -24,6 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -32,10 +32,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const font = Inter({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"]
|
||||
});
|
||||
|
||||
const fontClassName = inter.className;
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
@@ -79,16 +81,16 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<body className={`${fontClassName} h-screen-safe overflow-hidden`}>
|
||||
<StoreInternalRedirect />
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
{/* build === "saas" && (
|
||||
<Script
|
||||
src="https://rybbit.fossorial.io/api/script.js"
|
||||
data-site-id="fe1ff2a33287"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
)*/}
|
||||
<ViewportHeightFix />
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
@@ -125,9 +127,9 @@ export default async function RootLayout({
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
{/*process.env.NODE_ENV === "development" && (
|
||||
<TailwindIndicator />
|
||||
)}
|
||||
)*/}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
Building2,
|
||||
ChartLine,
|
||||
Combine,
|
||||
CreditCard,
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
KeyRound,
|
||||
Laptop,
|
||||
Link as LinkIcon,
|
||||
Logs, // Added from 'dev' branch
|
||||
Logs,
|
||||
MonitorUp,
|
||||
Plug,
|
||||
ReceiptText,
|
||||
ScanEye, // Added from 'dev' branch
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldIcon,
|
||||
@@ -33,6 +35,10 @@ export type SidebarNavSection = {
|
||||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export type OrgNavSectionsOptions = {
|
||||
isPrimaryOrg?: boolean;
|
||||
};
|
||||
|
||||
// Merged from 'user-management-and-resources' branch
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
@@ -42,14 +48,17 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
export const orgNavSections = (
|
||||
env?: Env,
|
||||
options?: OrgNavSectionsOptions
|
||||
): SidebarNavSection[] => [
|
||||
{
|
||||
heading: "sidebarGeneral",
|
||||
heading: "network",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarSites",
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Combine className="size-4 flex-none" />
|
||||
icon: <Plug className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarResources",
|
||||
@@ -100,17 +109,22 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "access",
|
||||
heading: "accessControl",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
icon: <User className="size-4 flex-none" />,
|
||||
title: "sidebarTeam",
|
||||
icon: <Users className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
href: "/{orgId}/settings/access/users",
|
||||
icon: <User className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarInvitations",
|
||||
href: "/{orgId}/settings/access/invitations",
|
||||
@@ -118,11 +132,6 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
@@ -170,90 +179,86 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "sidebarLogsAndAnalytics",
|
||||
items: (() => {
|
||||
const logItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const analytics = {
|
||||
title: "sidebarLogsAnalytics",
|
||||
href: "/{orgId}/settings/logs/analytics",
|
||||
icon: <ChartLine className="h-4 w-4" />
|
||||
};
|
||||
|
||||
// If only one log item, return it directly without grouping
|
||||
if (logItems.length === 1) {
|
||||
return [analytics, ...logItems];
|
||||
}
|
||||
|
||||
// If multiple log items, create a group
|
||||
return [
|
||||
analytics,
|
||||
{
|
||||
title: "sidebarLogs",
|
||||
icon: <Logs className="size-4 flex-none" />,
|
||||
items: logItems
|
||||
}
|
||||
];
|
||||
})()
|
||||
},
|
||||
{
|
||||
heading: "sidebarOrganization",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
title: "sidebarLogsAndAnalytics",
|
||||
icon: <ChartLine className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarLogsAnalytics",
|
||||
href: "/{orgId}/settings/logs/analytics",
|
||||
icon: <ChartLine className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: (
|
||||
<SquareMousePointer className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
title: "sidebarManagement",
|
||||
icon: <Building2 className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
...(build == "saas" && options?.isPrimaryOrg
|
||||
? [
|
||||
{
|
||||
title: "sidebarBillingAndLicenses",
|
||||
icon: <CreditCard className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: (
|
||||
<CreditCard className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "sidebarEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: (
|
||||
<TicketCheck className="size-4 flex-none" />
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "sidebarSettings",
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="size-4 flex-none" />
|
||||
},
|
||||
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: <CreditCard className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -73,7 +73,7 @@ export default async function Page(props: {
|
||||
|
||||
if (!orgs.length) {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
redirect("/setup?firstOrg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ export default async function Page(props: {
|
||||
targetOrgId = lastOrgCookie;
|
||||
} else {
|
||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
||||
if (!ownedOrg) {
|
||||
if (primaryOrg) {
|
||||
ownedOrg = primaryOrg;
|
||||
} else {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
}
|
||||
if (!ownedOrg) {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
|
||||
@@ -4,19 +4,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
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 { build } from "@server/build";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
@@ -35,7 +30,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { ArrowRight, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type Step = "org" | "site" | "resources";
|
||||
@@ -45,6 +40,7 @@ export default function StepperForm() {
|
||||
const [orgIdTaken, setOrgIdTaken] = useState(false);
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
@@ -54,7 +50,10 @@ export default function StepperForm() {
|
||||
|
||||
const orgSchema = z.object({
|
||||
orgName: z.string().min(1, { message: t("orgNameRequired") }),
|
||||
orgId: z.string().min(1, { message: t("orgIdRequired") }),
|
||||
orgId: z
|
||||
.string()
|
||||
.min(1, { message: t("orgIdRequired") })
|
||||
.max(32, { message: t("orgIdMaxLength") }),
|
||||
subnet: z.string().min(1, { message: t("subnetRequired") }),
|
||||
utilitySubnet: z.string().min(1, { message: t("subnetRequired") })
|
||||
});
|
||||
@@ -71,12 +70,27 @@ export default function StepperForm() {
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isFirstOrg = searchParams.get("firstOrg") != null;
|
||||
|
||||
// Fetch default subnet on component mount
|
||||
useEffect(() => {
|
||||
fetchDefaultSubnet();
|
||||
}, []);
|
||||
|
||||
// Prefill org name and id when build is saas and firstOrg query param is set
|
||||
useEffect(() => {
|
||||
if (build !== "saas" || !user || !isFirstOrg) return;
|
||||
|
||||
const orgName = user.email
|
||||
? `${user.email}'s Organization`
|
||||
: "My Organization";
|
||||
const orgId = `org_${user.userId}`;
|
||||
orgForm.setValue("orgName", orgName);
|
||||
orgForm.setValue("orgId", orgId);
|
||||
debouncedCheckOrgIdAvailability(orgId);
|
||||
}, []);
|
||||
|
||||
const fetchDefaultSubnet = async () => {
|
||||
try {
|
||||
const res = await api.get(`/pick-org-defaults`);
|
||||
@@ -129,6 +143,15 @@ export default function StepperForm() {
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const sanitizeOrgId = (value: string) => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 32);
|
||||
};
|
||||
|
||||
async function orgSubmit(values: z.infer<typeof orgSchema>) {
|
||||
if (orgIdTaken) {
|
||||
return;
|
||||
@@ -161,263 +184,254 @@ export default function StepperForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("setupNewOrg")}</CardTitle>
|
||||
<CardDescription>{t("setupCreate")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<section className="space-y-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "org"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "org"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "site"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "site"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("siteCreate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "resources"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "resources"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateResources")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("setupNewOrg")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{t("setupCreate")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "org"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "org"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "site"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "site"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("siteCreate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "resources"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "resources"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateResources")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
{currentStep === "org" && (
|
||||
<Form {...orgForm}>
|
||||
<form
|
||||
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupOrgName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Prevent "/" in orgName input
|
||||
const sanitizedValue =
|
||||
e.target.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
);
|
||||
const orgId =
|
||||
generateId(
|
||||
sanitizedValue
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgId",
|
||||
orgId
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgName",
|
||||
sanitizedValue
|
||||
);
|
||||
debouncedCheckOrgIdAvailability(
|
||||
orgId
|
||||
);
|
||||
}}
|
||||
value={field.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("orgDisplayName")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgId")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupIdentifierMessage"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{currentStep === "org" && (
|
||||
<Form {...orgForm}>
|
||||
<form
|
||||
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("setupOrgName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Prevent "/" in orgName input
|
||||
const sanitizedValue =
|
||||
e.target.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
);
|
||||
const orgId =
|
||||
generateId(sanitizedValue);
|
||||
orgForm.setValue(
|
||||
"orgId",
|
||||
orgId
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgName",
|
||||
sanitizedValue
|
||||
);
|
||||
debouncedCheckOrgIdAvailability(
|
||||
orgId
|
||||
);
|
||||
}}
|
||||
value={field.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("orgDisplayName")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("orgId")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = sanitizeOrgId(
|
||||
e.target.value
|
||||
);
|
||||
field.onChange(value);
|
||||
setOrgIdTaken(false);
|
||||
if (value) {
|
||||
debouncedCheckOrgIdAvailability(
|
||||
value
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("setupIdentifierMessage")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapsible
|
||||
open={isAdvancedOpen}
|
||||
onOpenChange={setIsAdvancedOpen}
|
||||
className="space-y-2"
|
||||
<Collapsible
|
||||
open={isAdvancedOpen}
|
||||
onOpenChange={setIsAdvancedOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm">
|
||||
{t("advancedSettings")}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("toggle")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<h4 className="text-sm">
|
||||
{t("advancedSettings")}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("toggle")}
|
||||
</span>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"setupSubnetAdvanced"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupSubnetDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupSubnetAdvanced")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("setupSubnetDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="utilitySubnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupUtilitySubnet")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupUtilitySubnetDescription"
|
||||
)}
|
||||
/>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="utilitySubnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"setupUtilitySubnet"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupUtilitySubnetDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{orgIdTaken && !orgCreated ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("setupErrorIdentifier")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{orgIdTaken && !orgCreated ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("setupErrorIdentifier")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{/* Error Alert removed, errors now shown as toast */}
|
||||
|
||||
{/* Error Alert removed, errors now shown as toast */}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || orgIdTaken}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || orgIdTaken}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
const target = getInternalRedirectTarget(orgId);
|
||||
if (target) {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -27,6 +28,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -43,13 +45,36 @@ export type AuthPageCustomizationProps = {
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.union([
|
||||
z.literal(""),
|
||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
@@ -269,12 +294,25 @@ export default function AuthPageBrandingForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingLogoURL")}
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoURLOrPath"
|
||||
)
|
||||
: t("brandingLogoURL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoPathDescription"
|
||||
)
|
||||
: t(
|
||||
"brandingLogoURLDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
@@ -55,12 +55,6 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -15,7 +15,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -51,6 +59,8 @@ export type InternalResourceRow = {
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonPort?: number | null;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
@@ -131,7 +141,26 @@ export default function ClientResourcesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
@@ -327,6 +356,14 @@ export default function ClientResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
|
||||
@@ -31,6 +31,18 @@ const CopyToClipboard = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{!copied ? (
|
||||
<Copy className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
{isLink ? (
|
||||
<Link
|
||||
href={text}
|
||||
@@ -54,18 +66,6 @@ const CopyToClipboard = ({
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{!copied ? (
|
||||
<Copy className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,31 +11,19 @@ import {
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||
import type {
|
||||
CreateRoleBody,
|
||||
CreateRoleResponse
|
||||
} from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
@@ -52,35 +40,39 @@ export default function CreateRoleForm({
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional()
|
||||
});
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
requireDeviceApproval: false
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(values: RoleFormValues) {
|
||||
const payload: CreateRoleBody = {
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
requireDeviceApproval: values.requireDeviceApproval,
|
||||
allowSsh: values.allowSsh
|
||||
};
|
||||
if (isPaidUser(tierMatrix.sshPam)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
values.sshSudoMode === "commands" &&
|
||||
values.sshSudoCommands?.trim()
|
||||
? values.sshSudoCommands
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (values.sshUnixGroups?.trim()) {
|
||||
payload.sshUnixGroups = values.sshUnixGroups
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateRoleResponse>
|
||||
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
||||
.put<AxiosResponse<CreateRoleResponse>>(
|
||||
`/org/${org?.org.orgId}/role`,
|
||||
payload
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -98,143 +90,42 @@ export default function CreateRoleForm({
|
||||
title: t("accessRoleCreated"),
|
||||
description: t("accessRoleCreatedDescription")
|
||||
});
|
||||
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (open) setOpen(false);
|
||||
afterCreate?.(res.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requireDeviceApproval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"requireDeviceApproval",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
{t(
|
||||
"requireDeviceApprovalDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("accessRoleCreateSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<RoleForm
|
||||
variant="create"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("accessRoleCreateSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"overflow-y-auto max-h-[100dvh] md:max-h-screen",
|
||||
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import type {
|
||||
DeleteMyAccountPreviewResponse,
|
||||
DeleteMyAccountCodeRequestedResponse,
|
||||
DeleteMyAccountSuccessResponse
|
||||
} from "@server/routers/auth/deleteMyAccount";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type DeleteAccountConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DeleteAccountConfirmDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: DeleteAccountConfirmDialogProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const passwordSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
password: z.string().min(1, { message: t("passwordRequired") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const codeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [preview, setPreview] =
|
||||
useState<DeleteMyAccountPreviewResponse | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const codeForm = useForm<z.infer<typeof codeSchema>>({
|
||||
resolver: zodResolver(codeSchema),
|
||||
defaultValues: { code: "" }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && step === 0 && !preview) {
|
||||
setLoadingPreview(true);
|
||||
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
|
||||
"/auth/delete-my-account",
|
||||
{}
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.data?.data?.preview) {
|
||||
setPreview(res.data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("deleteAccountError")
|
||||
)
|
||||
});
|
||||
setOpen(false);
|
||||
})
|
||||
.finally(() => setLoadingPreview(false));
|
||||
}
|
||||
}, [open, step, preview, api, setOpen, t]);
|
||||
|
||||
function reset() {
|
||||
setStep(0);
|
||||
setPreview(null);
|
||||
setPasswordValue("");
|
||||
passwordForm.reset();
|
||||
codeForm.reset();
|
||||
}
|
||||
|
||||
async function handleContinueToPassword() {
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(
|
||||
values: z.infer<typeof passwordSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
setPasswordValue(values.password);
|
||||
try {
|
||||
const res = await api.post<
|
||||
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
|
||||
| AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", { password: values.password });
|
||||
|
||||
const data = res.data?.data;
|
||||
|
||||
if (data && "codeRequested" in data && data.codeRequested) {
|
||||
setStep(2);
|
||||
} else if (data && "success" in data && data.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", {
|
||||
password: passwordValue,
|
||||
code: values.code
|
||||
});
|
||||
|
||||
if (res.data?.data?.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("deleteAccountConfirmTitle")}
|
||||
</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{step === 0 && (
|
||||
<>
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loading")}...
|
||||
</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountConfirmMessage")}
|
||||
</p>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"deleteAccountPreviewAccount"
|
||||
)}
|
||||
</p>
|
||||
{preview.orgs.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium mt-2">
|
||||
{t(
|
||||
"deleteAccountPreviewOrgs"
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{preview.orgs.map(
|
||||
(org) => (
|
||||
<li
|
||||
key={
|
||||
org.orgId
|
||||
}
|
||||
>
|
||||
{org.name ||
|
||||
org.orgId}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
{t("cannotbeUndone")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="delete-account-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
handlePasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...codeForm}>
|
||||
<form
|
||||
id="delete-account-code-form"
|
||||
onSubmit={codeForm.handleSubmit(
|
||||
handleCodeSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={codeForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{step === 0 && preview && !loadingPreview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleContinueToPassword}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-password-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-code-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,44 +11,26 @@ import {
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import type { Role } from "@server/db";
|
||||
import type {
|
||||
CreateRoleBody,
|
||||
CreateRoleResponse,
|
||||
UpdateRoleBody,
|
||||
UpdateRoleResponse
|
||||
} from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
type EditRoleFormProps = {
|
||||
role: Role;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSuccess?: (res: CreateRoleResponse) => void;
|
||||
onSuccess?: (res: UpdateRoleResponse) => void;
|
||||
};
|
||||
|
||||
export default function EditRoleForm({
|
||||
@@ -56,39 +38,44 @@ export default function EditRoleForm({
|
||||
role,
|
||||
setOpen,
|
||||
onSuccess
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
}: EditRoleFormProps) {
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional()
|
||||
});
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
requireDeviceApproval: role.requireDeviceApproval ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(values: RoleFormValues) {
|
||||
const payload: UpdateRoleBody = {
|
||||
requireDeviceApproval: values.requireDeviceApproval,
|
||||
allowSsh: values.allowSsh
|
||||
};
|
||||
if (!role.isAdmin) {
|
||||
payload.name = values.name;
|
||||
payload.description = values.description || undefined;
|
||||
}
|
||||
if (isPaidUser(tierMatrix.sshPam)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
values.sshSudoMode === "commands" &&
|
||||
values.sshSudoCommands?.trim()
|
||||
? values.sshSudoCommands
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (values.sshUnixGroups !== undefined) {
|
||||
payload.sshUnixGroups = values.sshUnixGroups
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<UpdateRoleResponse>
|
||||
>(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
|
||||
.post<AxiosResponse<UpdateRoleResponse>>(
|
||||
`/role/${role.roleId}`,
|
||||
payload
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -106,143 +93,43 @@ export default function EditRoleForm({
|
||||
title: t("accessRoleUpdated"),
|
||||
description: t("accessRoleUpdatedDescription")
|
||||
});
|
||||
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (open) setOpen(false);
|
||||
onSuccess?.(res.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requireDeviceApproval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"requireDeviceApproval",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
{t(
|
||||
"requireDeviceApprovalDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("accessRoleUpdateSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<RoleForm
|
||||
variant="edit"
|
||||
role={role}
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("accessRoleUpdateSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
1328
src/components/InternalResourceForm.tsx
Normal file
1328
src/components/InternalResourceForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,7 @@ export async function Layout({
|
||||
<div
|
||||
className={cn(
|
||||
"container mx-auto max-w-12xl mb-12",
|
||||
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
|
||||
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExternalLink, Menu, Server } from "lucide-react";
|
||||
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
@@ -44,7 +42,6 @@ export function LayoutMobileMenu({
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -73,17 +70,17 @@ export function LayoutMobileMenu({
|
||||
{t("navbarDescription")}
|
||||
</SheetDescription>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
<div className="px-1">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-b border-border" />
|
||||
<div className="px-3">
|
||||
<div className="px-3 pt-3">
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="py-2">
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
@@ -98,11 +95,12 @@ export function LayoutMobileMenu({
|
||||
<span className="flex-shrink-0 mr-2">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,22 +113,6 @@ export function LayoutMobileMenu({
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ExternalLink, Server } from "lucide-react";
|
||||
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -145,9 +145,14 @@ export function LayoutSidebar({
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-1">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
@@ -171,7 +176,12 @@ export function LayoutSidebar({
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span>{t("serverAdmin")}</span>
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -186,29 +196,55 @@ export function LayoutSidebar({
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-border" />
|
||||
{isSidebarCollapsed && (
|
||||
<div className="shrink-0 flex justify-center py-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSidebarCollapsed(false);
|
||||
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"
|
||||
aria-label={t("sidebarExpand")}
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{t("sidebarExpand")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full border-t border-border mb-3" />
|
||||
|
||||
<div className="p-4 pt-1 flex flex-col shrink-0">
|
||||
{canShowProductUpdates && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build === "enterprise" && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<SidebarLicenseButton
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{build === "oss" && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<SupporterStatus isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<SidebarSupportButton
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
@@ -224,19 +260,19 @@ export function LayoutSidebar({
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{link.href ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
className="flex items-center justify-start gap-1"
|
||||
>
|
||||
{link.text}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
{link.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -245,12 +281,12 @@ export function LayoutSidebar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
className="flex items-center justify-start gap-1"
|
||||
>
|
||||
{build === "oss"
|
||||
? t("communityEdition")
|
||||
@@ -263,22 +299,22 @@ export function LayoutSidebar({
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
{t("personalUseOnly")}
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
{t("unlicensed")}
|
||||
</div>
|
||||
) : null}
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<div className="text-xs text-muted-foreground text-left">
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
className="flex items-center justify-start gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
|
||||
@@ -204,7 +204,26 @@ export default function MachineClientsTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="px-3">{t("name")}</span>,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("name")}
|
||||
className="px-3"
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
|
||||
@@ -58,6 +58,18 @@ type Resource = {
|
||||
siteName?: string | null;
|
||||
};
|
||||
|
||||
type SiteResource = {
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
type: 'site';
|
||||
};
|
||||
|
||||
type MemberResourcesPortalProps = {
|
||||
orgId: string;
|
||||
};
|
||||
@@ -334,7 +346,9 @@ export default function MemberResourcesPortal({
|
||||
const { toast } = useToast();
|
||||
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
|
||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -360,7 +374,9 @@ export default function MemberResourcesPortal({
|
||||
|
||||
if (response.data.success) {
|
||||
setResources(response.data.data.resources);
|
||||
setSiteResources(response.data.data.siteResources || []);
|
||||
setFilteredResources(response.data.data.resources);
|
||||
setFilteredSiteResources(response.data.data.siteResources || []);
|
||||
} else {
|
||||
setError("Failed to load resources");
|
||||
}
|
||||
@@ -417,17 +433,61 @@ export default function MemberResourcesPortal({
|
||||
|
||||
setFilteredResources(filtered);
|
||||
|
||||
// Filter and sort site resources
|
||||
const filteredSites = siteResources.filter(
|
||||
(resource) =>
|
||||
resource.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
resource.destination
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort site resources
|
||||
filteredSites.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
case "domain-asc":
|
||||
case "domain-desc":
|
||||
// Sort by destination for site resources
|
||||
const destCompare = sortBy === "domain-asc"
|
||||
? a.destination.localeCompare(b.destination)
|
||||
: b.destination.localeCompare(a.destination);
|
||||
return destCompare;
|
||||
case "status-enabled":
|
||||
return b.enabled ? 1 : -1;
|
||||
case "status-disabled":
|
||||
return a.enabled ? 1 : -1;
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
setFilteredSiteResources(filteredSites);
|
||||
|
||||
// Reset to first page when search/sort changes
|
||||
setCurrentPage(1);
|
||||
}, [resources, searchQuery, sortBy]);
|
||||
}, [resources, siteResources, searchQuery, sortBy]);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
|
||||
const totalItems = filteredResources.length + filteredSiteResources.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const paginatedResources = filteredResources.slice(
|
||||
startIndex,
|
||||
startIndex + itemsPerPage
|
||||
);
|
||||
const remainingSlots = itemsPerPage - paginatedResources.length;
|
||||
const paginatedSiteResources = remainingSlots > 0
|
||||
? filteredSiteResources.slice(
|
||||
Math.max(0, startIndex - filteredResources.length),
|
||||
Math.max(0, startIndex - filteredResources.length) + remainingSlots
|
||||
)
|
||||
: [];
|
||||
|
||||
const handleOpenResource = (resource: Resource) => {
|
||||
// Open the resource in a new tab
|
||||
@@ -575,7 +635,7 @@ export default function MemberResourcesPortal({
|
||||
</div>
|
||||
|
||||
{/* Resources Content */}
|
||||
{filteredResources.length === 0 ? (
|
||||
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? (
|
||||
/* Enhanced Empty State */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
@@ -623,9 +683,20 @@ export default function MemberResourcesPortal({
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Resources Grid */}
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
|
||||
{paginatedResources.map((resource) => (
|
||||
{/* Public Resources Section */}
|
||||
{paginatedResources.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Public Resources
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Web applications and services accessible via browser
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
{paginatedResources.map((resource) => (
|
||||
<Card key={resource.resourceId}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -702,13 +773,167 @@ export default function MemberResourcesPortal({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Private Resources (Site Resources) Section */}
|
||||
{paginatedSiteResources.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Combine className="h-5 w-5" />
|
||||
Private Resources
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Internal network resources accessible via client
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
{paginatedSiteResources.map((siteResource) => (
|
||||
<Card key={siteResource.siteResourceId}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="min-w-0 max-w-full">
|
||||
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{siteResource.name}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs break-words">
|
||||
{siteResource.name}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<InfoPopup>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="text-xs font-medium mb-1.5">Resource Details</div>
|
||||
<div>
|
||||
<span className="font-medium">Mode:</span>
|
||||
<span className="ml-2 text-muted-foreground capitalize">
|
||||
{siteResource.mode}
|
||||
</span>
|
||||
</div>
|
||||
{siteResource.protocol && (
|
||||
<div>
|
||||
<span className="font-medium">Protocol:</span>
|
||||
<span className="ml-2 text-muted-foreground uppercase">
|
||||
{siteResource.protocol}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{siteResource.alias && (
|
||||
<div>
|
||||
<span className="font-medium">Alias:</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{siteResource.alias}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{siteResource.aliasAddress && (
|
||||
<div>
|
||||
<span className="font-medium">Alias Address:</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{siteResource.aliasAddress}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Status:</span>
|
||||
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{siteResource.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{siteResource.alias ? (
|
||||
<>
|
||||
{/* Alias as primary */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
|
||||
{siteResource.alias}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
siteResource.alias!
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource alias has been copied to your clipboard.",
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Destination as secondary */}
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{siteResource.destination}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Destination as primary when no alias */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
|
||||
{siteResource.destination}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
siteResource.destination
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource destination has been copied to your clipboard.",
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
<Combine className="h-3.5 w-3.5 mr-2" />
|
||||
Requires Client Connection
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
totalItems={filteredResources.length}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</>
|
||||
|
||||
70
src/components/OptionSelect.tsx
Normal file
70
src/components/OptionSelect.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type OptionSelectOption<TValue extends string> = {
|
||||
value: TValue;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
type OptionSelectProps<TValue extends string> = {
|
||||
options: ReadonlyArray<OptionSelectOption<TValue>>;
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
label?: string;
|
||||
/** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */
|
||||
cols?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function OptionSelect<TValue extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
cols = 5,
|
||||
className,
|
||||
disabled = false
|
||||
}: OptionSelectProps<TValue>) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<p className="font-bold mb-3">{label}</p>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
cols === 2 && "grid-cols-2",
|
||||
cols === 3 && "grid-cols-2 md:grid-cols-3",
|
||||
cols === 4 && "grid-cols-2 md:grid-cols-4",
|
||||
cols === 5 && "grid-cols-2 md:grid-cols-5",
|
||||
cols === 6 && "grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
|
||||
className={cn(
|
||||
"flex-1 min-w-30 shadow-none",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
@@ -20,12 +19,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -43,11 +44,23 @@ export function OrgSelector({
|
||||
const { user } = useUserContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) return orgs ?? [];
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) return -1;
|
||||
if (!aPrimary && bPrimary) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
const orgSelectorContent = (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -58,7 +71,7 @@ export function OrgSelector({
|
||||
"cursor-pointer transition-colors",
|
||||
isCollapsed
|
||||
? "w-full h-16 flex items-center justify-center hover:bg-muted"
|
||||
: "w-full px-4 py-4 hover:bg-muted"
|
||||
: "w-full px-5 py-4 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
@@ -80,73 +93,64 @@ export function OrgSelector({
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg">
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
>
|
||||
<Command className="rounded-lg border-0 flex-1 min-h-0">
|
||||
<CommandInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="border-0 focus:ring-0"
|
||||
className="border-0 focus:ring-0 h-9 rounded-b-none"
|
||||
/>
|
||||
<CommandEmpty className="py-6 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("orgNotFound2")}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
|
||||
<>
|
||||
<CommandGroup
|
||||
heading={t("create")}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
router.push("/setup");
|
||||
}}
|
||||
className="mx-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Plus className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t("setupNewOrg")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("createNewOrgDescription")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
<CommandSeparator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("orgs")} className="py-2">
|
||||
<CommandList>
|
||||
{orgs?.map((org) => (
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty className="py-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("orgNotFound2")}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="p-1" heading={t("orgs")}>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
router.push(`/${org.orgId}/settings`);
|
||||
const newPath = pathname.includes(
|
||||
"/settings/"
|
||||
)
|
||||
? pathname.replace(
|
||||
/^\/[^/]+/,
|
||||
`/${org.orgId}`
|
||||
)
|
||||
: `/${org.orgId}`;
|
||||
router.push(newPath);
|
||||
}}
|
||||
className="mx-2 rounded-md"
|
||||
className="mx-1 rounded-md py-1.5 h-auto min-h-0"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="font-medium">
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("organization")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
{org.isPrimaryOrg && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-[10px] px-1.5 py-0 font-medium ml-auto"
|
||||
>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
"h-4 w-4 text-primary flex-shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
@@ -154,9 +158,25 @@ export function OrgSelector({
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
|
||||
<div className="p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push("/setup");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-2" />
|
||||
{t("setupNewOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -12,34 +12,42 @@ import { useParams } from "next/navigation";
|
||||
|
||||
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||
|
||||
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
|
||||
const TIER_TRANSLATION_KEYS: Record<
|
||||
Tier,
|
||||
| "subscriptionTierTier1"
|
||||
| "subscriptionTierTier2"
|
||||
| "subscriptionTierTier3"
|
||||
| "subscriptionTierEnterprise"
|
||||
> = {
|
||||
tier1: "subscriptionTierTier1",
|
||||
tier2: "subscriptionTierTier2",
|
||||
tier3: "subscriptionTierTier3",
|
||||
enterprise: "subscriptionTierEnterprise"
|
||||
};
|
||||
|
||||
function getRequiredTier(tiers: Tier[]): Tier | null {
|
||||
function formatRequiredTiersList(
|
||||
tiers: Tier[],
|
||||
t: (key: (typeof TIER_TRANSLATION_KEYS)[Tier]) => string
|
||||
): string | null {
|
||||
if (tiers.length === 0) return null;
|
||||
let min: Tier | null = null;
|
||||
for (const tier of tiers) {
|
||||
const idx = TIER_ORDER.indexOf(tier);
|
||||
if (idx === -1) continue;
|
||||
if (min === null || TIER_ORDER.indexOf(min) > idx) {
|
||||
min = tier;
|
||||
}
|
||||
}
|
||||
return min;
|
||||
const sorted = [...tiers]
|
||||
.filter((tier) => TIER_ORDER.includes(tier))
|
||||
.sort((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b));
|
||||
if (sorted.length === 0) return null;
|
||||
const names = sorted.map((tier) => t(TIER_TRANSLATION_KEYS[tier]));
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} or ${names[1]}`;
|
||||
return `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
|
||||
}
|
||||
|
||||
const bannerClassName =
|
||||
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden";
|
||||
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
|
||||
const bannerContentClassName = "py-3 px-4";
|
||||
const bannerRowClassName =
|
||||
"flex items-center gap-2.5 text-sm text-muted-foreground";
|
||||
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
|
||||
const bannerIconClassName = "size-4 shrink-0 text-black-500";
|
||||
const docsLinkClassName =
|
||||
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
|
||||
"inline-flex items-center gap-1 font-medium text-black-600 underline";
|
||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
@@ -94,11 +102,17 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
|
||||
const {
|
||||
hasSaasSubscription,
|
||||
hasEnterpriseLicense,
|
||||
isActive,
|
||||
subscriptionTier
|
||||
} = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
const requiredTier = getRequiredTier(tiers);
|
||||
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
|
||||
const requiredTiersLabel = formatRequiredTiersList(tiers, t);
|
||||
const billingHref = orgId
|
||||
? `/${orgId}/settings/billing`
|
||||
: "https://pangolin.net/pricing";
|
||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||
@@ -115,16 +129,16 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{requiredTierName
|
||||
{requiredTiersLabel
|
||||
? isActive
|
||||
? t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTierName,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: t.rich("subscriptionRequiredTierToUse", {
|
||||
tier: requiredTierName,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
tier: requiredTiersLabel,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTiersLabel,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: isActive
|
||||
? t("mustUpgradeToUse")
|
||||
: t("subscriptionRequiredToUse")}
|
||||
@@ -141,7 +155,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("licenseRequiredToUse", {
|
||||
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
|
||||
enterpriseLicenseLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
@@ -157,7 +172,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("ossEnterpriseEditionRequired", {
|
||||
enterpriseEditionLink: enterpriseDocsLinkRenderer,
|
||||
enterpriseEditionLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -105,7 +105,7 @@ 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",
|
||||
"text-xs text-muted-foreground flex items-center gap-1 mt-2 empty:mt-0",
|
||||
showMoreUpdatesText
|
||||
? "animate-in fade-in duration-300"
|
||||
: "opacity-0"
|
||||
|
||||
@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { build } from "@server/build";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
|
||||
<DropdownMenuSeparator />
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenuSeparator />
|
||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/auth/delete-account"
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("deleteAccount")}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||
<span>{t("logout")}</span>
|
||||
|
||||
@@ -14,15 +14,19 @@ import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp10Icon,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
@@ -318,7 +322,26 @@ export default function ProxyResourcesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
@@ -563,6 +586,14 @@ export default function ProxyResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
|
||||
468
src/components/RoleForm.tsx
Normal file
468
src/components/RoleForm.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Role } from "@server/db";
|
||||
|
||||
export const SSH_SUDO_MODE_VALUES = ["none", "full", "commands"] as const;
|
||||
export type SshSudoMode = (typeof SSH_SUDO_MODE_VALUES)[number];
|
||||
|
||||
function parseRoleJsonArray(value: string | null | undefined): string[] {
|
||||
if (value == null || value === "") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
||||
if (value === "none" || value === "full" || value === "commands")
|
||||
return value;
|
||||
return "none";
|
||||
}
|
||||
|
||||
export type RoleFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
requireDeviceApproval?: boolean;
|
||||
allowSsh?: boolean;
|
||||
sshSudoMode: SshSudoMode;
|
||||
sshSudoCommands?: string;
|
||||
sshCreateHomeDir?: boolean;
|
||||
sshUnixGroups?: string;
|
||||
};
|
||||
|
||||
type RoleFormProps = {
|
||||
variant: "create" | "edit";
|
||||
role?: Role;
|
||||
onSubmit: (values: RoleFormValues) => void | Promise<void>;
|
||||
formId?: string;
|
||||
};
|
||||
|
||||
export function RoleForm({
|
||||
variant,
|
||||
role,
|
||||
onSubmit,
|
||||
formId = "create-role-form"
|
||||
}: RoleFormProps) {
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional(),
|
||||
allowSsh: z.boolean().optional(),
|
||||
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||
sshSudoCommands: z.string().optional(),
|
||||
sshCreateHomeDir: z.boolean().optional(),
|
||||
sshUnixGroups: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultValues: RoleFormValues = role
|
||||
? {
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
requireDeviceApproval: role.requireDeviceApproval ?? false,
|
||||
allowSsh:
|
||||
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||
", "
|
||||
),
|
||||
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
description: "",
|
||||
requireDeviceApproval: false,
|
||||
allowSsh: false,
|
||||
sshSudoMode: "none",
|
||||
sshSudoCommands: "",
|
||||
sshCreateHomeDir: true,
|
||||
sshUnixGroups: ""
|
||||
};
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === "edit" && role) {
|
||||
form.reset({
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
requireDeviceApproval: role.requireDeviceApproval ?? false,
|
||||
allowSsh:
|
||||
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||
", "
|
||||
),
|
||||
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||
});
|
||||
}
|
||||
}, [variant, role, form]);
|
||||
|
||||
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||
const sshSudoMode = form.watch("sshSudoMode");
|
||||
const isAdminRole = variant === "edit" && role?.isAdmin === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (sshDisabled) {
|
||||
form.setValue("allowSsh", false);
|
||||
}
|
||||
}, [sshDisabled, form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => onSubmit(values))}
|
||||
className="space-y-4"
|
||||
id={formId}
|
||||
>
|
||||
{env.flags.disableEnterpriseFeatures ? (
|
||||
<div className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("accessRoleName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isAdminRole}
|
||||
readOnly={isAdminRole}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("description")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isAdminRole}
|
||||
readOnly={isAdminRole}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
...(env.flags.disableEnterpriseFeatures
|
||||
? []
|
||||
: [{ title: t("sshAccess"), href: "#" }])
|
||||
]}
|
||||
>
|
||||
{/* General tab */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isAdminRole}
|
||||
readOnly={isAdminRole}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isAdminRole}
|
||||
readOnly={isAdminRole}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requireDeviceApproval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"requireDeviceApproval",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"requireDeviceApproval"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"requireDeviceApprovalDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SSH tab - hidden when enterprise features are disabled */}
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowSsh"
|
||||
render={({ field }) => {
|
||||
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
||||
{
|
||||
value: "allow",
|
||||
label: t("roleAllowSshAllow")
|
||||
},
|
||||
{
|
||||
value: "disallow",
|
||||
label: t("roleAllowSshDisallow")
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("roleAllowSsh")}
|
||||
</FormLabel>
|
||||
<OptionSelect<"allow" | "disallow">
|
||||
options={allowSshOptions}
|
||||
value={
|
||||
sshDisabled
|
||||
? "disallow"
|
||||
: field.value
|
||||
? "allow"
|
||||
: "disallow"
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (sshDisabled) return;
|
||||
field.onChange(v === "allow");
|
||||
}}
|
||||
cols={2}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"roleAllowSshDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshSudoMode"
|
||||
render={({ field }) => {
|
||||
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
|
||||
[
|
||||
{
|
||||
value: "none",
|
||||
label: t("sshSudoModeNone")
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: t("sshSudoModeFull")
|
||||
},
|
||||
{
|
||||
value: "commands",
|
||||
label: t(
|
||||
"sshSudoModeCommands"
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoMode")}
|
||||
</FormLabel>
|
||||
<OptionSelect<SshSudoMode>
|
||||
options={sudoOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={3}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{sshSudoMode === "commands" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshSudoCommands"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoCommands")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"sshSudoCommandsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshUnixGroups"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshUnixGroups")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("sshUnixGroupsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshCreateHomeDir"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"sshCreateHomeDir",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HorizontalTabs>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -103,45 +103,46 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const roleRow = row.original;
|
||||
const isAdmin = roleRow.isAdmin;
|
||||
return (
|
||||
!roleRow.isAdmin && (
|
||||
<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">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setRoleToRemove(roleRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingRole(roleRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isAdmin || false}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={isAdmin || false}
|
||||
onClick={() => {
|
||||
setRoleToRemove(roleRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingRole(roleRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ function CollapsibleNavItem({
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center w-full rounded-md transition-colors",
|
||||
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
|
||||
"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",
|
||||
@@ -128,7 +128,7 @@ function CollapsibleNavItem({
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -167,22 +167,192 @@ function CollapsibleNavItem({
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CollapsibleContent forceMount>
|
||||
<div
|
||||
className={cn(
|
||||
"border-l ml-3 pl-3 mt-0 space-y-0",
|
||||
"border-border"
|
||||
"grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
{item.items!.map((childItem) =>
|
||||
renderNavItem(childItem, level + 1)
|
||||
)}
|
||||
<div className="min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"border-l ml-[22px] pl-[9px] mt-0 space-y-0",
|
||||
"border-border"
|
||||
)}
|
||||
>
|
||||
{item.items!.map((childItem) =>
|
||||
renderNavItem(childItem, level + 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
type CollapsedNavItemWithPopoverProps = {
|
||||
item: SidebarNavItem;
|
||||
tooltipText: string;
|
||||
isActive: boolean;
|
||||
isChildActive: boolean;
|
||||
isDisabled: boolean;
|
||||
hydrateHref: (val?: string) => string | undefined;
|
||||
pathname: string;
|
||||
build: string;
|
||||
isUnlocked: () => boolean;
|
||||
disabled: boolean;
|
||||
t: (key: string) => string;
|
||||
onItemClick?: () => void;
|
||||
};
|
||||
|
||||
const TOOLTIP_SUPPRESS_MS = 400;
|
||||
|
||||
function CollapsedNavItemWithPopover({
|
||||
item,
|
||||
tooltipText,
|
||||
isActive,
|
||||
isChildActive,
|
||||
isDisabled,
|
||||
hydrateHref,
|
||||
pathname,
|
||||
build,
|
||||
isUnlocked,
|
||||
disabled,
|
||||
t,
|
||||
onItemClick
|
||||
}: CollapsedNavItemWithPopoverProps) {
|
||||
const [popoverOpen, setPopoverOpen] = React.useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = React.useState(false);
|
||||
const suppressTooltipRef = React.useRef(false);
|
||||
|
||||
const handlePopoverOpenChange = React.useCallback((open: boolean) => {
|
||||
setPopoverOpen(open);
|
||||
if (!open) {
|
||||
setTooltipOpen(false);
|
||||
suppressTooltipRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressTooltipRef.current = false;
|
||||
}, TOOLTIP_SUPPRESS_MS);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open && suppressTooltipRef.current) return;
|
||||
setTooltipOpen(open);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={handlePopoverOpenChange}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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",
|
||||
isDisabled &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-56 p-1"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{item.items!.map((childItem) => {
|
||||
const childHydratedHref = hydrateHref(
|
||||
childItem.href
|
||||
);
|
||||
const childIsActive = childHydratedHref
|
||||
? pathname.startsWith(childHydratedHref)
|
||||
: false;
|
||||
const childIsEE =
|
||||
build === "enterprise" &&
|
||||
childItem.showEE &&
|
||||
!isUnlocked();
|
||||
const childIsDisabled = disabled || childIsEE;
|
||||
|
||||
if (!childHydratedHref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={childItem.title}
|
||||
href={
|
||||
childIsDisabled
|
||||
? "#"
|
||||
: childHydratedHref
|
||||
}
|
||||
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",
|
||||
childIsDisabled &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (childIsDisabled) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
handlePopoverOpenChange(false);
|
||||
onItemClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="truncate">
|
||||
{t(childItem.title)}
|
||||
</span>
|
||||
{childItem.isBeta && (
|
||||
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
|
||||
{t("beta")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{build === "enterprise" &&
|
||||
childItem.showEE &&
|
||||
!isUnlocked() && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="flex-shrink-0 ml-2"
|
||||
>
|
||||
{t("licenseBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNav({
|
||||
className,
|
||||
sections,
|
||||
@@ -278,11 +448,7 @@ export function SidebarNav({
|
||||
href={isDisabled ? "#" : hydratedHref}
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors relative",
|
||||
isCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: level === 0
|
||||
? "px-3 py-1.5"
|
||||
: "px-3 py-1",
|
||||
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",
|
||||
@@ -298,10 +464,13 @@ export function SidebarNav({
|
||||
tabIndex={isDisabled ? -1 : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{item.icon && (
|
||||
{item.icon && level === 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center",
|
||||
isCollapsed
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
!isCollapsed && "mr-3"
|
||||
)}
|
||||
>
|
||||
@@ -355,13 +524,13 @@ export function SidebarNav({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
|
||||
"px-3 py-1.5",
|
||||
"text-muted-foreground",
|
||||
isDisabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
||||
{item.icon && level === 0 && (
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -401,120 +570,21 @@ export function SidebarNav({
|
||||
// If item has nested items, show both tooltip and popover
|
||||
if (hasNestedItems) {
|
||||
return (
|
||||
<TooltipProvider key={item.title}>
|
||||
<Tooltip>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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",
|
||||
isDisabled &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-56 p-1"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{item.items!.map((childItem) => {
|
||||
const childHydratedHref =
|
||||
hydrateHref(childItem.href);
|
||||
const childIsActive =
|
||||
childHydratedHref
|
||||
? pathname.startsWith(
|
||||
childHydratedHref
|
||||
)
|
||||
: false;
|
||||
const childIsEE =
|
||||
build === "enterprise" &&
|
||||
childItem.showEE &&
|
||||
!isUnlocked();
|
||||
const childIsDisabled =
|
||||
disabled || childIsEE;
|
||||
|
||||
if (!childHydratedHref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={childItem.title}
|
||||
href={
|
||||
childIsDisabled
|
||||
? "#"
|
||||
: childHydratedHref
|
||||
}
|
||||
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",
|
||||
childIsDisabled &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (childIsDisabled) {
|
||||
e.preventDefault();
|
||||
} else if (
|
||||
onItemClick
|
||||
) {
|
||||
onItemClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{childItem.icon && (
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
||||
{childItem.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="truncate">
|
||||
{t(childItem.title)}
|
||||
</span>
|
||||
{childItem.isBeta && (
|
||||
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
|
||||
{t("beta")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{build === "enterprise" &&
|
||||
childItem.showEE &&
|
||||
!isUnlocked() && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="flex-shrink-0 ml-2"
|
||||
>
|
||||
{t(
|
||||
"licenseBadge"
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<CollapsedNavItemWithPopover
|
||||
key={item.title}
|
||||
item={item}
|
||||
tooltipText={tooltipText}
|
||||
isActive={isActive}
|
||||
isChildActive={isChildActive}
|
||||
isDisabled={!!isDisabled}
|
||||
hydrateHref={hydrateHref}
|
||||
pathname={pathname}
|
||||
build={build}
|
||||
isUnlocked={isUnlocked}
|
||||
disabled={disabled ?? false}
|
||||
t={t}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,7 +619,7 @@ export function SidebarNav({
|
||||
className={cn(sectionIndex > 0 && "mt-4")}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
<div className="px-3 py-2 text-xs font-medium text-foreground uppercase tracking-wider">
|
||||
{t(`${section.heading}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,7 @@ type SignupFormProps = {
|
||||
inviteToken?: string;
|
||||
emailParam?: string;
|
||||
fromSmartLogin?: boolean;
|
||||
skipVerificationEmail?: boolean;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
@@ -103,7 +104,8 @@ export default function SignupForm({
|
||||
inviteId,
|
||||
inviteToken,
|
||||
emailParam,
|
||||
fromSmartLogin = false
|
||||
fromSmartLogin = false,
|
||||
skipVerificationEmail = false
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -147,7 +149,8 @@ export default function SignupForm({
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt,
|
||||
marketingEmailConsent:
|
||||
build === "saas" ? marketingEmailConsent : undefined
|
||||
build === "saas" ? marketingEmailConsent : undefined,
|
||||
skipVerificationEmail: skipVerificationEmail || undefined
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
@@ -68,15 +68,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{site.type == "newt" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.address?.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -141,7 +141,24 @@ export default function SitesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
return <span className="p-3">{t("name")}</span>;
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface StrategyOption<TValue extends string> {
|
||||
|
||||
interface StrategySelectProps<TValue extends string> {
|
||||
options: ReadonlyArray<StrategyOption<TValue>>;
|
||||
value?: TValue | null;
|
||||
defaultValue?: TValue;
|
||||
onChange?: (value: TValue) => void;
|
||||
cols?: number;
|
||||
@@ -21,18 +22,21 @@ interface StrategySelectProps<TValue extends string> {
|
||||
|
||||
export function StrategySelect<TValue extends string>({
|
||||
options,
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [selected, setSelected] = useState<TValue | undefined>(defaultValue);
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
defaultValue={defaultValue}
|
||||
value={selected ?? ""}
|
||||
onValueChange={(value: string) => {
|
||||
const typedValue = value as TValue;
|
||||
setSelected(typedValue);
|
||||
if (!isControlled) setUncontrolledSelected(typedValue);
|
||||
onChange?.(typedValue);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
|
||||
@@ -155,62 +155,72 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
const isCurrentUser =
|
||||
`${userRow.username}-${userRow.idpId}` ===
|
||||
`${user?.username}-${user?.idpId}`;
|
||||
const isDisabled = userRow.isOwner || isCurrentUser;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<div>
|
||||
{!userRow.isOwner && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("accessUsersManage")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{`${userRow.username}-${userRow.idpId}` !==
|
||||
`${user?.username}-${user?.idpId}` && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
true
|
||||
);
|
||||
setSelectedUser(
|
||||
userRow
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("accessUserRemove")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
className="block w-full"
|
||||
aria-disabled={isDisabled}
|
||||
onClick={(e) =>
|
||||
isDisabled && e.preventDefault()
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t("accessUsersManage")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{!isDisabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setSelectedUser(userRow);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("accessUserRemove")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{!userRow.isOwner && (
|
||||
{isDisabled ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="ml-2"
|
||||
disabled
|
||||
>
|
||||
{t("manage")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className="ml-2"
|
||||
disabled={userRow.isOwner}
|
||||
>
|
||||
{t("manage")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { Button } from "./ui/button";
|
||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||
import { useState } from "react";
|
||||
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
|
||||
import { Terminal } from "lucide-react";
|
||||
@@ -138,6 +138,14 @@ WantedBy=default.target`
|
||||
|
||||
const commands = commandList[platform][architecture];
|
||||
|
||||
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
|
||||
(os) => ({
|
||||
value: os,
|
||||
label: getPlatformName(os),
|
||||
icon: getPlatformIcon(os)
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -149,53 +157,33 @@ WantedBy=default.target`
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{PLATFORMS.map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
>
|
||||
{getPlatformIcon(os)}
|
||||
{getPlatformName(os)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<OptionSelect<Platform>
|
||||
label={t("operatingSystem")}
|
||||
options={platformOptions}
|
||||
value={platform}
|
||||
onChange={(os) => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
cols={5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{["docker", "podman"].includes(platform)
|
||||
<OptionSelect<string>
|
||||
label={
|
||||
["docker", "podman"].includes(platform)
|
||||
? t("method")
|
||||
: t("architecture")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures(platform).map((arch) => (
|
||||
<Button
|
||||
key={arch}
|
||||
variant={
|
||||
architecture === arch
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => setArchitecture(arch)}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
: t("architecture")
|
||||
}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
label: arch
|
||||
}))}
|
||||
value={architecture}
|
||||
onChange={setArchitecture}
|
||||
cols={5}
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
@@ -250,7 +238,6 @@ WantedBy=default.target`
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { Button } from "./ui/button";
|
||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
@@ -88,6 +88,15 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
};
|
||||
|
||||
const commands = commandList[platform][architecture];
|
||||
|
||||
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
|
||||
(os) => ({
|
||||
value: os,
|
||||
label: getPlatformName(os),
|
||||
icon: getPlatformIcon(os)
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -99,54 +108,35 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{PLATFORMS.map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
>
|
||||
{getPlatformIcon(os)}
|
||||
{getPlatformName(os)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<OptionSelect<Platform>
|
||||
label={t("operatingSystem")}
|
||||
options={platformOptions}
|
||||
value={platform}
|
||||
onChange={(os) => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
cols={5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{["docker", "podman"].includes(platform)
|
||||
<OptionSelect<string>
|
||||
label={
|
||||
platform === "docker"
|
||||
? t("method")
|
||||
: t("architecture")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures(platform).map((arch) => (
|
||||
<Button
|
||||
key={arch}
|
||||
variant={
|
||||
architecture === arch
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
||||
onClick={() => setArchitecture(arch)}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
: t("architecture")
|
||||
}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
label: arch
|
||||
}))}
|
||||
value={architecture}
|
||||
onChange={setArchitecture}
|
||||
cols={5}
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">{t("commands")}</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{commands.map((item, index) => {
|
||||
@@ -174,7 +164,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
|
||||
|
||||
|
||||
@@ -124,11 +124,6 @@ export function ControlledDataTable<TData, TValue>({
|
||||
return initial;
|
||||
}, [filters]);
|
||||
|
||||
console.log({
|
||||
pagination,
|
||||
rowCount
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
|
||||
@@ -320,11 +320,6 @@ export function DataTable<TData, TValue>({
|
||||
return result;
|
||||
}, [data, tabs, activeTab, filters, activeFilters]);
|
||||
|
||||
console.log({
|
||||
pagination,
|
||||
paginationState
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
@@ -852,12 +847,14 @@ export function DataTable<TData, TValue>({
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
{table.getRowModel().rows?.length > 0 && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,31 +2,20 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function authCookieHeader() {
|
||||
const otherHeaders = await headers();
|
||||
const otherHeadersObject = Object.fromEntries(otherHeaders.entries());
|
||||
const otherHeadersObject = Object.fromEntries(
|
||||
Array.from(otherHeaders.entries()).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
|
||||
return {
|
||||
headers: {
|
||||
cookie:
|
||||
otherHeadersObject["cookie"] || otherHeadersObject["Cookie"],
|
||||
host: otherHeadersObject["host"] || otherHeadersObject["Host"],
|
||||
"user-agent":
|
||||
otherHeadersObject["user-agent"] ||
|
||||
otherHeadersObject["User-Agent"],
|
||||
"x-forwarded-for":
|
||||
otherHeadersObject["x-forwarded-for"] ||
|
||||
otherHeadersObject["X-Forwarded-For"],
|
||||
"x-forwarded-host":
|
||||
otherHeadersObject["fx-forwarded-host"] ||
|
||||
otherHeadersObject["Fx-Forwarded-Host"],
|
||||
"x-forwarded-port":
|
||||
otherHeadersObject["x-forwarded-port"] ||
|
||||
otherHeadersObject["X-Forwarded-Port"],
|
||||
"x-forwarded-proto":
|
||||
otherHeadersObject["x-forwarded-proto"] ||
|
||||
otherHeadersObject["X-Forwarded-Proto"],
|
||||
"x-real-ip":
|
||||
otherHeadersObject["x-real-ip"] ||
|
||||
otherHeadersObject["X-Real-IP"]
|
||||
cookie: otherHeadersObject["cookie"],
|
||||
host: otherHeadersObject["host"],
|
||||
"user-agent": otherHeadersObject["user-agent"],
|
||||
"x-forwarded-for": otherHeadersObject["x-forwarded-for"],
|
||||
"x-forwarded-host": otherHeadersObject["x-forwarded-host"],
|
||||
"x-forwarded-port": otherHeadersObject["x-forwarded-port"],
|
||||
"x-forwarded-proto": otherHeadersObject["x-forwarded-proto"],
|
||||
"x-real-ip": otherHeadersObject["x-real-ip"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||
try {
|
||||
const subRes = await getCachedSubscription(orgId);
|
||||
subscribed =
|
||||
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") &&
|
||||
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
|
||||
subRes.data.data.active;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full redirect target for an org: either `/${orgId}` or
|
||||
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
|
||||
* stored value.
|
||||
* Returns the full redirect target if a valid internal_redirect was stored
|
||||
* (consumes the stored value). Returns null if none was stored or expired.
|
||||
* Paths starting with /auth/ are returned as-is; others are prefixed with orgId.
|
||||
*/
|
||||
export function getInternalRedirectTarget(orgId: string): string {
|
||||
export function getInternalRedirectTarget(orgId: string): string | null {
|
||||
const path = consumeInternalRedirectPath();
|
||||
return path ? `/${orgId}${path}` : `/${orgId}`;
|
||||
if (!path) return null;
|
||||
return path.startsWith("/auth/") ? path : `/${orgId}${path}`;
|
||||
}
|
||||
|
||||
16
src/lib/validateLocalPath.ts
Normal file
16
src/lib/validateLocalPath.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function validateLocalPath(value: string) {
|
||||
try {
|
||||
const url = new URL("https://pangoling.net" + value);
|
||||
if (
|
||||
url.pathname !== value ||
|
||||
value.includes("..") ||
|
||||
value.includes("*")
|
||||
) {
|
||||
throw new Error("Invalid Path");
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
"should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ export function SubscriptionStatusProvider({
|
||||
if (
|
||||
subscription.type == "tier1" ||
|
||||
subscription.type == "tier2" ||
|
||||
subscription.type == "tier3"
|
||||
subscription.type == "tier3" ||
|
||||
subscription.type == "enterprise"
|
||||
) {
|
||||
return {
|
||||
tier: subscription.type,
|
||||
@@ -61,7 +62,7 @@ export function SubscriptionStatusProvider({
|
||||
const isSubscribed = () => {
|
||||
const { tier, active } = getTier();
|
||||
return (
|
||||
(tier == "tier1" || tier == "tier2" || tier == "tier3") &&
|
||||
(tier == "tier1" || tier == "tier2" || tier == "tier3" || tier == "enterprise") &&
|
||||
active
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user