Merge branch 'dev' into refactor/paginated-tables

This commit is contained in:
Fred KISSIE
2026-02-06 03:54:50 +01:00
62 changed files with 2377 additions and 2537 deletions

View File

@@ -18,6 +18,7 @@ import { build } from "@server/build";
import OrgPolicyResult from "@app/components/OrgPolicyResult";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -70,6 +71,7 @@ export default async function OrgLayout(props: {
} catch (e) {}
return (
<UserProvider user={user}>
<ApplyInternalRedirect orgId={orgId} />
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
<OrgPolicyResult
orgId={orgId}
@@ -86,7 +88,7 @@ export default async function OrgLayout(props: {
try {
const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
`/org/${orgId}/billing/subscription`,
`/org/${orgId}/billing/subscriptions`,
cookie
)
);
@@ -104,6 +106,7 @@ export default async function OrgLayout(props: {
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
<ApplyInternalRedirect orgId={orgId} />
{props.children}
<SetLastOrgCookie orgId={orgId} />
</SubscriptionStatusProvider>

View File

@@ -43,15 +43,18 @@ import Link from "next/link";
export default function GeneralPage() {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const envContext = useEnvContext();
const api = createApiClient(envContext);
const t = useTranslations();
// Subscription state
const [subscription, setSubscription] =
useState<GetOrgSubscriptionResponse["subscription"]>(null);
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
// Subscription state - now handling multiple subscriptions
const [allSubscriptions, setAllSubscriptions] = useState<
GetOrgSubscriptionResponse["subscriptions"]
>([]);
const [tierSubscription, setTierSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [licenseSubscription, setLicenseSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available)
@@ -68,12 +71,41 @@ export default function GeneralPage() {
try {
const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscription`);
const { subscription, items } = res.data.data;
setSubscription(subscription);
setSubscriptionItems(items);
>(`/org/${org.org.orgId}/billing/subscriptions`);
const { subscriptions } = res.data.data;
setAllSubscriptions(subscriptions);
// Import tier and license price sets
const { getTierPriceSet } = await import("@server/lib/billing/tiers");
const { getLicensePriceSet } = await import("@server/lib/billing/licenses");
const tierPriceSet = getTierPriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
const licensePriceSet = getLicensePriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
// Find tier subscription (subscription with items matching tier prices)
const tierSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(tierPriceSet).includes(item.priceId)
)
);
setTierSubscription(tierSub || null);
// Find license subscription (subscription with items matching license prices)
const licenseSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(licensePriceSet).includes(item.priceId)
)
);
setLicenseSubscription(licenseSub || null);
setHasSubscription(
!!subscription && subscription.status === "active"
!!tierSub?.subscription && tierSub.subscription.status === "active"
);
} catch (error) {
toast({
@@ -121,7 +153,7 @@ export default function GeneralPage() {
setIsLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
`/org/${org.org.orgId}/billing/create-checkout-session`,
`/org/${org.org.orgId}/billing/create-checkout-session-saas`,
{}
);
console.log("Checkout session response:", response.data);
@@ -302,6 +334,10 @@ export default function GeneralPage() {
return { usage: usage ?? 0, item, limit };
}
// Get tier subscription items
const tierSubscriptionItems = tierSubscription?.items || [];
const tierSubscriptionData = tierSubscription?.subscription || null;
// Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false;
@@ -388,15 +424,15 @@ export default function GeneralPage() {
<div className="flex items-center justify-between mb-6">
<Badge
variant={
subscription?.status === "active" ? "green" : "outline"
tierSubscriptionData?.status === "active" ? "green" : "outline"
}
>
{subscription?.status === "active" && (
{tierSubscriptionData?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{subscription
? subscription.status.charAt(0).toUpperCase() +
subscription.status.slice(1)
{tierSubscriptionData
? tierSubscriptionData.status.charAt(0).toUpperCase() +
tierSubscriptionData.status.slice(1)
: t("billingFreeTier")}
</Badge>
<Link
@@ -413,7 +449,7 @@ export default function GeneralPage() {
{usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -441,7 +477,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -530,7 +566,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -614,7 +650,7 @@ export default function GeneralPage() {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -636,7 +672,7 @@ export default function GeneralPage() {
);
})}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{subscriptionItems
{tierSubscriptionItems
.filter(
(item) =>
item.unitAmount &&
@@ -672,7 +708,7 @@ export default function GeneralPage() {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -687,7 +723,7 @@ export default function GeneralPage() {
return sum + cost;
}, 0) +
// Add recurring charges
subscriptionItems
tierSubscriptionItems
.filter(
(item) =>
item.unitAmount &&
@@ -749,6 +785,56 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
)}
{/* License Keys Section */}
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingLicenseKeys") || "License Keys"}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("billingLicenseKeysDescription") || "Manage your license key subscriptions"}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
<span className="font-semibold">
{t("billingLicenseSubscription") || "License Subscription"}
</span>
</div>
<Badge
variant={
licenseSubscription.subscription?.status === "active"
? "green"
: "outline"
}
>
{licenseSubscription.subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{licenseSubscription.subscription?.status
? licenseSubscription.subscription.status
.charAt(0)
.toUpperCase() +
licenseSubscription.subscription.status.slice(1)
: t("billingInactive") || "Inactive"}
</Badge>
</div>
<SettingsSectionFooter>
<Button
variant="secondary"
onClick={() => handleModifySubscription()}
disabled={isLoading}
>
{t("billingModifyLicenses") || "Modify License Subscription"}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -7,22 +7,35 @@ import { cache } from "react";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string }>;
searchParams: Promise<{ code?: string; user?: string }>;
};
function deviceRedirectSearchParams(params: {
code?: string;
user?: string;
}): string {
const search = new URLSearchParams();
if (params.code) search.set("code", params.code);
if (params.user) search.set("user", params.user);
const q = search.toString();
return q ? `?${q}` : "";
}
export default async function DeviceLoginPage({ searchParams }: Props) {
const user = await verifySession({ forceLogin: true });
const params = await searchParams;
const code = params.code || "";
const defaultUser = params.user;
if (!user) {
const redirectDestination = code
? `/auth/login/device?code=${encodeURIComponent(code)}`
: "/auth/login/device";
redirect(
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`
);
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
const loginUrl = new URL("/auth/login", "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);
}
const userName = user
@@ -37,6 +50,7 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
userEmail={user?.email || ""}
userName={userName}
initialCode={code}
userQueryParam={defaultUser}
/>
);
}

View File

@@ -72,6 +72,8 @@ export default async function Page(props: {
searchParams.redirect = redirectUrl;
}
const defaultUser = searchParams.user as string | undefined;
// Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled)
const useSmartLogin =
build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp);
@@ -151,6 +153,7 @@ export default async function Page(props: {
<SmartLoginForm
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
/>
</CardContent>
</Card>
@@ -165,6 +168,7 @@ export default async function Page(props: {
(build === "saas" || env.flags.useOrgOnlyIdp)
}
searchParams={searchParams}
defaultUser={defaultUser}
/>
)}

View File

@@ -23,6 +23,7 @@ import Script from "next/script";
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -79,6 +80,7 @@ export default async function RootLayout({
return (
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen-safe overflow-hidden`}>
<StoreInternalRedirect />
<TopLoader />
{build === "saas" && (
<Script

View File

@@ -10,6 +10,7 @@ import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
@@ -80,15 +81,16 @@ export default async function Page(props: {
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
let targetOrgId: string | null = null;
if (lastOrgExists && lastOrgCookie) {
redirect(`/${lastOrgCookie}`);
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
if (!ownedOrg) {
ownedOrg = orgs[0];
}
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
targetOrgId = ownedOrg.orgId;
} else {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
@@ -96,6 +98,10 @@ export default async function Page(props: {
}
}
if (targetOrgId) {
return <RedirectToOrg targetOrgId={targetOrgId} />;
}
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={[]}>