mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 20:29:50 +00:00
Compare commits
9 Commits
crowdin_de
...
feat/remem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d13e9105c | ||
|
|
289be30e6b | ||
|
|
a74c0c227c | ||
|
|
05bf77da29 | ||
|
|
bb4deb1ae9 | ||
|
|
1bf3d2cdd6 | ||
|
|
5fc5a3ebca | ||
|
|
e40f325703 | ||
|
|
8f377a4fb2 |
@@ -1503,6 +1503,7 @@
|
|||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
|
"idpLastUsed": "Last used",
|
||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
@@ -1915,6 +1916,9 @@
|
|||||||
"billingDomains": "Domains",
|
"billingDomains": "Domains",
|
||||||
"billingOrganizations": "Orgs",
|
"billingOrganizations": "Orgs",
|
||||||
"billingRemoteExitNodes": "Remote Nodes",
|
"billingRemoteExitNodes": "Remote Nodes",
|
||||||
|
"billingPublicResources": "Public Resources",
|
||||||
|
"billingPrivateResources": "Private Resources",
|
||||||
|
"billingMachineClients": "Machine Clients",
|
||||||
"billingNoLimitConfigured": "No limit configured",
|
"billingNoLimitConfigured": "No limit configured",
|
||||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||||
"billingIncludedUsage": "Included Usage",
|
"billingIncludedUsage": "Included Usage",
|
||||||
@@ -1943,6 +1947,9 @@
|
|||||||
"billingUsersInfo": "How many users you can use",
|
"billingUsersInfo": "How many users you can use",
|
||||||
"billingDomainInfo": "How many domains you can use",
|
"billingDomainInfo": "How many domains you can use",
|
||||||
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
||||||
|
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||||
|
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||||
|
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||||
"billingLicenseKeys": "License Keys",
|
"billingLicenseKeys": "License Keys",
|
||||||
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||||
"billingLicenseSubscription": "License Subscription",
|
"billingLicenseSubscription": "License Subscription",
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export async function getFeatureDisplayName(
|
|||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
case LimitId.ORGANIZATIONS:
|
case LimitId.ORGANIZATIONS:
|
||||||
return "Organizations";
|
return "Organizations";
|
||||||
|
case LimitId.PUBLIC_RESOURCES:
|
||||||
|
return "Public Resources";
|
||||||
|
case LimitId.PRIVATE_RESOURCES:
|
||||||
|
return "Private Resources";
|
||||||
|
case LimitId.MACHINE_CLIENTS:
|
||||||
|
return "Machine Clients";
|
||||||
case LimitId.TIER1:
|
case LimitId.TIER1:
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ export async function getOrgUsage(
|
|||||||
orgId,
|
orgId,
|
||||||
LimitId.ORGANIZATIONS
|
LimitId.ORGANIZATIONS
|
||||||
);
|
);
|
||||||
|
const publicResources = await usageService.getUsage(
|
||||||
|
orgId,
|
||||||
|
LimitId.PUBLIC_RESOURCES
|
||||||
|
);
|
||||||
|
const privateResources = await usageService.getUsage(
|
||||||
|
orgId,
|
||||||
|
LimitId.PRIVATE_RESOURCES
|
||||||
|
);
|
||||||
|
const machineClients = await usageService.getUsage(
|
||||||
|
orgId,
|
||||||
|
LimitId.MACHINE_CLIENTS
|
||||||
|
);
|
||||||
// const egressData = await usageService.getUsage(
|
// const egressData = await usageService.getUsage(
|
||||||
// orgId,
|
// orgId,
|
||||||
// FeatureId.EGRESS_DATA_MB
|
// FeatureId.EGRESS_DATA_MB
|
||||||
@@ -127,6 +139,15 @@ export async function getOrgUsage(
|
|||||||
if (organizations) {
|
if (organizations) {
|
||||||
usageData.push(organizations);
|
usageData.push(organizations);
|
||||||
}
|
}
|
||||||
|
if (publicResources) {
|
||||||
|
usageData.push(publicResources);
|
||||||
|
}
|
||||||
|
if (privateResources) {
|
||||||
|
usageData.push(privateResources);
|
||||||
|
}
|
||||||
|
if (machineClients) {
|
||||||
|
usageData.push(machineClients);
|
||||||
|
}
|
||||||
|
|
||||||
const orgLimits = await db
|
const orgLimits = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ import { Newt } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sendNewtSyncMessage } from "./sync";
|
import { sendNewtSyncMessage } from "./sync";
|
||||||
|
import semver from "semver";
|
||||||
import { recordSitePing } from "./pingAccumulator";
|
import { recordSitePing } from "./pingAccumulator";
|
||||||
|
|
||||||
|
const NEWT_SUPPORTS_SYNC_VERSION = ">=1.14.0";
|
||||||
|
const PONG = {
|
||||||
|
message: {
|
||||||
|
type: "pong",
|
||||||
|
data: {
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false,
|
||||||
|
excludeSender: false
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from newt clients.
|
* Handles ping messages from newt clients.
|
||||||
*
|
*
|
||||||
@@ -37,6 +50,14 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
// cross-region latency to the database.
|
// cross-region latency to the database.
|
||||||
recordSitePing(newt.siteId);
|
recordSitePing(newt.siteId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
newt.version &&
|
||||||
|
!semver.satisfies(newt.version, NEWT_SUPPORTS_SYNC_VERSION)
|
||||||
|
) {
|
||||||
|
// Newt does not support the sync message so not checking - stop here -
|
||||||
|
return PONG;
|
||||||
|
}
|
||||||
|
|
||||||
// Check config version and sync if stale.
|
// Check config version and sync if stale.
|
||||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||||
|
|
||||||
@@ -65,14 +86,5 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
await sendNewtSyncMessage(newt, site);
|
await sendNewtSyncMessage(newt, site);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return PONG;
|
||||||
message: {
|
|
||||||
type: "pong",
|
|
||||||
data: {
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
broadcast: false,
|
|
||||||
excludeSender: false
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,45 +9,45 @@ import {
|
|||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
// const {
|
const {
|
||||||
// tcpTargets,
|
tcpTargets,
|
||||||
// udpTargets,
|
udpTargets,
|
||||||
// validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
// browserGatewayTargets,
|
browserGatewayTargets,
|
||||||
// remoteExitNodeSubnets
|
remoteExitNodeSubnets
|
||||||
// } = await buildTargetConfigurationForNewtClient(site.siteId);
|
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||||
// let exitNode: ExitNode | undefined;
|
let exitNode: ExitNode | undefined;
|
||||||
// if (site.exitNodeId) {
|
if (site.exitNodeId) {
|
||||||
// [exitNode] = await db
|
[exitNode] = await db
|
||||||
// .select()
|
.select()
|
||||||
// .from(exitNodes)
|
.from(exitNodes)
|
||||||
// .where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||||
// .limit(1);
|
.limit(1);
|
||||||
// }
|
}
|
||||||
// const { peers, targets } = await buildClientConfigurationForNewtClient(
|
const { peers, targets } = await buildClientConfigurationForNewtClient(
|
||||||
// site,
|
site,
|
||||||
// exitNode
|
exitNode
|
||||||
// );
|
);
|
||||||
// await sendToClient(
|
await sendToClient(
|
||||||
// newt.newtId,
|
newt.newtId,
|
||||||
// {
|
{
|
||||||
// type: "newt/sync",
|
type: "newt/sync",
|
||||||
// data: {
|
data: {
|
||||||
// proxyTargets: {
|
proxyTargets: {
|
||||||
// udp: udpTargets,
|
udp: udpTargets,
|
||||||
// tcp: tcpTargets
|
tcp: tcpTargets
|
||||||
// },
|
},
|
||||||
// healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
// peers: peers,
|
peers: peers,
|
||||||
// clientTargets: targets,
|
clientTargets: targets,
|
||||||
// browserGatewayTargets: browserGatewayTargets,
|
browserGatewayTargets: browserGatewayTargets,
|
||||||
// remoteExitNodeSubnets: remoteExitNodeSubnets
|
remoteExitNodeSubnets: remoteExitNodeSubnets
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// compress: canCompress(newt.version, "newt")
|
compress: canCompress(newt.version, "newt")
|
||||||
// }
|
}
|
||||||
// ).catch((error) => {
|
).catch((error) => {
|
||||||
// logger.warn(`Error sending newt sync message:`, error);
|
logger.warn(`Error sending newt sync message:`, error);
|
||||||
// });
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { db, orgs } from "@server/db";
|
|||||||
import { actions, roles, roleActions } from "@server/db";
|
import { actions, roles, roleActions } from "@server/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export async function ensureActions() {
|
export async function ensureActions() {
|
||||||
|
if (build == "saas") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const actionIds = Object.values(ActionsEnum);
|
const actionIds = Object.values(ActionsEnum);
|
||||||
const existingActions = await trx.select().from(actions).execute();
|
const existingActions = await trx.select().from(actions).execute();
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ const tierLimits: Record<
|
|||||||
domains: number;
|
domains: number;
|
||||||
remoteNodes: number;
|
remoteNodes: number;
|
||||||
organizations: number;
|
organizations: number;
|
||||||
|
publicResources: number;
|
||||||
|
privateResources: number;
|
||||||
|
machineClients: number;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
basic: {
|
basic: {
|
||||||
@@ -165,35 +168,50 @@ const tierLimits: Record<
|
|||||||
sites: freeLimitSet[LimitId.SITES]?.value ?? 0,
|
sites: freeLimitSet[LimitId.SITES]?.value ?? 0,
|
||||||
domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0,
|
domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||||
remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||||
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||||
|
publicResources: freeLimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||||
|
privateResources: freeLimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||||
|
machineClients: freeLimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||||
},
|
},
|
||||||
tier1: {
|
tier1: {
|
||||||
users: tier1LimitSet[LimitId.USERS]?.value ?? 0,
|
users: tier1LimitSet[LimitId.USERS]?.value ?? 0,
|
||||||
sites: tier1LimitSet[LimitId.SITES]?.value ?? 0,
|
sites: tier1LimitSet[LimitId.SITES]?.value ?? 0,
|
||||||
domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||||
remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||||
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||||
|
publicResources: tier1LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||||
|
privateResources: tier1LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||||
|
machineClients: tier1LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||||
},
|
},
|
||||||
tier2: {
|
tier2: {
|
||||||
users: tier2LimitSet[LimitId.USERS]?.value ?? 0,
|
users: tier2LimitSet[LimitId.USERS]?.value ?? 0,
|
||||||
sites: tier2LimitSet[LimitId.SITES]?.value ?? 0,
|
sites: tier2LimitSet[LimitId.SITES]?.value ?? 0,
|
||||||
domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||||
remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||||
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||||
|
publicResources: tier2LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||||
|
privateResources: tier2LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||||
|
machineClients: tier2LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||||
},
|
},
|
||||||
tier3: {
|
tier3: {
|
||||||
users: tier3LimitSet[LimitId.USERS]?.value ?? 0,
|
users: tier3LimitSet[LimitId.USERS]?.value ?? 0,
|
||||||
sites: tier3LimitSet[LimitId.SITES]?.value ?? 0,
|
sites: tier3LimitSet[LimitId.SITES]?.value ?? 0,
|
||||||
domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||||
remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||||
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||||
|
publicResources: tier3LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||||
|
privateResources: tier3LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||||
|
machineClients: tier3LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||||
},
|
},
|
||||||
enterprise: {
|
enterprise: {
|
||||||
users: 0, // Custom for enterprise
|
users: 0, // Custom for enterprise
|
||||||
sites: 0, // Custom for enterprise
|
sites: 0, // Custom for enterprise
|
||||||
domains: 0, // Custom for enterprise
|
domains: 0, // Custom for enterprise
|
||||||
remoteNodes: 0, // Custom for enterprise
|
remoteNodes: 0, // Custom for enterprise
|
||||||
organizations: 0 // Custom for enterprise
|
organizations: 0, // Custom for enterprise
|
||||||
|
publicResources: 0, // Custom for enterprise
|
||||||
|
privateResources: 0, // Custom for enterprise
|
||||||
|
machineClients: 0 // Custom for enterprise
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,6 +252,9 @@ export default function BillingPage() {
|
|||||||
const DOMAINS = "domains";
|
const DOMAINS = "domains";
|
||||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||||
const ORGINIZATIONS = "organizations";
|
const ORGINIZATIONS = "organizations";
|
||||||
|
const PUBLIC_RESOURCES = "publicResources";
|
||||||
|
const PRIVATE_RESOURCES = "privateResources";
|
||||||
|
const MACHINE_CLIENTS = "machineClients";
|
||||||
|
|
||||||
// Confirmation dialog state
|
// Confirmation dialog state
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
@@ -797,6 +818,45 @@ export default function BillingPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check public resources
|
||||||
|
const publicResourcesUsage = getUsageValue(PUBLIC_RESOURCES);
|
||||||
|
if (
|
||||||
|
limits.publicResources > 0 &&
|
||||||
|
publicResourcesUsage > limits.publicResources
|
||||||
|
) {
|
||||||
|
violations.push({
|
||||||
|
feature: "Public Resources",
|
||||||
|
currentUsage: publicResourcesUsage,
|
||||||
|
newLimit: limits.publicResources
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check private resources
|
||||||
|
const privateResourcesUsage = getUsageValue(PRIVATE_RESOURCES);
|
||||||
|
if (
|
||||||
|
limits.privateResources > 0 &&
|
||||||
|
privateResourcesUsage > limits.privateResources
|
||||||
|
) {
|
||||||
|
violations.push({
|
||||||
|
feature: "Private Resources",
|
||||||
|
currentUsage: privateResourcesUsage,
|
||||||
|
newLimit: limits.privateResources
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check machine clients
|
||||||
|
const machineClientsUsage = getUsageValue(MACHINE_CLIENTS);
|
||||||
|
if (
|
||||||
|
limits.machineClients > 0 &&
|
||||||
|
machineClientsUsage > limits.machineClients
|
||||||
|
) {
|
||||||
|
violations.push({
|
||||||
|
feature: "Machine Clients",
|
||||||
|
currentUsage: machineClientsUsage,
|
||||||
|
newLimit: limits.machineClients
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return violations;
|
return violations;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -997,21 +1057,23 @@ export default function BillingPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
{/* Current Usage */}
|
{/* Current Usage */}
|
||||||
<div className="border rounded-lg p-4">
|
<div className="border rounded-lg p-4 md:col-span-1">
|
||||||
<div className="text-sm text-muted-foreground mb-2">
|
<div className="text-sm text-muted-foreground mb-2">
|
||||||
{t("billingCurrentUsage") || "Current Usage"}
|
{t("billingCurrentUsage") || "Current Usage"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span className="text-3xl font-semibold">
|
<div className="flex items-baseline gap-2">
|
||||||
{getUserCount()}
|
<span className="text-3xl font-semibold">
|
||||||
</span>
|
{getUserCount()}
|
||||||
<span className="text-lg">
|
</span>
|
||||||
{t("billingUsers") || "Users"}
|
<span className="text-lg">
|
||||||
</span>
|
{t("billingUsers") || "Users"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{hasSubscription && getPricePerUser() > 0 && (
|
{hasSubscription && getPricePerUser() > 0 && (
|
||||||
<div className="text-sm text-muted-foreground mt-1">
|
<div className="text-sm text-muted-foreground">
|
||||||
x ${getPricePerUser()} / month = $
|
x ${getPricePerUser()} / month = $
|
||||||
{getUserCount() * getPricePerUser()} /
|
{getUserCount() * getPricePerUser()} /
|
||||||
month
|
month
|
||||||
@@ -1021,11 +1083,11 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Maximum Limits */}
|
{/* Maximum Limits */}
|
||||||
<div className="border rounded-lg p-4">
|
<div className="border rounded-lg p-4 md:col-span-3">
|
||||||
<div className="text-sm text-muted-foreground mb-3">
|
<div className="text-sm text-muted-foreground mb-3">
|
||||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||||
</div>
|
</div>
|
||||||
<InfoSections cols={5}>
|
<InfoSections cols={8}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||||
{t("billingUsers") || "Users"}
|
{t("billingUsers") || "Users"}
|
||||||
@@ -1308,6 +1370,168 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||||
|
{t("billingPublicResources") ||
|
||||||
|
"Public Resources"}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent className="text-sm">
|
||||||
|
{isOverLimit(PUBLIC_RESOURCES) ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
PUBLIC_RESOURCES
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
|
"∞"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
PUBLIC_RESOURCES
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
PUBLIC_RESOURCES
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(PUBLIC_RESOURCES)}) exceeds limit (${getLimitValue(PUBLIC_RESOURCES)})`}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{getLimitValue(
|
||||||
|
PUBLIC_RESOURCES
|
||||||
|
) ??
|
||||||
|
t("billingUnlimited") ??
|
||||||
|
"∞"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||||
|
{t("billingPrivateResources") ||
|
||||||
|
"Private Resources"}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent className="text-sm">
|
||||||
|
{isOverLimit(PRIVATE_RESOURCES) ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
PRIVATE_RESOURCES
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
|
"∞"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
PRIVATE_RESOURCES
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
PRIVATE_RESOURCES
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(PRIVATE_RESOURCES)}) exceeds limit (${getLimitValue(PRIVATE_RESOURCES)})`}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{getLimitValue(
|
||||||
|
PRIVATE_RESOURCES
|
||||||
|
) ??
|
||||||
|
t("billingUnlimited") ??
|
||||||
|
"∞"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||||
|
{t("billingMachineClients") ||
|
||||||
|
"Machine Clients"}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent className="text-sm">
|
||||||
|
{isOverLimit(MACHINE_CLIENTS) ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
MACHINE_CLIENTS
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
|
"∞"}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
MACHINE_CLIENTS
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
MACHINE_CLIENTS
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(MACHINE_CLIENTS)}) exceeds limit (${getLimitValue(MACHINE_CLIENTS)})`}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{getLimitValue(
|
||||||
|
MACHINE_CLIENTS
|
||||||
|
) ??
|
||||||
|
t("billingUnlimited") ??
|
||||||
|
"∞"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1507,6 +1731,45 @@ export default function BillingPage() {
|
|||||||
"Remote Nodes"}
|
"Remote Nodes"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
tierLimits[
|
||||||
|
pendingTier.tier
|
||||||
|
].publicResources
|
||||||
|
}{" "}
|
||||||
|
{t(
|
||||||
|
"billingPublicResources"
|
||||||
|
) || "Public Resources"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
tierLimits[
|
||||||
|
pendingTier.tier
|
||||||
|
].privateResources
|
||||||
|
}{" "}
|
||||||
|
{t(
|
||||||
|
"billingPrivateResources"
|
||||||
|
) || "Private Resources"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
tierLimits[
|
||||||
|
pendingTier.tier
|
||||||
|
].machineClients
|
||||||
|
}{" "}
|
||||||
|
{t(
|
||||||
|
"billingMachineClients"
|
||||||
|
) || "Machine Clients"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
|||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||||
import { ListIdpsResponse } from "@server/routers/idp";
|
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Log In"
|
title: "Log In"
|
||||||
@@ -29,8 +32,9 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
||||||
|
|
||||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||||
const forceLoginParam = searchParams?.forceLogin;
|
const forceLoginParam = searchParams?.forceLogin;
|
||||||
@@ -85,19 +89,47 @@ export default async function Page(props: {
|
|||||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
|
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||||
|
null;
|
||||||
if (!useSmartLogin) {
|
if (!useSmartLogin) {
|
||||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||||
const idpsRes = await cache(
|
const idpsRes =
|
||||||
async () =>
|
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
variant: idp.type
|
variant: idp.type
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (lastUsedIdpCookie) {
|
||||||
|
const lastUsedIdpSchema = z.object({
|
||||||
|
orgId: z.string().optional(),
|
||||||
|
idpId: z.number()
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const persistedData = lastUsedIdpSchema.parse(
|
||||||
|
JSON.parse(lastUsedIdpCookie.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
||||||
|
`/idp/${persistedData.idpId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const idp = idpRes.data.data.idp;
|
||||||
|
|
||||||
|
lastUsedIdpForSmartLogin = {
|
||||||
|
idpId: idp.idpId,
|
||||||
|
name: idp.name,
|
||||||
|
variant: idp.type,
|
||||||
|
orgId: persistedData.orgId,
|
||||||
|
lastUsed: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// the idp might not exist or the data is malformatted, skip this
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -160,6 +192,7 @@ export default async function Page(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
defaultUser={defaultUser}
|
defaultUser={defaultUser}
|
||||||
|
lastUsedIdp={lastUsedIdpForSmartLogin}
|
||||||
orgSignIn={
|
orgSignIn={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
(build === "saas" ||
|
(build === "saas" ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -27,8 +25,7 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
import {
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
generateOidcUrlProxy,
|
import { Button } from "@app/components/ui/button";
|
||||||
type GenerateOidcUrlResponse
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
} from "@app/actions/server";
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
redirect as redirectTo,
|
redirect as redirectTo,
|
||||||
useParams,
|
useRouter,
|
||||||
useSearchParams
|
useSearchParams
|
||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdpLoginButtonsProps = {
|
type IdpLoginButtonsProps = {
|
||||||
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
|
|||||||
orgId
|
orgId
|
||||||
}: IdpLoginButtonsProps) {
|
}: IdpLoginButtonsProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
setClientCookie(
|
||||||
|
LAST_USED_IDP_COOKIE_NAME,
|
||||||
|
JSON.stringify({
|
||||||
|
orgId,
|
||||||
|
idpId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
sameSite: "Lax"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log("generating", idpId, redirect || "/", orgId);
|
console.log("generating", idpId, redirect || "/", orgId);
|
||||||
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
|
|||||||
"An unexpected error occurred. Please try again."
|
"An unexpected error occurred. Please try again."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectToUrl) {
|
if (redirectToUrl) {
|
||||||
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
|
className="w-full relative"
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full inline-flex items-center space-x-2"
|
|
||||||
onClick={() => {
|
|
||||||
loginWithIdp(idp.idpId);
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
loading={loading}
|
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<Button
|
||||||
<span>{idp.name}</span>
|
key={idp.idpId}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(() =>
|
||||||
|
loginWithIdp(idp.idpId)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span>{idp.name}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{idp.lastUsed && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ import Link from "next/link";
|
|||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
||||||
generateOidcUrlProxy,
|
|
||||||
loginProxy
|
|
||||||
} from "@app/actions/server";
|
|
||||||
import { redirect as redirectTo } from "next/navigation";
|
import { redirect as redirectTo } from "next/navigation";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { loadReoScript } from "reodotdev";
|
import { loadReoScript } from "reodotdev";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
|
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
@@ -105,7 +104,6 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||||
@@ -130,6 +128,10 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
||||||
|
"login:last-used-idp",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
@@ -179,8 +181,7 @@ export default function LoginForm({
|
|||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
setError(
|
setError(
|
||||||
t("securityKeyRequired", {
|
t("securityKeyRequired", {
|
||||||
defaultValue:
|
defaultValue: "Please use your security key to sign in."
|
||||||
"Please use your security key to sign in."
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -242,6 +243,8 @@ export default function LoginForm({
|
|||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
let redirectUrl: string | undefined;
|
let redirectUrl: string | undefined;
|
||||||
|
|
||||||
|
setLastUsedIdpId(idpId.toString());
|
||||||
try {
|
try {
|
||||||
const data = await generateOidcUrlProxy(
|
const data = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
@@ -356,7 +359,6 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<SecurityKeyAuthButton
|
<SecurityKeyAuthButton
|
||||||
@@ -385,25 +387,41 @@ export default function LoginForm({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
|
className="w-full relative"
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full inline-flex items-center space-x-2"
|
|
||||||
onClick={() => {
|
|
||||||
loginWithIdp(idp.idpId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<Button
|
||||||
<span>{idp.name}</span>
|
key={idp.idpId}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
|
onClick={() => {
|
||||||
|
loginWithIdp(idp.idpId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span>{idp.name}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{lastUsedIdpId ===
|
||||||
|
idp.idpId.toString() && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ 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";
|
||||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||||
|
import type { LoginFormIDP } from "./LoginForm";
|
||||||
|
import IdpLoginButtons from "./IdpLoginButtons";
|
||||||
|
|
||||||
const identifierSchema = z.object({
|
const identifierSchema = z.object({
|
||||||
identifier: z.string().min(1, "Username or email is required")
|
identifier: z.string().min(1, "Username or email is required")
|
||||||
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
|
|||||||
forceLogin?: boolean;
|
forceLogin?: boolean;
|
||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
|
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
|
|||||||
redirect,
|
redirect,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
orgSignIn
|
orgSignIn,
|
||||||
|
lastUsedIdp
|
||||||
}: SmartLoginFormProps) {
|
}: SmartLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lastUsedIdp && (
|
||||||
|
<IdpLoginButtons
|
||||||
|
idps={[lastUsedIdp]}
|
||||||
|
orgId={lastUsedIdp.orgId}
|
||||||
|
redirect={redirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<OrgSignInLink
|
<OrgSignInLink
|
||||||
href={orgSignIn.href}
|
href={orgSignIn.href}
|
||||||
linkText={orgSignIn.linkText}
|
linkText={orgSignIn.linkText}
|
||||||
|
|||||||
1
src/lib/consts.ts
Normal file
1
src/lib/consts.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
||||||
32
src/lib/setClientCookie.ts
Normal file
32
src/lib/setClientCookie.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Set a cookie on the client side in javascript code, not on the server
|
||||||
|
* @param name
|
||||||
|
* @param value
|
||||||
|
* @param days
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function setClientCookie(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
options: {
|
||||||
|
days?: number;
|
||||||
|
path?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
||||||
|
|
||||||
|
if (options.days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + options.days * 864e5);
|
||||||
|
cookie += `; expires=${date.toUTCString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie += `; path=${options.path ?? "/"}`;
|
||||||
|
|
||||||
|
if (options.secure) cookie += "; Secure";
|
||||||
|
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
||||||
|
|
||||||
|
document.cookie = cookie;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user