mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-08 17:29:54 +00:00
Compare commits
16 Commits
1.18.2-s.2
...
redis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a90fbd2b | ||
|
|
2ecf076c0f | ||
|
|
e06dda27cb | ||
|
|
18f6e0f75d | ||
|
|
3b232bcc58 | ||
|
|
c575bb76e7 | ||
|
|
c8e7e0ee1e | ||
|
|
0e7aafd364 | ||
|
|
91f1bae3e9 | ||
|
|
53c138ce3e | ||
|
|
969db14a3c | ||
|
|
2154811ffb | ||
|
|
9bd33072f4 | ||
|
|
0655ba9423 | ||
|
|
2c85bcd06b | ||
|
|
d6abe83fdc |
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -11,8 +12,69 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
|
// retry loops that accumulate memory.
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
|
// reducing event-loop blocking.
|
||||||
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
});
|
});
|
||||||
@@ -23,7 +85,7 @@ export default db;
|
|||||||
export const primaryDb = db;
|
export const primaryDb = db;
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
|
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
|
||||||
|
|
||||||
function checkFileExists(filePath: string): boolean {
|
function checkFileExists(filePath: string): boolean {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import { regionalCache as cache } from "@server/private/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
const keys = await cache.keysWithPrefix(prefix);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,7 +500,30 @@ function findAcmeJsonFiles(dirPath: string): string[] {
|
|||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
results.push(...findAcmeJsonFiles(fullPath));
|
results.push(...findAcmeJsonFiles(fullPath));
|
||||||
} else if (entry.isFile() && entry.name === "acme.json") {
|
} else if (entry.isFile()) {
|
||||||
|
// check if it is a json file
|
||||||
|
if (entry.name.endsWith(".json")) {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(fullPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not read file "${fullPath}": ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results.push(fullPath);
|
results.push(fullPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
|
* reads are served from the same cluster the user is hitting.
|
||||||
|
*/
|
||||||
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
class RegionalAdaptiveCache {
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return (
|
||||||
|
regionalRedisManager.isRedisEnabled() &&
|
||||||
|
regionalRedisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const success = await regionalRedisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis set error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||||
|
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis get error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = regionalLocalCache.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<number> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
for (const k of keys) {
|
||||||
|
const success = await regionalRedisManager.del(k);
|
||||||
|
if (success) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
|
deletedCount = 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis del error:`, error);
|
||||||
|
deletedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const count = regionalLocalCache.del(k);
|
||||||
|
if (count > 0) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
return value !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis has error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns keys matching the given prefix from whichever backend is active.
|
||||||
|
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
return await regionalRedisManager.keys(`${prefix}*`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis keys error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBackend(): "redis" | "local" {
|
||||||
|
return this.useRedis() ? "redis" : "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalCache = new RegionalAdaptiveCache();
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
|
.optional(),
|
||||||
|
regional_redis: z
|
||||||
|
.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: portSchema,
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
||||||
|
db: z.int().nonnegative().optional().default(0),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class RedisManager {
|
|||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db
|
db: redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +135,14 @@ class RedisManager {
|
|||||||
password: replica.password,
|
password: replica.password,
|
||||||
db: replica.db || redisConfig.db
|
db: replica.db || redisConfig.db
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
if (redisConfig.tls) {
|
if (redisConfig.tls) {
|
||||||
opts.tls = {
|
opts.tls = {
|
||||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,3 +855,163 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||||
|
* Connects only when `redis.regional_redis` is present in the private config
|
||||||
|
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||||
|
* caching of regionally-scoped data.
|
||||||
|
*/
|
||||||
|
class RegionalRedisManager {
|
||||||
|
private writeClient: Redis | null = null;
|
||||||
|
private readClient: Redis | null = null;
|
||||||
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = false;
|
||||||
|
private connectionTimeout: number = 5000;
|
||||||
|
private commandTimeout: number = 5000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (build === "oss") return;
|
||||||
|
|
||||||
|
const cfg = privateConfig.getRawPrivateConfig();
|
||||||
|
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.initializeClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): RedisOptions {
|
||||||
|
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||||
|
const opts: RedisOptions = {
|
||||||
|
host: r.host,
|
||||||
|
port: r.port,
|
||||||
|
password: r.password,
|
||||||
|
db: r.db
|
||||||
|
};
|
||||||
|
if (r.tls) {
|
||||||
|
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClients(): void {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const baseOpts = {
|
||||||
|
...cfg,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 10000,
|
||||||
|
connectTimeout: this.connectionTimeout,
|
||||||
|
commandTimeout: this.commandTimeout
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
|
this.readClient = this.writeClient;
|
||||||
|
|
||||||
|
this.writeClient.on("ready", () => {
|
||||||
|
logger.info("Regional Redis client ready");
|
||||||
|
this.isHealthy = true;
|
||||||
|
});
|
||||||
|
this.writeClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis client error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
this.writeClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis client reconnecting...");
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Regional Redis client initialized");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRedisEnabled(): boolean {
|
||||||
|
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHealthStatus() {
|
||||||
|
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
if (ttl) {
|
||||||
|
await this.writeClient.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.writeClient.set(key, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis SET error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||||
|
try {
|
||||||
|
return await this.readClient.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis GET error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async del(key: string): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
await this.writeClient.del(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis DEL error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async keys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||||
|
try {
|
||||||
|
return await this.readClient.keys(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis KEYS error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.writeClient) {
|
||||||
|
await this.writeClient.quit();
|
||||||
|
this.writeClient = null;
|
||||||
|
}
|
||||||
|
this.readClient = null;
|
||||||
|
logger.info("Regional Redis client disconnected");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalRedisManager = new RegionalRedisManager();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Olm,
|
Olm,
|
||||||
olms,
|
olms,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
remoteExitNodes,
|
remoteExitNodes
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
@@ -194,8 +194,6 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
|||||||
// Config version tracking map (local to this node, resets on server restart)
|
// Config version tracking map (local to this node, resets on server restart)
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
const clientConfigVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -406,6 +404,9 @@ const removeClient = async (
|
|||||||
const updatedClients = existingClients.filter((client) => client !== ws);
|
const updatedClients = existingClients.filter((client) => client !== ws);
|
||||||
if (updatedClients.length === 0) {
|
if (updatedClients.length === 0) {
|
||||||
connectedClients.delete(mapKey);
|
connectedClients.delete(mapKey);
|
||||||
|
// Remove clientId from clientConfigVersions on disconnect — prevents
|
||||||
|
// unbounded memory growth from stale entries.
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
@@ -1097,6 +1098,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Eagerly remove client — close event may not fire if socket is already
|
||||||
|
// CLOSING, leaving zombie entries.
|
||||||
|
connectedClients.delete(mapKey);
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -333,23 +333,16 @@ 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(
|
}
|
||||||
// // createHttpError(
|
|
||||||
// // HttpCode.FORBIDDEN,
|
|
||||||
// // "This organization's current plan does not support this feature."
|
|
||||||
// // )
|
|
||||||
// // );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
}
|
}
|
||||||
@@ -490,7 +483,14 @@ export async function validateOidcCallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(existingUser.userId);
|
calculateUserClientsForOrgs(existingUser.userId).catch(
|
||||||
|
(err) => {
|
||||||
|
logger.error(
|
||||||
|
"Error calculating user clients after removing all orgs for user with no valid IdP mappings",
|
||||||
|
{ error: err }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -512,10 +512,9 @@ export async function validateOidcCallback(
|
|||||||
|
|
||||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||||
|
|
||||||
|
let userId = existingUser?.userId;
|
||||||
// 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) => {
|
||||||
let userId = existingUser?.userId;
|
|
||||||
|
|
||||||
// create user if not exists
|
// create user if not exists
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
userId = generateId(15);
|
userId = generateId(15);
|
||||||
@@ -645,8 +644,15 @@ export async function validateOidcCallback(
|
|||||||
userCount: userCount.length
|
userCount: userCount.length
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.transaction(async (trx) => {
|
||||||
await calculateUserClientsForOrgs(userId!, trx);
|
await calculateUserClientsForOrgs(userId!, trx);
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
||||||
|
{ error: err }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const orgCount of orgUserCounts) {
|
for (const orgCount of orgUserCounts) {
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import zlib from "zlib";
|
|||||||
import { Server as HttpServer } from "http";
|
import { Server as HttpServer } from "http";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
|
import {
|
||||||
|
Newt,
|
||||||
|
newts,
|
||||||
|
NewtSession,
|
||||||
|
olms,
|
||||||
|
Olm,
|
||||||
|
OlmSession,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { recordPing } from "@server/routers/newt/pingAccumulator";
|
import { recordPing } from "@server/routers/newt/pingAccumulator";
|
||||||
@@ -80,6 +88,9 @@ const removeClient = async (
|
|||||||
const updatedClients = existingClients.filter((client) => client !== ws);
|
const updatedClients = existingClients.filter((client) => client !== ws);
|
||||||
if (updatedClients.length === 0) {
|
if (updatedClients.length === 0) {
|
||||||
connectedClients.delete(mapKey);
|
connectedClients.delete(mapKey);
|
||||||
|
// Remove clientId from clientConfigVersions — prevents unbounded growth
|
||||||
|
// from stale entries.
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`
|
`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`
|
||||||
@@ -218,9 +229,13 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the current config version for a client
|
// Get the current config version for a client
|
||||||
const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => {
|
const getClientConfigVersion = async (
|
||||||
|
clientId: string
|
||||||
|
): Promise<number | undefined> => {
|
||||||
const version = clientConfigVersions.get(clientId);
|
const version = clientConfigVersions.get(clientId);
|
||||||
logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`);
|
logger.debug(
|
||||||
|
`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`
|
||||||
|
);
|
||||||
return version;
|
return version;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,6 +522,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Eagerly remove client — close event may not fire if socket already
|
||||||
|
// CLOSING, leaving zombie entries.
|
||||||
|
connectedClients.delete(mapKey);
|
||||||
|
clientConfigVersions.delete(clientId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ export function CertificateStatusContent({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const labelClass =
|
const labelClass =
|
||||||
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-none";
|
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal";
|
||||||
const valueClass = "inline-flex items-center gap-2 text-sm leading-none";
|
const valueClass =
|
||||||
|
"inline-flex items-center gap-2 text-sm leading-normal";
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
await refreshCert();
|
await refreshCert();
|
||||||
@@ -133,14 +134,14 @@ export function CertificateStatusContent({
|
|||||||
{isPending && !disableRestartButton ? (
|
{isPending && !disableRestartButton ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center"
|
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-normal inline-flex items-center self-center"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
title={t("restartCertificate", {
|
title={t("restartCertificate", {
|
||||||
defaultValue: "Restart Certificate"
|
defaultValue: "Restart Certificate"
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 leading-none">
|
<span className="inline-flex items-center gap-2 leading-normal">
|
||||||
<FileBadge
|
<FileBadge
|
||||||
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
|
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -148,7 +149,7 @@ export function CertificateStatusContent({
|
|||||||
{cert.status.charAt(0).toUpperCase() +
|
{cert.status.charAt(0).toUpperCase() +
|
||||||
cert.status.slice(1)}
|
cert.status.slice(1)}
|
||||||
<RotateCw
|
<RotateCw
|
||||||
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
className={`h-4 w-4 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -164,7 +165,7 @@ export function CertificateStatusContent({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0"
|
className="inline-flex h-4 w-4 min-h-0 shrink-0 items-center justify-center self-center p-0"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
title={t("restartCertificate", {
|
title={t("restartCertificate", {
|
||||||
@@ -172,7 +173,7 @@ export function CertificateStatusContent({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<RotateCw
|
<RotateCw
|
||||||
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
className={`h-4 w-4 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const CopyToClipboard = ({
|
|||||||
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{!copied ? (
|
{!copied ? (
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function IdpLoginButtons({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
{params.get("gotoapp") ? (
|
{params.get("gotoapp") ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function InfoSections({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||||
columnSizing === "content" &&
|
columnSizing === "content" &&
|
||||||
"md:justify-items-start md:justify-start"
|
"md:justify-items-start md:justify-start"
|
||||||
)}
|
)}
|
||||||
@@ -41,7 +41,11 @@ export function InfoSection({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return <div className={cn("space-y-1", className)}>{children}</div>;
|
return (
|
||||||
|
<div className={cn("min-w-0 w-full max-w-full space-y-1", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoSectionTitle({
|
export function InfoSectionTitle({
|
||||||
@@ -51,7 +55,11 @@ export function InfoSectionTitle({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return <div className={cn("font-semibold", className)}>{children}</div>;
|
return (
|
||||||
|
<div className={cn("min-w-0 truncate font-semibold", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoSectionContent({
|
export function InfoSectionContent({
|
||||||
@@ -62,8 +70,13 @@ export function InfoSectionContent({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-w-0 overflow-hidden", className)}>
|
<div
|
||||||
<div className="w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
|
className={cn(
|
||||||
|
"w-full min-w-0 max-w-full overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full min-w-0 max-w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export default function LoginForm({
|
|||||||
|
|
||||||
{hasIdp && (
|
{hasIdp && (
|
||||||
<>
|
<>
|
||||||
<div className="relative my-4">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function MfaInputForm({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form={formId}
|
form={formId}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ export default function ResetPasswordForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "request" && (
|
{state === "request" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4">
|
||||||
{env.email.emailEnabled && (
|
{env.email.emailEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.niceId}
|
<span className="inline-flex items-center">
|
||||||
|
{resource.niceId}
|
||||||
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
@@ -49,7 +51,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.wildcard ? (
|
{resource.wildcard ? (
|
||||||
<span>{fullUrl}</span>
|
<span className="inline-flex items-center">
|
||||||
|
{fullUrl}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={fullUrl}
|
text={fullUrl}
|
||||||
@@ -68,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
authInfo.sso ||
|
authInfo.sso ||
|
||||||
authInfo.whitelist ||
|
authInfo.whitelist ||
|
||||||
authInfo.headerAuth ? (
|
authInfo.headerAuth ? (
|
||||||
<div className="flex items-start space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||||
<span>{t("protected")}</span>
|
<span>{t("protected")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +110,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
{t("protocol")}
|
{t("protocol")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.protocol.toUpperCase()}
|
<span className="inline-flex items-center">
|
||||||
|
{resource.protocol.toUpperCase()}
|
||||||
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export default function SmartLoginForm({
|
|||||||
|
|
||||||
{orgSignIn && (
|
{orgSignIn && (
|
||||||
<>
|
<>
|
||||||
<div className="relative my-4">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function SmartLoginOrgSelector({
|
|||||||
const response = await generateOidcUrlProxy(
|
const response = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
safeRedirect,
|
safeRedirect,
|
||||||
orgId,
|
undefined,
|
||||||
forceLogin
|
forceLogin
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export default function SmartLoginOrgSelector({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{hasInternalAccount && (
|
{hasInternalAccount && (
|
||||||
<div className="mt-3">
|
<div className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -237,7 +237,7 @@ export default function SmartLoginOrgSelector({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
{params.get("gotoapp") ? (
|
{params.get("gotoapp") ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { validateOidcUrlCallbackProxy } from "@app/actions/server";
|
import { validateOidcUrlCallbackProxy } from "@app/actions/server";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
type ValidateOidcTokenParams = {
|
type ValidateOidcTokenParams = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -96,7 +97,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||||||
stateCookie: props.stateCookie
|
stateCookie: props.stateCookie
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLicenseViolation()) {
|
if (build === "enterprise" && isLicenseViolation()) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user