Compare commits

..

5 Commits

Author SHA1 Message Date
Fred KISSIE
3d13e9105c 🏷️ fix types 2026-07-03 22:33:58 +02:00
Fred KISSIE
289be30e6b show last used login idp in smart login form 2026-07-03 22:33:51 +02:00
Fred KISSIE
a74c0c227c 🌐 text 2026-07-03 21:05:53 +02:00
Fred KISSIE
05bf77da29 ♻️ remove cache on verifySession calls as it's already wrapped in cache 2026-07-03 21:05:47 +02:00
Fred KISSIE
bb4deb1ae9 remember last used idp in dashboard login form 2026-07-03 21:05:16 +02:00
28 changed files with 647 additions and 790 deletions

View File

@@ -864,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Username", "policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Password", "policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode", "policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code", "policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist", "policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -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",

View File

@@ -1,83 +0,0 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}

View File

@@ -1,31 +1,10 @@
import { db, exitNodes, Transaction } from "@server/db"; import { db, exitNodes } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
/** export async function getNextAvailableSubnet(): Promise<string> {
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* which org(s) they belong to - since HA exit nodes can end up routing for
* the same org. This acquires a lock that the caller MUST release (via the
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
// Get all existing subnets from routes table // Get all existing subnets from routes table
const existingAddresses = await trx const existingAddresses = await db
.select({ .select({
address: exitNodes.address address: exitNodes.address
}) })
@@ -47,9 +26,5 @@ export async function getNextAvailableSubnet(
".1" + ".1" +
"/" + "/" +
subnet.split("/")[1]; subnet.split("/")[1];
return { value: subnet, release }; return subnet;
} catch (e) {
await release();
throw e;
}
} }

View File

@@ -45,7 +45,6 @@ import {
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
import { withRetry, isTransientError } from "@server/lib/dbRetry";
import { import {
checkOrgRebuildRateLimit, checkOrgRebuildRateLimit,
decrementOrgRebuildCount, decrementOrgRebuildCount,
@@ -286,20 +285,10 @@ export async function rebuildClientAssociationsFromSiteResource(
) { ) {
await incrementOrgRebuildCount(siteResource.orgId); await incrementOrgRebuildCount(siteResource.orgId);
try { try {
// The whole locked rebuild is idempotent (it diffs full expected vs. return await lockManager.withLock(
// actual state each time), so on a transient DB error it's safe to
// retry the entire thing rather than just the failed query.
return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => () => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
rebuildClientAssociationsFromSiteResourceImpl(
siteResource
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -315,17 +304,6 @@ export async function rebuildClientAssociationsFromSiteResource(
}); });
return { mergedAllClients: [] }; return { mergedAllClients: [] };
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(siteResource.orgId); await decrementOrgRebuildCount(siteResource.orgId);
@@ -485,7 +463,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSiteResourcesAssociationsCache) .insert(clientSiteResourcesAssociationsCache)
.values(clientSiteResourcesToInsert) .values(clientSiteResourcesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
@@ -533,7 +510,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
for (const site of sitesToProcess) { for (const site of sitesToProcess) {
const siteId = site.siteId; const siteId = site.siteId;
try {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
); );
@@ -563,14 +539,11 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
subnet: clients.subnet subnet: clients.subnet
}) })
.from(clients) .from(clients)
.where( .where(inArray(clients.clientId, existingClientSiteIds))
inArray(clients.clientId, existingClientSiteIds)
)
: []; : [];
const otherResourceClientIds = const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ?? clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
new Set<number>();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
@@ -582,17 +555,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
? mergedAllClientIds ? mergedAllClientIds
: []; : [];
// Note: we deliberately do NOT exclude clients covered by another
// site resource here (unlike clientSitesToRemove below). Doing so
// previously caused a permanent gap: if resource A saw resource B's
// cache row and skipped adding (assuming B would maintain it), and
// B's own rebuild made the same assumption about A, the site-level
// row could end up never inserted by anyone even though both
// resources' client associations were otherwise correct.
// onConflictDoNothing makes a redundant insert harmless, so there's
// no correctness reason to skip here.
const clientSitesToAdd = expectedClientIdsForSite.filter( const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) => !existingClientSiteIds.includes(clientId) (clientId) =>
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
); );
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
@@ -611,7 +577,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSitesAssociationsCache) .insert(clientSitesAssociationsCache)
.values(clientSitesToInsert) .values(clientSitesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
@@ -660,21 +625,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSitesToRemove, clientSitesToRemove,
trx trx
); );
} catch (err) {
// Don't let a failure on one site abort processing of every
// other site queued after it in this run. Since we're not
// re-throwing, the outer wrapper's retry/requeue logic never
// sees this failure, so explicitly queue this resource for a
// follow-up pass to reconcile whatever this site didn't get to.
logger.error(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
}
} }
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
@@ -707,7 +657,7 @@ async function handleMessagesForSiteClients(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.debug( logger.warn(
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
@@ -721,14 +671,14 @@ async function handleMessagesForSiteClients(
.limit(1); .limit(1);
if (!exitNode) { if (!exitNode) {
logger.debug( logger.warn(
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
} }
if (!site.publicKey) { if (!site.publicKey) {
logger.debug( logger.warn(
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients` `Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
); );
return; return;
@@ -742,7 +692,7 @@ async function handleMessagesForSiteClients(
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
logger.debug( logger.warn(
`Newt not found for site ${siteId} so cannot add peers to clients` `Newt not found for site ${siteId} so cannot add peers to clients`
); );
return; return;
@@ -967,7 +917,7 @@ export async function updateClientSiteDestinations(
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.subnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1706,17 +1656,10 @@ export async function rebuildClientAssociationsFromClient(
await incrementOrgRebuildCount(client.orgId); await incrementOrgRebuildCount(client.orgId);
try { try {
const trx = primaryDb; const trx = primaryDb;
// The whole locked rebuild is idempotent (it diffs full expected vs. return await lockManager.withLock(
// actual state each time), so on a transient DB error it's safe to
// retry the entire thing rather than just the failed query.
return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`, `rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx), () => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromClient:${client.clientId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -1732,17 +1675,6 @@ export async function rebuildClientAssociationsFromClient(
}); });
return; return;
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(client.orgId); await decrementOrgRebuildCount(client.orgId);
@@ -1894,15 +1826,12 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new associations // Insert new associations
if (resourcesToAdd.length > 0) { if (resourcesToAdd.length > 0) {
await trx await trx.insert(clientSiteResourcesAssociationsCache).values(
.insert(clientSiteResourcesAssociationsCache)
.values(
resourcesToAdd.map((siteResourceId) => ({ resourcesToAdd.map((siteResourceId) => ({
clientId: client.clientId, clientId: client.clientId,
siteResourceId siteResourceId
})) }))
) );
.onConflictDoNothing();
} }
// Remove old associations // Remove old associations
@@ -1940,15 +1869,12 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new site associations // Insert new site associations
if (sitesToAdd.length > 0) { if (sitesToAdd.length > 0) {
await trx await trx.insert(clientSitesAssociationsCache).values(
.insert(clientSitesAssociationsCache)
.values(
sitesToAdd.map((siteId) => ({ sitesToAdd.map((siteId) => ({
clientId: client.clientId, clientId: client.clientId,
siteId siteId
})) }))
) );
.onConflictDoNothing();
} }
// Remove old site associations // Remove old site associations

View File

@@ -1,14 +1,10 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -28,10 +24,6 @@ export interface RebuildQueueManager {
// retried shortly after against fresh DB state. // retried shortly after against fresh DB state.
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
const BATCH_SIZE = 5; const BATCH_SIZE = 5;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
function dedupeKey(job: RebuildJob): string { function dedupeKey(job: RebuildJob): string {
return `${job.type}:${job.id}`; return `${job.type}:${job.id}`;
@@ -114,31 +106,12 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1;
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
const delay =
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error( logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`, `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err err
); );
} }
} }
}
} finally { } finally {
this.processing = false; this.processing = false;
} }

View File

@@ -14,7 +14,7 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import logger from "@server/logger"; import logger from "@server/logger";
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a // Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
// server process dies while holding a rebuild, this ensures the counter key // server process dies while holding a rebuild, this ensures the counter key

View File

@@ -14,16 +14,12 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import { lockManager } from "#private/lib/lock"; import { lockManager } from "#private/lib/lock";
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -47,11 +43,6 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
class RedisRebuildQueue { class RedisRebuildQueue {
private processingStarted = false; private processingStarted = false;
@@ -189,35 +180,12 @@ class RedisRebuildQueue {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1;
if (
isTransientError(err) &&
attempt <= MAX_JOB_ATTEMPTS
) {
const delay =
JOB_RETRY_BASE_DELAY_MS *
Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error( logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`, `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err err
); );
} }
} }
}
}, },
PROCESSOR_LOCK_TTL_MS PROCESSOR_LOCK_TTL_MS
); );

View File

@@ -894,19 +894,6 @@ class RegionalRedisManager {
return opts; return opts;
} }
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
// (primary). The replica (redis-1) is only reachable through the
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
// if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
if (!match) return null;
const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
}
private initializeClients(): void { private initializeClients(): void {
const cfg = this.getConfig(); const cfg = this.getConfig();
const baseOpts = { const baseOpts = {
@@ -920,42 +907,35 @@ class RegionalRedisManager {
try { try {
this.writeClient = new Redis(baseOpts); 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;
})
});
const replicaHost = this.getReplicaHost(cfg.host!); // For simplicity use same host for both; callers can always read from primary
this.readClient = replicaHost // The real replica routing is handled by the StatefulSet headless service
? new Redis({ ...baseOpts, host: replicaHost }) this.readClient = this.writeClient;
: this.writeClient;
this.writeClient.on("ready", () => { this.writeClient.on("ready", () => {
logger.info("Regional Redis write client ready"); logger.info("Regional Redis client ready");
this.isHealthy = true; this.isHealthy = true;
}); });
this.writeClient.on("error", (err) => { this.writeClient.on("error", (err) => {
logger.error("Regional Redis write client error:", err); logger.error("Regional Redis client error:", err);
this.isHealthy = false; this.isHealthy = false;
}); });
this.writeClient.on("reconnecting", () => { this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis write client reconnecting..."); logger.info("Regional Redis client reconnecting...");
this.isHealthy = false; this.isHealthy = false;
}); });
if (this.readClient !== this.writeClient) { logger.info("Regional Redis client initialized");
this.readClient.on("ready", () => {
logger.info("Regional Redis read client ready");
});
this.readClient.on("error", (err) => {
logger.error("Regional Redis read client error:", err);
});
this.readClient.on("reconnecting", () => {
logger.info("Regional Redis read client reconnecting...");
});
}
logger.info(
replicaHost
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
);
} catch (error) { } catch (error) {
logger.error("Failed to initialize regional Redis client:", error); logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false; this.isEnabled = false;
@@ -1061,14 +1041,11 @@ class RegionalRedisManager {
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
try { try {
if (this.readClient && this.readClient !== this.writeClient) {
await this.readClient.quit();
}
this.readClient = null;
if (this.writeClient) { if (this.writeClient) {
await this.writeClient.quit(); await this.writeClient.quit();
this.writeClient = null; this.writeClient = null;
} }
this.readClient = null;
logger.info("Regional Redis client disconnected"); logger.info("Regional Redis client disconnected");
} catch (error) { } catch (error) {
logger.error("Error disconnecting regional Redis client:", error); logger.error("Error disconnecting regional Redis client:", error);

View File

@@ -29,8 +29,7 @@ export async function createExitNode(
.where(eq(exitNodes.publicKey, publicKey)); .where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -61,9 +60,6 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the reachable at // update the reachable at
[exitNode] = await db [exitNode] = await db

View File

@@ -114,6 +114,8 @@ export async function createRemoteExitNode(
} }
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -189,27 +191,13 @@ export async function createRemoteExitNode(
); );
} }
// If this remote exit node isn't already backing an exit node in
// another org, we're about to create a brand new one. Reserve a
// subnet for it up front so the allocation lock is held across the
// whole insert - this guarantees exit node subnets never overlap,
// even under concurrent creation, which matters for HA setups.
let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null;
if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet();
newExitNodeAddress = value;
releaseSubnetLock = release;
}
try {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (!existingExitNode) { if (!existingExitNode) {
const [res] = await trx const [res] = await trx
.insert(exitNodes) .insert(exitNodes)
.values({ .values({
name: remoteExitNodeId, name: remoteExitNodeId,
address: newExitNodeAddress!, address,
endpoint: "", endpoint: "",
publicKey: "", publicKey: "",
listenPort: 0, listenPort: 0,
@@ -286,9 +274,6 @@ export async function createRemoteExitNode(
} }
} }
}); });
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);

View File

@@ -13,8 +13,7 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet(); const address = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -45,9 +44,6 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db

View File

@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
clientsRes clientsRes
.filter((client) => { .filter((client) => {
if (!client.clients.pubKey) { if (!client.clients.pubKey) {
logger.debug( logger.warn(
`Client ${client.clients.clientId} has no public key, skipping` `Client ${client.clients.clientId} has no public key, skipping`
); );
return false; return false;
} }
if (!client.clients.subnet) { if (!client.clients.subnet) {
logger.debug( logger.warn(
`Client ${client.clients.clientId} has no subnet, skipping` `Client ${client.clients.clientId} has no subnet, skipping`
); );
return false; return false;

View File

@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
} }
if (!newt.siteId) { if (!newt.siteId) {
logger.warn("Newt has no site ID!"); logger.warn("Newt has no client ID!");
return; return;
} }
@@ -34,12 +34,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!)) .where(eq(sites.siteId, newt.siteId!))
.returning(); .returning();
if (!site) {
throw new Error(
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
);
}
await fireSiteOfflineAlert( await fireSiteOfflineAlert(
site.orgId, site.orgId,
site.siteId, site.siteId,
@@ -49,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
); );
}); });
} catch (error) { } catch (error) {
logger.error("Error handling site disconnecting message", error); logger.error("Error handling disconnecting message", { error });
} }
}; };

View File

@@ -3,7 +3,6 @@ import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts"; import { fireSiteOnlineAlert } from "@server/lib/alerts";
import { withRetry } from "@server/lib/dbRetry";
/** /**
* Ping Accumulator * Ping Accumulator
@@ -23,6 +22,8 @@ import { withRetry } from "@server/lib/dbRetry";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
// Map of siteId -> latest ping timestamp (unix seconds) // Map of siteId -> latest ping timestamp (unix seconds)
@@ -265,7 +266,85 @@ export async function flushPingsToDb(): Promise<void> {
} }
// ── Retry / Error Helpers ────────────────────────────────────────────── // ── Retry / Error Helpers ──────────────────────────────────────────────
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/
async function withRetry<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < MAX_RETRIES) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
/**
* Detect transient errors that are safe to retry.
*/
function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
// ── Lifecycle ────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────

