Compare commits

..

16 Commits

Author SHA1 Message Date
Owen
f2d4c2f83c Remove duplicate target 2025-12-13 12:16:11 -05:00
Owen
25fed23758 Speed up build 2025-12-13 12:13:33 -05:00
Owen Schwartz
5cb3fa1127 Merge pull request #2066 from fosrl/dev
Dev
2025-12-13 12:09:22 -05:00
Owen
deac26bad2 Bump version 2025-12-13 12:07:35 -05:00
miloschwartz
c7747fd4b4 add license watermark 2025-12-13 11:45:15 -05:00
Owen
1aaad43871 Format 2025-12-13 11:36:53 -05:00
Owen
143175bde7 Update react-dom 2025-12-13 11:34:58 -05:00
Owen
9f55d6b20a Try to fix issue not sending newt commands 2025-12-13 11:19:42 -05:00
miloschwartz
4366ca5836 add spacing to delete modal 2025-12-13 10:57:24 -05:00
miloschwartz
d5307adef0 fix bug preventing save resource priority closes #2063 2025-12-12 22:52:00 -05:00
miloschwartz
3d857c3b52 fix client side pagination issue 2025-12-12 22:41:10 -05:00
Owen
a012369f83 Make sure to always check retention first
Fixes #2061
2025-12-12 18:39:13 -05:00
Owen
ba99614d58 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-12-12 14:54:59 -05:00
Owen
27db77bca4 Format 2025-12-12 14:53:26 -05:00
miloschwartz
29b924230f add runner restart action 2025-12-12 14:48:49 -05:00
Owen
8eb3f6aacc Bump next and react again
CVE-2025-55184 and CVE-2025-67779
2025-12-12 09:55:52 -05:00
37 changed files with 286 additions and 165 deletions

View File

@@ -107,7 +107,7 @@ jobs:
- name: Build and push Docker images (Docker Hub)
run: |
TAG=${{ env.TAG }}
make build-release tag=$TAG
make -j4 build-release tag=$TAG
echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
shell: bash

39
.github/workflows/restart-runners.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -1,8 +1,13 @@
.PHONY: build build-pg build-release build-arm build-x86 test clean
.PHONY: build dev-build-sqlite dev-build-pg build-release build-arm build-x86 test clean
major_tag := $(shell echo $(tag) | cut -d. -f1)
minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
build-release:
.PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
build-sqlite:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
@@ -16,6 +21,12 @@ build-release:
--tag fosrl/pangolin:$(minor_tag) \
--tag fosrl/pangolin:$(tag) \
--push .
build-postgresql:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \
--build-arg BUILD=oss \
--build-arg DATABASE=pg \
@@ -25,6 +36,12 @@ build-release:
--tag fosrl/pangolin:postgresql-$(minor_tag) \
--tag fosrl/pangolin:postgresql-$(tag) \
--push .
build-ee-sqlite:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \
@@ -34,6 +51,12 @@ build-release:
--tag fosrl/pangolin:ee-$(minor_tag) \
--tag fosrl/pangolin:ee-$(tag) \
--push .
build-ee-postgresql:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=pg \
@@ -80,10 +103,10 @@ build-arm:
build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-sqlite:
dev-build-sqlite:
docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
build-pg:
dev-build-pg:
docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
test:

View File

@@ -2272,5 +2272,8 @@
"remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.",
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
"agent": "Agent"
"agent": "Agent",
"personalUseOnly": "Personal Use Only",
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
"instanceIsUnlicensed": "This instance is unlicensed."
}

34
package-lock.json generated
View File

