mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-17 22:44:42 +00:00
@@ -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.",
|
"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.",
|
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
||||||
"trialBannerExpired": "Your trial has expired. Upgrade now to restore 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",
|
"trialActive": "Free Trial Active",
|
||||||
"trialExpired": "Trial Expired",
|
"trialExpired": "Trial Expired",
|
||||||
"trialHasEnded": "Your trial has ended.",
|
"trialHasEnded": "Your trial has ended.",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
|
|
||||||
export async function handleCustomerCreated(
|
export async function handleCustomerCreated(
|
||||||
customer: Stripe.Customer
|
customer: Stripe.Customer
|
||||||
@@ -62,6 +63,13 @@ export async function handleCustomerCreated(
|
|||||||
expiresAt: trialExpiresAt,
|
expiresAt: trialExpiresAt,
|
||||||
trial: true
|
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.`);
|
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType(
|
|||||||
export async function handleSubscriptionLifesycle(
|
export async function handleSubscriptionLifesycle(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
status: string,
|
status: string,
|
||||||
subType: SubscriptionType | null
|
subType: SubscriptionType | null = null
|
||||||
) {
|
) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
|
|||||||
@@ -90,14 +90,13 @@ export async function createCertificate(
|
|||||||
domainToWrite = `*.${domainToWrite}`;
|
domainToWrite = `*.${domainToWrite}`;
|
||||||
}
|
}
|
||||||
} else if (domainRecord.type == "ns") {
|
} 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 == domainRecord.baseDomain) {
|
||||||
if (domain.startsWith("*.")) {
|
domainToWrite = domainRecord.baseDomain;
|
||||||
domain = domain.slice(2);
|
} else {
|
||||||
}
|
const parts = domain.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
const parts = domain.split(".");
|
domainToWrite = parts.slice(1).join(".");
|
||||||
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 { sendEmail } from "@server/emails";
|
||||||
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle";
|
||||||
|
|
||||||
const sendTrialNotificationParamsSchema = z.object({
|
const sendTrialNotificationParamsSchema = z.object({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendTrialNotificationBodySchema = z.object({
|
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(),
|
orgName: z.string(),
|
||||||
trialEndsAt: z.number(),
|
trialEndsAt: z.number(),
|
||||||
billingLink: z.string().optional()
|
billingLink: z.string().optional()
|
||||||
@@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUserId = new Map(
|
const byUserId = new Map(admins.map((a) => [a.userId, a]));
|
||||||
admins.map((a) => [a.userId, a])
|
|
||||||
);
|
|
||||||
const orgAdmins = Array.from(byUserId.values()).filter(
|
const orgAdmins = Array.from(byUserId.values()).filter(
|
||||||
(admin) => admin.email && admin.email.length > 0
|
(admin) => admin.email && admin.email.length > 0
|
||||||
);
|
);
|
||||||
@@ -108,8 +111,12 @@ export async function sendTrialNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } =
|
const {
|
||||||
parsedBody.data;
|
notificationType,
|
||||||
|
orgName,
|
||||||
|
trialEndsAt,
|
||||||
|
billingLink: bodyBillingLink
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Verify organization exists
|
// Verify organization exists
|
||||||
const org = await db
|
const org = await db
|
||||||
@@ -146,13 +153,17 @@ export async function sendTrialNotification(
|
|||||||
bodyBillingLink ??
|
bodyBillingLink ??
|
||||||
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
||||||
|
|
||||||
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString(
|
const trialEndsAtFormatted = new Date(
|
||||||
"en-US",
|
trialEndsAt * 1000
|
||||||
{ year: "numeric", month: "long", day: "numeric" }
|
).toLocaleDateString("en-US", {
|
||||||
);
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
});
|
||||||
|
|
||||||
let daysRemaining: number | null;
|
let daysRemaining: number | null;
|
||||||
let subject: string;
|
let subject: string;
|
||||||
|
let resetLimits = false;
|
||||||
|
|
||||||
if (notificationType === "trial_ending_5d") {
|
if (notificationType === "trial_ending_5d") {
|
||||||
daysRemaining = 5;
|
daysRemaining = 5;
|
||||||
@@ -163,6 +174,7 @@ export async function sendTrialNotification(
|
|||||||
} else {
|
} else {
|
||||||
daysRemaining = null;
|
daysRemaining = null;
|
||||||
subject = "Your trial has ended";
|
subject = "Your trial has ended";
|
||||||
|
resetLimits = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let emailsSent = 0;
|
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, {
|
return response<SendTrialNotificationResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -221,4 +241,4 @@ export async function sendTrialNotification(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1003,7 +1003,11 @@ async function checkRules(
|
|||||||
isIpInCidr(clientIp, rule.value)
|
isIpInCidr(clientIp, rule.value)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
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;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (
|
||||||
path &&
|
path &&
|
||||||
@@ -1013,16 +1017,35 @@ async function checkRules(
|
|||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "COUNTRY" &&
|
rule.match == "COUNTRY"
|
||||||
(await isIpInGeoIP(ipCC, rule.value))
|
|
||||||
) {
|
) {
|
||||||
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 (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "ASN" &&
|
rule.match == "ASN"
|
||||||
(await isIpInAsn(ipAsn, rule.value))
|
|
||||||
) {
|
) {
|
||||||
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 (
|
} else if (
|
||||||
clientIp &&
|
clientIp &&
|
||||||
rule.match == "REGION" &&
|
rule.match == "REGION" &&
|
||||||
@@ -1184,6 +1207,26 @@ async function isIpInGeoIP(
|
|||||||
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
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(
|
async function isIpInAsn(
|
||||||
ipAsn: number | undefined,
|
ipAsn: number | undefined,
|
||||||
checkAsn: string
|
checkAsn: string
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
|||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import {
|
import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
|
||||||
assignUserToOrg,
|
|
||||||
removeUserFromOrg
|
|
||||||
} from "@server/lib/userOrg";
|
|
||||||
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
@@ -336,23 +333,23 @@ export async function validateOidcCallback(
|
|||||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||||
|
|
||||||
for (const org of allOrgs) {
|
// for (const org of allOrgs) {
|
||||||
const subscribed = await isSubscribed(
|
// const subscribed = await isSubscribed(
|
||||||
org.orgId,
|
// org.orgId,
|
||||||
tierMatrix.autoProvisioning
|
// tierMatrix.autoProvisioning
|
||||||
);
|
// );
|
||||||
if (!subscribed) {
|
// if (!subscribed) {
|
||||||
// filter out the org
|
// // filter out the org
|
||||||
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
// allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
||||||
|
|
||||||
// return next(
|
// // return next(
|
||||||
// createHttpError(
|
// // createHttpError(
|
||||||
// HttpCode.FORBIDDEN,
|
// // HttpCode.FORBIDDEN,
|
||||||
// "This organization's current plan does not support this feature."
|
// // "This organization's current plan does not support this feature."
|
||||||
// )
|
// // )
|
||||||
// );
|
// // );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
} else {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
}
|
}
|
||||||
@@ -396,16 +393,14 @@ export async function validateOidcCallback(
|
|||||||
idpOrgRes?.roleMapping || defaultRoleMapping;
|
idpOrgRes?.roleMapping || defaultRoleMapping;
|
||||||
if (roleMapping) {
|
if (roleMapping) {
|
||||||
logger.debug("Role Mapping", { roleMapping });
|
logger.debug("Role Mapping", { roleMapping });
|
||||||
const roleMappingJmes = unwrapRoleMapping(
|
const roleMappingJmes =
|
||||||
roleMapping
|
unwrapRoleMapping(roleMapping).evaluationExpression;
|
||||||
).evaluationExpression;
|
|
||||||
const roleMappingResult = jmespath.search(
|
const roleMappingResult = jmespath.search(
|
||||||
claims,
|
claims,
|
||||||
roleMappingJmes
|
roleMappingJmes
|
||||||
);
|
);
|
||||||
const roleNames = normalizeRoleMappingResult(
|
const roleNames =
|
||||||
roleMappingResult
|
normalizeRoleMappingResult(roleMappingResult);
|
||||||
);
|
|
||||||
|
|
||||||
const supportsMultiRole = await isLicensedOrSubscribed(
|
const supportsMultiRole = await isLicensedOrSubscribed(
|
||||||
org.orgId,
|
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
|
// sync the user with the orgs and roles
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -628,7 +623,7 @@ export async function validateOidcCallback(
|
|||||||
{
|
{
|
||||||
orgId: org.orgId,
|
orgId: org.orgId,
|
||||||
userId: userId!,
|
userId: userId!,
|
||||||
autoProvisioned: true,
|
autoProvisioned: true
|
||||||
},
|
},
|
||||||
org.roleIds,
|
org.roleIds,
|
||||||
trx
|
trx
|
||||||
@@ -758,9 +753,7 @@ function hydrateOrgMapping(
|
|||||||
return orgMapping.split("{{orgId}}").join(orgId);
|
return orgMapping.split("{{orgId}}").join(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRoleMappingResult(
|
function normalizeRoleMappingResult(result: unknown): string[] {
|
||||||
result: unknown
|
|
||||||
): string[] {
|
|
||||||
if (typeof result === "string") {
|
if (typeof result === "string") {
|
||||||
const role = result.trim();
|
const role = result.trim();
|
||||||
return role ? [role] : [];
|
return role ? [role] : [];
|
||||||
@@ -770,7 +763,9 @@ function normalizeRoleMappingResult(
|
|||||||
return [
|
return [
|
||||||
...new Set(
|
...new Set(
|
||||||
result
|
result
|
||||||
.filter((value): value is string => typeof value === "string")
|
.filter(
|
||||||
|
(value): value is string => typeof value === "string"
|
||||||
|
)
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
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 { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
tier3LimitSet
|
tier3LimitSet
|
||||||
} from "@server/lib/billing/limitSet";
|
} from "@server/lib/billing/limitSet";
|
||||||
import { FeatureId } from "@server/lib/billing/features";
|
import { FeatureId } from "@server/lib/billing/features";
|
||||||
|
import TrialBillingBanner from "@app/components/TrialBillingBanner";
|
||||||
|
|
||||||
// Plan tier definitions matching the mockup
|
// Plan tier definitions matching the mockup
|
||||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||||
@@ -805,6 +807,20 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
|
{/* Trial Banner */}
|
||||||
|
{isTrial && (
|
||||||
|
<TrialBillingBanner
|
||||||
|
onUpgrade={() => {
|
||||||
|
const currentPlan = planOptions.find(
|
||||||
|
(p) => p.id === currentPlanId
|
||||||
|
);
|
||||||
|
if (currentPlan?.tierType) {
|
||||||
|
handleStartSubscription(currentPlan.tierType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subscription Status Alert */}
|
{/* Subscription Status Alert */}
|
||||||
{isProblematicState && statusMessage && (
|
{isProblematicState && statusMessage && (
|
||||||
<Alert variant="destructive" className="mb-6">
|
<Alert variant="destructive" className="mb-6">
|
||||||
@@ -859,8 +875,19 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-2xl">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{plan.name}
|
<span className="text-2xl">
|
||||||
|
{plan.name}
|
||||||
|
</span>
|
||||||
|
{isCurrentPlan && isTrial && (
|
||||||
|
<Badge
|
||||||
|
variant="outlinePrimary"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("billingTrialBadge") ||
|
||||||
|
"Free Trial"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="text-xl">
|
<span className="text-xl">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type DismissableBannerProps = {
|
|||||||
titleIcon: ReactNode;
|
titleIcon: ReactNode;
|
||||||
description: string;
|
description: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
dismissable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DismissableBanner = ({
|
export const DismissableBanner = ({
|
||||||
@@ -21,7 +22,8 @@ export const DismissableBanner = ({
|
|||||||
title,
|
title,
|
||||||
titleIcon,
|
titleIcon,
|
||||||
description,
|
description,
|
||||||
children
|
children,
|
||||||
|
dismissable = true
|
||||||
}: DismissableBannerProps) => {
|
}: DismissableBannerProps) => {
|
||||||
const [isDismissed, setIsDismissed] = useState(true);
|
const [isDismissed, setIsDismissed] = useState(true);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -66,19 +68,21 @@ export const DismissableBanner = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDismissed) {
|
if (dismissable && isDismissed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
||||||
<button
|
{dismissable && (
|
||||||
onClick={handleDismiss}
|
<button
|
||||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
onClick={handleDismiss}
|
||||||
aria-label={t("dismiss")}
|
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>
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||||
<div className="flex-1 space-y-2 min-w-0">
|
<div className="flex-1 space-y-2 min-w-0">
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
KeyRound,
|
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -50,6 +49,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||||
@@ -63,6 +63,61 @@ export type IdpRow = {
|
|||||||
|
|
||||||
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
|
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({
|
function IdpImportRowIcon({
|
||||||
type,
|
type,
|
||||||
variant
|
variant
|
||||||
@@ -114,16 +169,22 @@ export default function IdpTable({ idps, orgId }: Props) {
|
|||||||
);
|
);
|
||||||
}, [adminIdpsRaw, orgId, idps]);
|
}, [adminIdpsRaw, orgId, idps]);
|
||||||
|
|
||||||
const shownImportIdps = useMemo(() => {
|
const importableGrouped = useMemo(
|
||||||
|
() => groupImportableIdps(importableIdps),
|
||||||
|
[importableIdps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shownImportGrouped = useMemo(() => {
|
||||||
const q = debouncedImportSearch.trim().toLowerCase();
|
const q = debouncedImportSearch.trim().toLowerCase();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return importableIdps;
|
return importableGrouped;
|
||||||
}
|
}
|
||||||
return importableIdps.filter((row) => {
|
return importableGrouped.filter((group) => {
|
||||||
const hay = `${row.orgName} ${row.name}`.toLowerCase();
|
const hay =
|
||||||
|
`${group.name} ${group.sources.map((s) => s.orgName).join(" ")}`.toLowerCase();
|
||||||
return hay.includes(q);
|
return hay.includes(q);
|
||||||
});
|
});
|
||||||
}, [importableIdps, debouncedImportSearch]);
|
}, [importableGrouped, debouncedImportSearch]);
|
||||||
|
|
||||||
const deleteIdp = async (idpId: number) => {
|
const deleteIdp = async (idpId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -364,31 +425,44 @@ export default function IdpTable({ idps, orgId }: Props) {
|
|||||||
{t("idpImportEmpty")}
|
{t("idpImportEmpty")}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{shownImportIdps.map((row) => (
|
{shownImportGrouped.map((group) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={`${row.idpId}:${row.orgId}`}
|
key={group.idpId}
|
||||||
className="items-start gap-3 py-2.5"
|
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}
|
disabled={!canImportOrgOidcIdp}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!canImportOrgOidcIdp) {
|
if (!canImportOrgOidcIdp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void importIdp(row);
|
void importIdp(
|
||||||
|
adminRowForImport(
|
||||||
|
group,
|
||||||
|
group.sources[0]
|
||||||
|
)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0">
|
<div className="mt-0.5 shrink-0">
|
||||||
<IdpImportRowIcon
|
<IdpImportRowIcon
|
||||||
type={row.type}
|
type={group.type}
|
||||||
variant={row.variant}
|
variant={group.variant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<div className="truncate font-medium leading-tight">
|
<div className="truncate font-medium leading-tight">
|
||||||
{row.orgName}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-sm leading-tight text-muted-foreground">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{row.name}
|
{group.sources.map((src) => (
|
||||||
|
<Badge
|
||||||
|
key={src.orgId}
|
||||||
|
variant="secondary"
|
||||||
|
className="max-w-full truncate font-normal"
|
||||||
|
>
|
||||||
|
{src.orgName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import LoginPasswordForm from "@app/components/LoginPasswordForm";
|
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 UserProfileCard from "@app/components/UserProfileCard";
|
||||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
@@ -206,7 +206,7 @@ export default function SmartLoginForm({
|
|||||||
if (viewState.type === "orgSelector") {
|
if (viewState.type === "orgSelector") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<LoginOrgSelector
|
<SmartLoginOrgSelector
|
||||||
identifier={viewState.identifier}
|
identifier={viewState.identifier}
|
||||||
lookupResult={viewState.lookupResult}
|
lookupResult={viewState.lookupResult}
|
||||||
redirect={redirect}
|
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