View File

@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
} }
if (!site.subnet) { if (!site.subnet) {
logger.debug(`Site ${site.siteId} has no subnet, skipping`); logger.warn(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }

View File

@@ -6,9 +6,7 @@ import logger from "@server/logger";
/** /**
* Handles disconnecting messages from clients to show disconnected in the ui * Handles disconnecting messages from clients to show disconnected in the ui
*/ */
export const handleOlmDisconnectingMessage: MessageHandler = async ( export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
context
) => {
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;
@@ -31,6 +29,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (
}) })
.where(eq(clients.clientId, olm.clientId)); .where(eq(clients.clientId, olm.clientId));
} catch (error) { } catch (error) {
logger.error("Error handling client disconnecting message", error); logger.error("Error handling disconnecting message", { error });
} }
}; };

View File

@@ -616,8 +616,8 @@ export async function listResources(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
or( or(
effectiveSso, eq(effectiveSso, true),
effectiveWhitelist, eq(effectiveWhitelist, true),
not(isNull(effectiveHeaderAuthId)), not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)), not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId)) not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push( conditions.push(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
not(effectiveSso), not(eq(effectiveSso, true)),
not(effectiveWhitelist), not(eq(effectiveWhitelist, true)),
isNull(effectiveHeaderAuthId), isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId), isNull(effectivePincodeId),
isNull(effectivePasswordId) isNull(effectivePasswordId)

View File

@@ -268,11 +268,7 @@ export async function createSite(
let newSite: Site | undefined; let newSite: Site | undefined;
try { try {
if (type === "wireguard" && subnet && exitNodeId) { if (subnet && exitNodeId) {
// Only wireguard sites actually persist the provided subnet/exitNodeId.
// Newt sites have their subnet/exit node chosen (under a lock) when the
// newt connects, so validating them here is both unnecessary and racy,
// since pickSiteDefaults does not lock the subnet it suggests.
//make sure the subnet is in the range of the exit node if provided //make sure the subnet is in the range of the exit node if provided
const [exitNode] = await db const [exitNode] = await db
.select() .select()

View File

@@ -290,13 +290,7 @@ export default function BillingPage() {
setHasSubscription( setHasSubscription(
tierSub.subscription.status === "active" tierSub.subscription.status === "active"
); );
// expiresAt is only meaningful while the trial hasn't setIsTrial(tierSub.subscription.expiresAt != null);
// actually run out yet; a stale row with a past
// expiresAt should no longer be treated as a live trial
const expiresAt = tierSub.subscription.expiresAt;
setIsTrial(
expiresAt != null && expiresAt * 1000 > Date.now()
);
} }
// Find license subscription // Find license subscription

View File

@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react"; import { useActionState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
}); });
const [, formAction, saveLoading] = useActionState(onSubmit, null); const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() { async function onSubmit() {
const isValid = await form.trigger(); const isValid = await form.trigger();
if (!isValid) return; if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues(); const data = form.getValues();
const res = await api const res = await api
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
onChange={ onChange={
field.onChange field.onChange
} }
onValidityChange={
setHeadersValid
}
rows={4} rows={4}
/> />
</FormControl> </FormControl>
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}
disabled={saveLoading || !headersValid} disabled={saveLoading}
form="http-settings-form" form="http-settings-form"
> >
{t("saveSettings")} {t("saveSettings")}

View File

@@ -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" ||

View File

@@ -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 {

View File

@@ -2,36 +2,24 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps { interface HeadersInputProps {
value?: { name: string; value: string }[] | null; value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void; onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({ export function HeadersInput({
value = [], value = [],
onChange, onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
rows = 4, rows = 4,
className className
}: HeadersInputProps) { }: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false); const isUserEditingRef = useRef(false);
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
.join("\n"); .join("\n");
}; };
// Parse newline-separated text into header objects, validating each line // Convert newline-separated string to header objects array
// against the same rules enforced by the server. Returns either the const convertToHeadersArray = (
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string newlineSeparated: string
): ): { name: string; value: string }[] | null => {
| { headers: { name: string; value: string }[]; error: null } if (!newlineSeparated || newlineSeparated.trim() === "") return [];
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
const lines = newlineSeparated return newlineSeparated
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0 && line.includes(":"))
.map((line) => {
const headers: { name: string; value: string }[] = [];
for (const line of lines) {
const colonIndex = line.indexOf(":"); const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim(); const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim(); const value = line.substring(colonIndex + 1).trim();
if ( // Ensure header name conforms to HTTP header requirements
!validHeaderNamePattern.test(name) || // Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
!validHeaderValuePattern.test(value) || const normalizedName = name
templatePattern.test(name) || .replace(/[^a-zA-Z0-9\-]/g, "")
templatePattern.test(value) .toLowerCase();
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value }); return { name: normalizedName, value };
} })
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
return { headers, error: null };
}; };
// Update internal value when external value changes // Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused) // But only if the user is not currently editing (textarea not focused)
useEffect(() => { useEffect(() => {
if (!isUserEditingRef.current) { if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value ?? [])); setInternalValue(convertToNewlineSeparated(value));
setError(null);
onValidityChange?.(true);
} }
}, [value]); }, [value]);
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
// Mark that user is actively editing // Mark that user is actively editing
isUserEditingRef.current = true; isUserEditingRef.current = true;
const result = parseHeaders(newValue); // Only update parent if the input is in a valid state
// Valid states: empty/whitespace only, or contains properly formatted headers
if (result.error) { if (newValue.trim() === "") {
// Surface the error and do not touch the last known good value. // Empty input is valid - represents no headers
// Silently dropping the update here (without telling the user) onChange([]);
// is what previously let stale data get saved without warning. } else {
setError(result.error); // Check if all non-empty lines are properly formatted (contain ':')
onValidityChange?.(false); const lines = newValue.split("\n");
return; const nonEmptyLines = lines
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
} }
setError(null);
onValidityChange?.(true);
onChange(result.headers);
}; };
const handleFocus = () => { const handleFocus = () => {
@@ -134,7 +114,6 @@ X-Another-Header: another-value`,
}; };
return ( return (
<div>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={internalValue} value={internalValue}
@@ -145,9 +124,5 @@ X-Another-Header: another-value`,
rows={rows} rows={rows}
className={className} className={className}
/> />
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
); );
} }

View File

@@ -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 (
<div
className="w-full relative"
key={idp.idpId}
>
<Button <Button
key={idp.idpId} key={idp.idpId}
type="button" type="button"
variant="outline" variant="outline"
className="w-full inline-flex items-center space-x-2" className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => { onClick={() => {
loginWithIdp(idp.idpId); startTransition(() =>
loginWithIdp(idp.idpId)
);
}} }}
disabled={loading} disabled={loading}
loading={loading} loading={loading}
> >
<IdpTypeIcon type={effectiveType} size={16} /> <IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span> <span>{idp.name}</span>
</Button> </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>
); );
})} })}
</> </>

View File

@@ -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 (
<div
className="w-full relative"
key={idp.idpId}
>
<Button <Button
key={idp.idpId} key={idp.idpId}
type="button" type="button"
variant="outline" variant="outline"
className="w-full inline-flex items-center space-x-2" className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => { onClick={() => {
loginWithIdp(idp.idpId); loginWithIdp(idp.idpId);
}} }}
> >
<IdpTypeIcon type={effectiveType} size={16} /> <IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span> <span>{idp.name}</span>
</Button> </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>
); );

View File

@@ -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
View File

@@ -0,0 +1 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";

View 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;
}