mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 02:46:37 +00:00
Merge branch 'dev' into refactor/paginated-tables
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={[]}>
|
||||
|
||||
Reference in New Issue
Block a user