mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-04 11:34:19 +00:00
Compare commits
11 Commits
newt-insta
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f7c4a9c4 | ||
|
|
1cc0e9b689 | ||
|
|
584be4dbd2 | ||
|
|
c33e295ce7 | ||
|
|
1a926a7127 | ||
|
|
eb515a8f7f | ||
|
|
81b8a8a9e3 | ||
|
|
bcd164219f | ||
|
|
c90e405105 | ||
|
|
b2c8311b26 | ||
|
|
8e1905a695 |
@@ -25,6 +25,10 @@
|
||||
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
||||
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
||||
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
|
||||
"billingTrialBannerTitle": "Free Trial Active",
|
||||
"billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
|
||||
"billingTrialBannerUpgrade": "Upgrade Now",
|
||||
"billingTrialBadge": "Free Trial",
|
||||
"trialActive": "Free Trial Active",
|
||||
"trialExpired": "Trial Expired",
|
||||
"trialHasEnded": "Your trial has ended.",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
|
||||
export async function handleCustomerCreated(
|
||||
customer: Stripe.Customer
|
||||
@@ -62,6 +63,13 @@ export async function handleCustomerCreated(
|
||||
expiresAt: trialExpiresAt,
|
||||
trial: true
|
||||
});
|
||||
|
||||
// update to the business limits for the trial
|
||||
await handleSubscriptionLifesycle(
|
||||
customer.metadata.orgId,
|
||||
"active",
|
||||
"tier3"
|
||||
);
|
||||
});
|
||||
|
||||
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
||||
|
||||
@@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType(
|
||||
export async function handleSubscriptionLifesycle(
|
||||
orgId: string,
|
||||
status: string,
|
||||
subType: SubscriptionType | null
|
||||
subType: SubscriptionType | null = null
|
||||
) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
|
||||
@@ -90,14 +90,13 @@ export async function createCertificate(
|
||||
domainToWrite = `*.${domainToWrite}`;
|
||||
}
|
||||
} else if (domainRecord.type == "ns") {
|
||||
// first if we have a * in the domain for this case we dont want to include it because it will mess with the cert generator so remove it
|
||||
if (domain.startsWith("*.")) {
|
||||
domain = domain.slice(2);
|
||||
}
|
||||
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
if (domain == domainRecord.baseDomain) {
|
||||
domainToWrite = domainRecord.baseDomain;
|
||||
} else {
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,18 @@ import { fromError } from "zod-validation-error";
|
||||
import { sendEmail } from "@server/emails";
|
||||
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
||||
import config from "@server/lib/config";
|
||||
import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle";
|
||||
|
||||
const sendTrialNotificationParamsSchema = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const sendTrialNotificationBodySchema = z.object({
|
||||
notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]),
|
||||
notificationType: z.enum([
|
||||
"trial_ending_5d",
|
||||
"trial_ending_24h",
|
||||
"trial_ended"
|
||||
]),
|
||||
orgName: z.string(),
|
||||
trialEndsAt: z.number(),
|
||||
billingLink: z.string().optional()
|
||||
@@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) {
|
||||
)
|
||||
);
|
||||
|
||||
const byUserId = new Map(
|
||||
admins.map((a) => [a.userId, a])
|
||||
);
|
||||
const byUserId = new Map(admins.map((a) => [a.userId, a]));
|
||||
const orgAdmins = Array.from(byUserId.values()).filter(
|
||||
(admin) => admin.email && admin.email.length > 0
|
||||
);
|
||||
@@ -108,8 +111,12 @@ export async function sendTrialNotification(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } =
|
||||
parsedBody.data;
|
||||
const {
|
||||
notificationType,
|
||||
orgName,
|
||||
trialEndsAt,
|
||||
billingLink: bodyBillingLink
|
||||
} = parsedBody.data;
|
||||
|
||||
// Verify organization exists
|
||||
const org = await db
|
||||
@@ -146,13 +153,17 @@ export async function sendTrialNotification(
|
||||
bodyBillingLink ??
|
||||
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
||||
|
||||
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{ year: "numeric", month: "long", day: "numeric" }
|
||||
);
|
||||
const trialEndsAtFormatted = new Date(
|
||||
trialEndsAt * 1000
|
||||
).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
|
||||
let daysRemaining: number | null;
|
||||
let subject: string;
|
||||
let resetLimits = false;
|
||||
|
||||
if (notificationType === "trial_ending_5d") {
|
||||
daysRemaining = 5;
|
||||
@@ -163,6 +174,7 @@ export async function sendTrialNotification(
|
||||
} else {
|
||||
daysRemaining = null;
|
||||
subject = "Your trial has ended";
|
||||
resetLimits = true;
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
@@ -201,6 +213,14 @@ export async function sendTrialNotification(
|
||||
}
|
||||
}
|
||||
|
||||
if (resetLimits) {
|
||||
// this will only fire if they have not upgraded yet because when upgrading we delete the trial
|
||||
await handleSubscriptionLifesycle(orgId, "cancled");
|
||||
logger.debug(
|
||||
`Trial ended for org ${orgId}, limits reset to free tier`
|
||||
);
|
||||
}
|
||||
|
||||
return response<SendTrialNotificationResponse>(res, {
|
||||
data: {
|
||||
success: true,
|
||||
@@ -221,4 +241,4 @@ export async function sendTrialNotification(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1003,7 +1003,11 @@ async function checkRules(
|
||||
isIpInCidr(clientIp, rule.value)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "IP" &&
|
||||
clientIp == rule.value
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
path &&
|
||||
@@ -1013,16 +1017,35 @@ async function checkRules(
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "COUNTRY" &&
|
||||
(await isIpInGeoIP(ipCC, rule.value))
|
||||
rule.match == "COUNTRY"
|
||||
) {
|
||||
return rule.action as any;
|
||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
rule.value.toUpperCase() === "ALL" &&
|
||||
isLocalOrCarrierGradeNatIp(clientIp)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||
return rule.action as any;
|
||||
}
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "ASN" &&
|
||||
(await isIpInAsn(ipAsn, rule.value))
|
||||
rule.match == "ASN"
|
||||
) {
|
||||
return rule.action as any;
|
||||
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
(rule.value.toUpperCase() === "ALL" ||
|
||||
rule.value.toUpperCase() === "AS0") &&
|
||||
isLocalOrCarrierGradeNatIp(clientIp)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isIpInAsn(ipAsn, rule.value)) {
|
||||
return rule.action as any;
|
||||
}
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "REGION" &&
|
||||
@@ -1184,6 +1207,26 @@ async function isIpInGeoIP(
|
||||
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
||||
}
|
||||
|
||||
function isLocalOrCarrierGradeNatIp(ip: string): boolean {
|
||||
const localAndCgnatCidrs = [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
"fe80::/10"
|
||||
];
|
||||
|
||||
try {
|
||||
return localAndCgnatCidrs.some((cidr) => isIpInCidr(ip, cidr));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isIpInAsn(
|
||||
ipAsn: number | undefined,
|
||||
checkAsn: string
|
||||
|
||||
@@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
assignUserToOrg,
|
||||
removeUserFromOrg
|
||||
} from "@server/lib/userOrg";
|
||||
import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
|
||||
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
@@ -336,23 +333,23 @@ export async function validateOidcCallback(
|
||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||
|
||||
for (const org of allOrgs) {
|
||||
const subscribed = await isSubscribed(
|
||||
org.orgId,
|
||||
tierMatrix.autoProvisioning
|
||||
);
|
||||
if (!subscribed) {
|
||||
// filter out the org
|
||||
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
||||
// for (const org of allOrgs) {
|
||||
// const subscribed = await isSubscribed(
|
||||
// org.orgId,
|
||||
// tierMatrix.autoProvisioning
|
||||
// );
|
||||
// if (!subscribed) {
|
||||
// // filter out the org
|
||||
// allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
||||
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.FORBIDDEN,
|
||||
// "This organization's current plan does not support this feature."
|
||||
// )
|
||||
// );
|
||||
}
|
||||
}
|
||||
// // return next(
|
||||
// // createHttpError(
|
||||
// // HttpCode.FORBIDDEN,
|
||||
// // "This organization's current plan does not support this feature."
|
||||
// // )
|
||||
// // );
|
||||
// }
|
||||
// }
|
||||
} else {
|
||||
allOrgs = await db.select().from(orgs);
|
||||
}
|
||||
@@ -396,16 +393,14 @@ export async function validateOidcCallback(
|
||||
idpOrgRes?.roleMapping || defaultRoleMapping;
|
||||
if (roleMapping) {
|
||||
logger.debug("Role Mapping", { roleMapping });
|
||||
const roleMappingJmes = unwrapRoleMapping(
|
||||
roleMapping
|
||||
).evaluationExpression;
|
||||
const roleMappingJmes =
|
||||
unwrapRoleMapping(roleMapping).evaluationExpression;
|
||||
const roleMappingResult = jmespath.search(
|
||||
claims,
|
||||
roleMappingJmes
|
||||
);
|
||||
const roleNames = normalizeRoleMappingResult(
|
||||
roleMappingResult
|
||||
);
|
||||
const roleNames =
|
||||
normalizeRoleMappingResult(roleMappingResult);
|
||||
|
||||
const supportsMultiRole = await isLicensedOrSubscribed(
|
||||
org.orgId,
|
||||
@@ -515,7 +510,7 @@ export async function validateOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||
|
||||
// sync the user with the orgs and roles
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -628,7 +623,7 @@ export async function validateOidcCallback(
|
||||
{
|
||||
orgId: org.orgId,
|
||||
userId: userId!,
|
||||
autoProvisioned: true,
|
||||
autoProvisioned: true
|
||||
},
|
||||
org.roleIds,
|
||||
trx
|
||||
@@ -758,9 +753,7 @@ function hydrateOrgMapping(
|
||||
return orgMapping.split("{{orgId}}").join(orgId);
|
||||
}
|
||||
|
||||
function normalizeRoleMappingResult(
|
||||
result: unknown
|
||||
): string[] {
|
||||
function normalizeRoleMappingResult(result: unknown): string[] {
|
||||
if (typeof result === "string") {
|
||||
const role = result.trim();
|
||||
return role ? [role] : [];
|
||||
@@ -770,7 +763,9 @@ function normalizeRoleMappingResult(
|
||||
return [
|
||||
...new Set(
|
||||
result
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.filter(
|
||||
(value): value is string => typeof value === "string"
|
||||
)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@app/components/Credenza";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
tier3LimitSet
|
||||
} from "@server/lib/billing/limitSet";
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
import TrialBillingBanner from "@app/components/TrialBillingBanner";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||
@@ -805,6 +807,20 @@ export default function BillingPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/* Trial Banner */}
|
||||
{isTrial && (
|
||||
<TrialBillingBanner
|
||||
onUpgrade={() => {
|
||||
const currentPlan = planOptions.find(
|
||||
(p) => p.id === currentPlanId
|
||||
);
|
||||
if (currentPlan?.tierType) {
|
||||
handleStartSubscription(currentPlan.tierType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subscription Status Alert */}
|
||||
{isProblematicState && statusMessage && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
@@ -859,8 +875,19 @@ export default function BillingPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-2xl">
|
||||
{plan.name}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-2xl">
|
||||
{plan.name}
|
||||
</span>
|
||||
{isCurrentPlan && isTrial && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("billingTrialBadge") ||
|
||||
"Free Trial"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-xl">
|
||||
|
||||
@@ -13,6 +13,7 @@ type DismissableBannerProps = {
|
||||
titleIcon: ReactNode;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
dismissable?: boolean;
|
||||
};
|
||||
|
||||
export const DismissableBanner = ({
|
||||
@@ -21,7 +22,8 @@ export const DismissableBanner = ({
|
||||
title,
|
||||
titleIcon,
|
||||
description,
|
||||
children
|
||||
children,
|
||||
dismissable = true
|
||||
}: DismissableBannerProps) => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
const t = useTranslations();
|
||||
@@ -66,19 +68,21 @@ export const DismissableBanner = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (isDismissed) {
|
||||
if (dismissable && isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
{dismissable && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
KeyRound,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -50,6 +49,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
@@ -63,6 +63,61 @@ export type IdpRow = {
|
||||
|
||||
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
|
||||
|
||||
type ImportSourceOrg = { orgId: string; orgName: string };
|
||||
|
||||
type GroupedImportableIdp = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
variant: string;
|
||||
tags: string | null;
|
||||
sources: ImportSourceOrg[];
|
||||
};
|
||||
|
||||
function adminRowForImport(
|
||||
group: GroupedImportableIdp,
|
||||
source: ImportSourceOrg
|
||||
): AdminIdpRow {
|
||||
return {
|
||||
idpId: group.idpId,
|
||||
orgId: source.orgId,
|
||||
orgName: source.orgName,
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
variant: group.variant,
|
||||
tags: group.tags
|
||||
};
|
||||
}
|
||||
|
||||
function groupImportableIdps(rows: AdminIdpRow[]): GroupedImportableIdp[] {
|
||||
const map = new Map<number, GroupedImportableIdp>();
|
||||
for (const row of rows) {
|
||||
let g = map.get(row.idpId);
|
||||
if (!g) {
|
||||
g = {
|
||||
idpId: row.idpId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
variant: row.variant,
|
||||
tags: row.tags,
|
||||
sources: []
|
||||
};
|
||||
map.set(row.idpId, g);
|
||||
}
|
||||
if (!g.sources.some((s) => s.orgId === row.orgId)) {
|
||||
g.sources.push({ orgId: row.orgId, orgName: row.orgName });
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
sources: [...item.sources].sort((a, b) =>
|
||||
a.orgName.localeCompare(b.orgName)
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
|
||||
function IdpImportRowIcon({
|
||||
type,
|
||||
variant
|
||||
@@ -114,16 +169,22 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
);
|
||||
}, [adminIdpsRaw, orgId, idps]);
|
||||
|
||||
const shownImportIdps = useMemo(() => {
|
||||
const importableGrouped = useMemo(
|
||||
() => groupImportableIdps(importableIdps),
|
||||
[importableIdps]
|
||||
);
|
||||
|
||||
const shownImportGrouped = useMemo(() => {
|
||||
const q = debouncedImportSearch.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return importableIdps;
|
||||
return importableGrouped;
|
||||
}
|
||||
return importableIdps.filter((row) => {
|
||||
const hay = `${row.orgName} ${row.name}`.toLowerCase();
|
||||
return importableGrouped.filter((group) => {
|
||||
const hay =
|
||||
`${group.name} ${group.sources.map((s) => s.orgName).join(" ")}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [importableIdps, debouncedImportSearch]);
|
||||
}, [importableGrouped, debouncedImportSearch]);
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
@@ -364,31 +425,44 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
{t("idpImportEmpty")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{shownImportIdps.map((row) => (
|
||||
{shownImportGrouped.map((group) => (
|
||||
<CommandItem
|
||||
key={`${row.idpId}:${row.orgId}`}
|
||||
key={group.idpId}
|
||||
className="items-start gap-3 py-2.5"
|
||||
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`}
|
||||
value={`${group.idpId}:${group.name}:${group.sources.map((s) => s.orgName).join(" ")}`}
|
||||
disabled={!canImportOrgOidcIdp}
|
||||
onSelect={() => {
|
||||
if (!canImportOrgOidcIdp) {
|
||||
return;
|
||||
}
|
||||
void importIdp(row);
|
||||
void importIdp(
|
||||
adminRowForImport(
|
||||
group,
|
||||
group.sources[0]
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<IdpImportRowIcon
|
||||
type={row.type}
|
||||
variant={row.variant}
|
||||
type={group.type}
|
||||
variant={group.variant}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium leading-tight">
|
||||
{row.orgName}
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="truncate text-sm leading-tight text-muted-foreground">
|
||||
{row.name}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{group.sources.map((src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LoginPasswordForm from "@app/components/LoginPasswordForm";
|
||||
import LoginOrgSelector from "@app/components/LoginOrgSelector";
|
||||
import SmartLoginOrgSelector from "@app/components/SmartLoginOrgSelector";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
@@ -206,7 +206,7 @@ export default function SmartLoginForm({
|
||||
if (viewState.type === "orgSelector") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LoginOrgSelector
|
||||
<SmartLoginOrgSelector
|
||||
identifier={viewState.identifier}
|
||||
lookupResult={viewState.lookupResult}
|
||||
redirect={redirect}
|
||||
|
||||
297
src/components/SmartLoginOrgSelector.tsx
Normal file
297
src/components/SmartLoginOrgSelector.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LoginPasswordForm from "@app/components/LoginPasswordForm";
|
||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||
import {
|
||||
redirect as redirectTo,
|
||||
useRouter,
|
||||
useSearchParams
|
||||
} from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
type SmartLoginOrgSelectorProps = {
|
||||
identifier: string;
|
||||
lookupResult: LookupUserResponse;
|
||||
redirect?: string;
|
||||
forceLogin?: boolean;
|
||||
onUseDifferentAccount?: () => void;
|
||||
};
|
||||
|
||||
type OrgBucket = {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
idps: Array<{
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant: string | null;
|
||||
}>;
|
||||
hasInternalAuth: boolean;
|
||||
};
|
||||
|
||||
type GroupedLoginIdp = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant: string | null;
|
||||
orgs: { orgId: string; orgName: string }[];
|
||||
};
|
||||
|
||||
function buildOrgMap(lookupResult: LookupUserResponse) {
|
||||
const orgMap = new Map<string, OrgBucket>();
|
||||
|
||||
for (const account of lookupResult.accounts) {
|
||||
for (const org of account.orgs) {
|
||||
if (!orgMap.has(org.orgId)) {
|
||||
orgMap.set(org.orgId, {
|
||||
orgId: org.orgId,
|
||||
orgName: org.orgName,
|
||||
idps: org.idps,
|
||||
hasInternalAuth: org.hasInternalAuth
|
||||
});
|
||||
} else {
|
||||
const existing = orgMap.get(org.orgId)!;
|
||||
const existingIdpIds = new Set(
|
||||
existing.idps.map((i) => i.idpId)
|
||||
);
|
||||
for (const idp of org.idps) {
|
||||
if (!existingIdpIds.has(idp.idpId)) {
|
||||
existing.idps.push(idp);
|
||||
}
|
||||
}
|
||||
if (org.hasInternalAuth) {
|
||||
existing.hasInternalAuth = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(orgMap.values());
|
||||
}
|
||||
|
||||
function groupIdpsAcrossOrgs(orgs: OrgBucket[]): GroupedLoginIdp[] {
|
||||
const map = new Map<number, GroupedLoginIdp>();
|
||||
|
||||
for (const org of orgs) {
|
||||
for (const idp of org.idps) {
|
||||
let g = map.get(idp.idpId);
|
||||
if (!g) {
|
||||
g = {
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.variant,
|
||||
orgs: []
|
||||
};
|
||||
map.set(idp.idpId, g);
|
||||
}
|
||||
if (!g.orgs.some((o) => o.orgId === org.orgId)) {
|
||||
g.orgs.push({ orgId: org.orgId, orgName: org.orgName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.map((g) => ({
|
||||
...g,
|
||||
orgs: [...g.orgs].sort((a, b) => a.orgName.localeCompare(b.orgName))
|
||||
}))
|
||||
.sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
|
||||
export default function SmartLoginOrgSelector({
|
||||
identifier,
|
||||
lookupResult,
|
||||
redirect,
|
||||
forceLogin,
|
||||
onUseDifferentAccount
|
||||
}: SmartLoginOrgSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingIdpId, setPendingIdpId] = useState<number | null>(null);
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const orgs = buildOrgMap(lookupResult);
|
||||
const groupedIdps = groupIdpsAcrossOrgs(orgs);
|
||||
|
||||
const hasInternalAccount = lookupResult.accounts.some(
|
||||
(acc) => acc.hasInternalAuth
|
||||
);
|
||||
|
||||
function goToApp() {
|
||||
const url = window.location.href.split("?")[0];
|
||||
router.push(url);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get("gotoapp")) {
|
||||
goToApp();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function loginWithIdp(idpId: number, orgId: string) {
|
||||
setPendingIdpId(idpId);
|
||||
setError(null);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const safeRedirect = cleanRedirect(redirect || "/");
|
||||
const response = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
safeRedirect,
|
||||
orgId,
|
||||
forceLogin
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setPendingIdpId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (data?.redirectUrl) {
|
||||
redirectToUrl = data.redirectUrl;
|
||||
}
|
||||
} catch {
|
||||
setError(
|
||||
t("loginError", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectTo(redirectToUrl);
|
||||
} else {
|
||||
setPendingIdpId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (showPasswordForm) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t(
|
||||
"deviceLoginUseDifferentAccount"
|
||||
)}
|
||||
/>
|
||||
<LoginPasswordForm
|
||||
identifier={identifier}
|
||||
redirect={redirect}
|
||||
forceLogin={forceLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserProfileCard
|
||||
identifier={identifier}
|
||||
description={t("loginSelectAuthenticationMethod")}
|
||||
onUseDifferentAccount={onUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
|
||||
{hasInternalAccount && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
>
|
||||
{t("signInWithPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedIdps.length > 0 ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
{t("idpContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{params.get("gotoapp") ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
) : (
|
||||
groupedIdps.map((group) => {
|
||||
const effectiveType =
|
||||
group.variant || group.name.toLowerCase();
|
||||
const sourceOrgId = group.orgs[0].orgId;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={group.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto w-full flex flex-wrap items-center justify-start gap-x-2 gap-y-1.5 py-3 text-left"
|
||||
onClick={() => {
|
||||
void loginWithIdp(
|
||||
group.idpId,
|
||||
sourceOrgId
|
||||
);
|
||||
}}
|
||||
disabled={pendingIdpId !== null}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="font-medium shrink-0">
|
||||
{group.name}
|
||||
</span>
|
||||
{group.orgs.map((org) => (
|
||||
<Badge
|
||||
key={org.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full shrink-0 truncate font-normal"
|
||||
>
|
||||
{org.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/TrialBillingBanner.tsx
Normal file
38
src/components/TrialBillingBanner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ClockIcon, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type TrialBillingBannerProps = {
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="trial-billing-banner-dismissed"
|
||||
version={1}
|
||||
title={t("billingTrialBannerTitle")}
|
||||
titleIcon={<ClockIcon className="w-5 h-5 text-primary" />}
|
||||
description={t("billingTrialBannerDescription")}
|
||||
dismissable={false}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{t("billingTrialBannerUpgrade")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialBillingBanner;
|
||||
Reference in New Issue
Block a user