@@ -75,7 +75,7 @@
"lucide-react": "0.559.0",
"maxmind": "5.0.1",
"moment": "2.30.1",
"next": "15.5.7",
"next": "15.5.9",
"next-intl": "4.5.8",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
@@ -88,9 +88,9 @@
"pg": "8.16.3",
"posthog-node": "5.17.2",
"qrcode.react": "4.2.0",
"react": "19.2.1",
"react": "19.2.3",
"react-day-picker": "9.12.0",
"react-dom": "19.2.1",
"react-dom": "19.2.3",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.68.0",
"react-icons": "5.5.0",
@@ -3835,9 +3835,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -16291,13 +16291,13 @@
}
},
"node_modules/next": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.7",
"@next/env": "15.5.9",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -19737,9 +19737,9 @@
}
},
"node_modules/react": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
@@ -19768,16 +19768,16 @@
}
},
"node_modules/react-dom": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.1"
"react": "^19.2.3"
}
},
"node_modules/react-easy-sort": {

View File

@@ -99,7 +99,7 @@
"lucide-react": "0.559.0",
"maxmind": "5.0.1",
"moment": "2.30.1",
"next": "15.5.7",
"next": "15.5.9",
"next-intl": "4.5.8",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
@@ -112,9 +112,9 @@
"pg": "8.16.3",
"posthog-node": "5.17.2",
"qrcode.react": "4.2.0",
"react": "19.2.1",
"react": "19.2.3",
"react-day-picker": "9.12.0",
"react-dom": "19.2.1",
"react-dom": "19.2.3",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.68.0",
"react-icons": "5.5.0",
@@ -178,4 +178,4 @@
"typescript": "5.9.3",
"typescript-eslint": "8.49.0"
}
}
}

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.13.0";
export const APP_VERSION = "1.13.1";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -120,11 +120,13 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
* Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses.
* IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080.
* For unbracketed IPv6, the last colon-separated segment is treated as the port.
*
*
* @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080")
* @returns An object with ip and port, or null if parsing fails
*/
export function parseEndpoint(endpoint: string): { ip: string; port: number } | null {
export function parseEndpoint(
endpoint: string
): { ip: string; port: number } | null {
if (!endpoint) return null;
// Check for bracketed IPv6 format: [ip]:port
@@ -138,7 +140,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } |
// Check if this looks like IPv6 (contains multiple colons)
const colonCount = (endpoint.match(/:/g) || []).length;
if (colonCount > 1) {
// This is IPv6 - the port is after the last colon
const lastColonIndex = endpoint.lastIndexOf(":");
@@ -163,7 +165,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } |
/**
* Formats an IP and port into a consistent endpoint string.
* IPv6 addresses are wrapped in brackets for proper parsing.
*
*
* @param ip The IP address (IPv4 or IPv6)
* @param port The port number
* @returns Formatted endpoint string
@@ -430,7 +432,12 @@ export function generateRemoteSubnets(
): string[] {
const remoteSubnets = allSiteResources
.filter((sr) => {
if (sr.mode === "cidr") return true;
if (sr.mode === "cidr") {
// check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success;
}
if (sr.mode === "host") {
// check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -454,13 +461,12 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
let aliasConfigs = allSiteResources
return allSiteResources
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
.map((sr) => ({
alias: sr.alias,
aliasAddress: sr.aliasAddress
}));
return aliasConfigs;
}
export type SubnetProxyTarget = {

View File

@@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient(
/////////// Send messages ///////////
// Get the olm for this client
const [olm] = await trx
.select({ olmId: olms.olmId })
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
logger.warn(
`Olm not found for client ${client.clientId}, skipping peer updates`
);
return;
}
// Handle messages for sites being added
await handleMessagesForClientSites(
client,
olm.olmId,
sitesToAdd,
sitesToRemove,
trx
);
await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx);
// Handle subnet proxy target updates for resources
await handleMessagesForClientResources(
@@ -996,11 +976,26 @@ async function handleMessagesForClientSites(
userId: string | null;
orgId: string;
},
olmId: string,
sitesToAdd: number[],
sitesToRemove: number[],
trx: Transaction | typeof db = db
): Promise<void> {
// Get the olm for this client
const [olm] = await trx
.select({ olmId: olms.olmId })
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
logger.warn(
`Olm not found for client ${client.clientId}, skipping peer updates`
);
return;
}
const olmId = olm.olmId;
if (!client.subnet || !client.pubKey) {
logger.warn(
`Client ${client.clientId} missing subnet or pubKey, skipping peer updates`
@@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites(
.leftJoin(newts, eq(sites.siteId, newts.siteId))
.where(inArray(sites.siteId, allSiteIds));
let newtJobs: Promise<any>[] = [];
let olmJobs: Promise<any>[] = [];
let exitNodeJobs: Promise<any>[] = [];
const newtJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = [];
for (const siteData of sitesData) {
const site = siteData.sites;
@@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources(
resourcesToRemove: number[],
trx: Transaction | typeof db = db
): Promise<void> {
// Group resources by site
const resourcesBySite = new Map<number, SiteResource[]>();
for (const resource of allNewResources) {
if (!resourcesBySite.has(resource.siteId)) {
resourcesBySite.set(resource.siteId, []);
}
resourcesBySite.get(resource.siteId)!.push(resource);
}
let proxyJobs: Promise<any>[] = [];
let olmJobs: Promise<any>[] = [];
const proxyJobs: Promise<any>[] = [];
const olmJobs: Promise<any>[] = [];
// Handle additions
if (resourcesToAdd.length > 0) {

View File

@@ -84,14 +84,11 @@ LQIDAQAB
-----END PUBLIC KEY-----`;
constructor(private hostMeta: HostMeta) {
setInterval(
async () => {
this.doRecheck = true;
await this.check();
this.doRecheck = false;
},
1000 * this.phoneHomeInterval
);
setInterval(async () => {
this.doRecheck = true;
await this.check();
this.doRecheck = false;
}, 1000 * this.phoneHomeInterval);
}
public listKeys(): LicenseKeyCache[] {
@@ -242,7 +239,9 @@ LQIDAQAB
// First failure: fail silently
logger.error("Error communicating with license server:");
logger.error(e);
logger.error(`Allowing failure. Will retry one more time at next run interval.`);
logger.error(
`Allowing failure. Will retry one more time at next run interval.`
);
// return last known good status
return this.statusCache.get(
this.statusKey

View File

@@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
}
}
export function logRequestAudit(
export async function logRequestAudit(
data: {
action: boolean;
reason: number;
@@ -174,14 +174,13 @@ export function logRequestAudit(
}
) {
try {
// Quick synchronous check - if org has 0 retention, skip immediately
// Check retention before buffering any logs
if (data.orgId) {
const cached = cache.get<number>(`org_${data.orgId}_retentionDays`);
if (cached === 0) {
const retentionDays = await getRetentionDays(data.orgId);
if (retentionDays === 0) {
// do not log
return;
}
// If not cached or > 0, we'll log it (async retention check happens in background)
}
let actorType: string | undefined;
@@ -261,16 +260,6 @@ export function logRequestAudit(
} else {
scheduleFlush();
}
// Async retention check in background (don't await)
if (
data.orgId &&
cache.get<number>(`org_${data.orgId}_retentionDays`) === undefined
) {
getRetentionDays(data.orgId).catch((err) =>
logger.error("Error checking retention days:", err)
);
}
} catch (error) {
logger.error(error);
}

View File

@@ -304,7 +304,7 @@ export default function ExitNodesTable({
setSelectedNode(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("remoteExitNodeQuestionRemove")}</p>
<p>{t("remoteExitNodeMessageRemove")}</p>

View File

@@ -289,7 +289,7 @@ export default function GeneralPage() {
setIsDeleteModalOpen(val);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
@@ -303,7 +303,7 @@ export default function GeneralPage() {
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div>
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}

View File

@@ -449,15 +449,16 @@ export default function ResourceRules(props: {
type="number"
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const parsed = z
const parsed = z.coerce
.number()
.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
if (!parsed.success) {
toast({
variant: "destructive",
title: t("rulesErrorInvalidIpAddress"), // correct priority or IP?
title: t("rulesErrorInvalidPriority"), // correct priority or IP?
description: t(
"rulesErrorInvalidPriorityDescription"
)

View File

@@ -315,7 +315,7 @@ export default function LicensePage() {
setSelectedLicenseKey(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("licenseQuestionRemove")}</p>
<p>
<b>{t("licenseMessageRemove")}</b>
@@ -360,7 +360,8 @@ export default function LicensePage() {
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed")}
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (

View File

@@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>

View File

@@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const t = await getTranslations();
let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build == "enterprise") {
const licenseStatusRes = await cache(
async () =>
@@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
"/license/status"
)
)();
licenseStatus = licenseStatusRes.data.data;
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid
licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) {
hideFooter = true;
}
@@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && (
<>
<Separator orientation="vertical" />

View File

@@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) {
setSelectedIdp(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name

View File

@@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>
{t("userQuestionRemove", {
selectedUser:

View File

@@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("apiKeysQuestionRemove")}</p>
<p>{t("apiKeysMessageRemove")}</p>

View File

@@ -284,7 +284,7 @@ export default function ClientResourcesTable({
setSelectedInternalResource(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>

View File

@@ -177,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
<CredenzaFooter className={cn("mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border", className)} {...props}>
<CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border",
className
)}
{...props}
>
{children}
</CredenzaFooter>
);

View File

@@ -24,6 +24,8 @@ interface DataTablePaginationProps<TData> {
isServerPagination?: boolean;
isLoading?: boolean;
disabled?: boolean;
pageSize?: number;
pageIndex?: number;
}
export function DataTablePagination<TData>({
@@ -33,10 +35,26 @@ export function DataTablePagination<TData>({
totalCount,
isServerPagination = false,
isLoading = false,
disabled = false
disabled = false,
pageSize: controlledPageSize,
pageIndex: controlledPageIndex
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
// Use controlled values if provided, otherwise fall back to table state
const pageSize = controlledPageSize ?? table.getState().pagination.pageSize;
const pageIndex =
controlledPageIndex ?? table.getState().pagination.pageIndex;
// Calculate page boundaries based on controlled state
// For server-side pagination, use totalCount if available for accurate page count
const pageCount =
isServerPagination && totalCount !== undefined
? Math.ceil(totalCount / pageSize)
: table.getPageCount();
const canNextPage = pageIndex < pageCount - 1;
const canPreviousPage = pageIndex > 0;
const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value);
table.setPageSize(newPageSize);
@@ -51,7 +69,7 @@ export function DataTablePagination<TData>({
action: "first" | "previous" | "next" | "last"
) => {
if (isServerPagination && onPageChange) {
const currentPage = table.getState().pagination.pageIndex;
const currentPage = pageIndex;
const pageCount = table.getPageCount();
let newPage: number;
@@ -77,18 +95,24 @@ export function DataTablePagination<TData>({
}
} else {
// Use table's built-in navigation for client-side pagination
// But add bounds checking to prevent going beyond page boundaries
const pageCount = table.getPageCount();
switch (action) {
case "first":
table.setPageIndex(0);
break;
case "previous":
table.previousPage();
if (pageIndex > 0) {
table.previousPage();
}
break;
case "next":
table.nextPage();
if (pageIndex < pageCount - 1) {
table.nextPage();
}
break;
case "last":
table.setPageIndex(table.getPageCount() - 1);
table.setPageIndex(Math.max(0, pageCount - 1));
break;
}
}
@@ -98,14 +122,12 @@ export function DataTablePagination<TData>({
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
<Select
value={`${table.getState().pagination.pageSize}`}
value={`${pageSize}`}
onValueChange={handlePageSizeChange}
disabled={disabled}
>
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="bottom">
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
@@ -121,16 +143,11 @@ export function DataTablePagination<TData>({
<div className="flex items-center justify-center text-sm font-medium">
{isServerPagination && totalCount !== undefined
? t("paginator", {
current:
table.getState().pagination.pageIndex + 1,
last: Math.ceil(
totalCount /
table.getState().pagination.pageSize
)
current: pageIndex + 1,
last: Math.ceil(totalCount / pageSize)
})
: t("paginator", {
current:
table.getState().pagination.pageIndex + 1,
current: pageIndex + 1,
last: table.getPageCount()
})}
</div>
@@ -139,9 +156,7 @@ export function DataTablePagination<TData>({
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation("first")}
disabled={
!table.getCanPreviousPage() || isLoading || disabled
}
disabled={!canPreviousPage || isLoading || disabled}
>
<span className="sr-only">{t("paginatorToFirst")}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
@@ -150,9 +165,7 @@ export function DataTablePagination<TData>({
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageNavigation("previous")}
disabled={
!table.getCanPreviousPage() || isLoading || disabled
}
disabled={!canPreviousPage || isLoading || disabled}
>
<span className="sr-only">
{t("paginatorToPrevious")}
@@ -163,9 +176,7 @@ export function DataTablePagination<TData>({
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageNavigation("next")}
disabled={
!table.getCanNextPage() || isLoading || disabled
}
disabled={!canNextPage || isLoading || disabled}
>
<span className="sr-only">{t("paginatorToNext")}</span>
<ChevronRightIcon className="h-4 w-4" />
@@ -174,9 +185,7 @@ export function DataTablePagination<TData>({
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation("last")}
disabled={
!table.getCanNextPage() || isLoading || disabled
}
disabled={!canNextPage || isLoading || disabled}
>
<span className="sr-only">{t("paginatorToLast")}</span>
<DoubleArrowRightIcon className="h-4 w-4" />

View File

@@ -304,7 +304,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
setSelectedDomain(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("domainQuestionRemove")}</p>
<p>{t("domainMessageRemove")}</p>
</div>

View File

@@ -182,7 +182,7 @@ export default function InvitationsTable({
setSelectedInvitation(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("inviteQuestionRemove")}</p>
<p>{t("inviteMessageRemove")}</p>
</div>

View File

@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import SidebarLicenseButton from "./SidebarLicenseButton";
import { SidebarSupportButton } from "./SidebarSupportButton";
import { is } from "drizzle-orm";
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
ssr: false
@@ -52,7 +53,7 @@ export function LayoutSidebar({
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
const { env } = useEnvContext();
const t = useTranslations();
@@ -226,6 +227,18 @@ export function LayoutSidebar({
<FaGithub size={12} />
</Link>
</div>
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-center">
{t("personalUseOnly")}
</div>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-center">
{t("unlicensed")}
</div>
) : null}
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link

View File

@@ -542,6 +542,8 @@ export function LogDataTable<TData, TValue>({
isServerPagination={isServerPagination}
isLoading={isLoading}
disabled={disabled}
pageSize={pageSize}
pageIndex={currentPage}
/>
</div>
</CardContent>

View File

@@ -354,7 +354,7 @@ export default function MachineClientsTable({
setSelectedClient(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>

View File

@@ -189,7 +189,7 @@ export default function OrgApiKeysTable({
setSelected(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("apiKeysQuestionRemove")}</p>
<p>{t("apiKeysMessageRemove")}</p>

View File

@@ -535,7 +535,7 @@ export default function ProxyResourcesTable({
setSelectedResource(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>

View File

@@ -93,7 +93,7 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
const getNumMethods = () => {
let colLength = 0;
@@ -737,6 +737,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</span>
</div>
)}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("instanceIsUnlicensed")}
</span>
</div>
) : null}
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("loginPageLicenseWatermark")}
</span>
</div>
) : null}
</div>
) : (
<ResourceAccessDenied />

View File

@@ -412,7 +412,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setSelectedSite(null);
}}
dialog={
<div className="">
<div className="space-y-2">
<p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p>
</div>

View File

@@ -401,7 +401,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
setSelectedClient(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>

View File

@@ -258,7 +258,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
setSelectedUser(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("userQuestionOrgRemove")}</p>
<p>{t("userMessageOrgRemove")}</p>
</div>

View File

@@ -224,7 +224,7 @@ export default function ViewDevicesDialog({
}
}}
dialog={
<div>
<div className="space-y-2">
<p>
{t("deviceQuestionRemove") ||
"Are you sure you want to delete this device?"}

View File

@@ -177,7 +177,7 @@ export default function IdpTable({ idps, orgId }: Props) {
setSelectedIdp(null);
}}
dialog={
<div>
<div className="space-y-2">
<p>{t("idpQuestionRemove")}</p>
<p>{t("idpMessageRemove")}</p>
</div>

View File

@@ -10,7 +10,8 @@ import {
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
VisibilityState
VisibilityState,
PaginationState
} from "@tanstack/react-table";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
@@ -227,6 +228,10 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility
);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: pageSize
});
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
@@ -256,6 +261,7 @@ export function DataTable<TData, TValue>({
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
initialState: {
pagination: {
pageSize: pageSize,
@@ -267,21 +273,18 @@ export function DataTable<TData, TValue>({
sorting,
columnFilters,
globalFilter,
columnVisibility
columnVisibility,
pagination
}
});
// Persist pageSize to localStorage when it changes
useEffect(() => {
const currentPageSize = table.getState().pagination.pageSize;
if (currentPageSize !== pageSize) {
table.setPageSize(pageSize);
// Persist to localStorage if enabled
if (persistPageSize) {
setStoredPageSize(pageSize, tableId);
}
if (persistPageSize && pagination.pageSize !== pageSize) {
setStoredPageSize(pagination.pageSize, tableId);
setPageSize(pagination.pageSize);
}
}, [pageSize, table, persistPageSize, tableId]);
}, [pagination.pageSize, persistPageSize, tableId, pageSize]);
useEffect(() => {
// Persist column visibility to localStorage when it changes
@@ -293,13 +296,17 @@ export function DataTable<TData, TValue>({
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
table.setPageIndex(0);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
};
// Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => {
setPagination((prev) => ({
...prev,
pageSize: newPageSize,
pageIndex: 0
}));
setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed
if (persistPageSize) {
@@ -614,6 +621,8 @@ export function DataTable<TData, TValue>({
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
</div>
</CardContent>