Merge branch 'dev' into refactor/show-product-updates-conditionnally

This commit is contained in:
Fred KISSIE
2025-12-06 00:55:18 +01:00
29 changed files with 3851 additions and 5263 deletions

View File

@@ -144,9 +144,9 @@
"expires": "Expires", "expires": "Expires",
"never": "Never", "never": "Never",
"shareErrorSelectResource": "Please select a resource", "shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Proxy Resources", "proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"clientResourceTitle": "Manage Client Resources", "clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
@@ -924,6 +924,10 @@
"passwordResetSent": "We'll send a password reset code to this email address.", "passwordResetSent": "We'll send a password reset code to this email address.",
"passwordResetCode": "Reset Code", "passwordResetCode": "Reset Code",
"passwordResetCodeDescription": "Check your email for the reset code.", "passwordResetCodeDescription": "Check your email for the reset code.",
"generatePasswordResetCode": "Generate Password Reset Code",
"passwordResetCodeGenerated": "Password Reset Code Generated",
"passwordResetCodeGeneratedDescription": "Share this code with the user. They can use it to reset their password.",
"passwordResetUrl": "Reset URL",
"passwordNew": "New Password", "passwordNew": "New Password",
"passwordNewConfirm": "Confirm New Password", "passwordNewConfirm": "Confirm New Password",
"changePassword": "Change Password", "changePassword": "Change Password",
@@ -941,8 +945,9 @@
"pincodeAuth": "Authenticator Code", "pincodeAuth": "Authenticator Code",
"pincodeSubmit2": "Submit Code", "pincodeSubmit2": "Submit Code",
"passwordResetSubmit": "Request Reset", "passwordResetSubmit": "Request Reset",
"passwordResetAlreadyHaveCode": "Enter Password Reset Code",
"passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequired": "Please contact your administrator",
"passwordResetSmtpRequiredDescription": "Password reset is not available because no SMTP server is configured. Please contact your administrator for assistance.", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
"passwordBack": "Back to Password", "passwordBack": "Back to Password",
"loginBack": "Go back to log in", "loginBack": "Go back to log in",
"signup": "Sign up", "signup": "Sign up",
@@ -1171,8 +1176,8 @@
"sidebarHome": "Home", "sidebarHome": "Home",
"sidebarSites": "Sites", "sidebarSites": "Sites",
"sidebarResources": "Resources", "sidebarResources": "Resources",
"sidebarProxyResources": "Proxy Resources", "sidebarProxyResources": "Public",
"sidebarClientResources": "Client Resources", "sidebarClientResources": "Private",
"sidebarAccessControl": "Access Control", "sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarUsers": "Users", "sidebarUsers": "Users",
@@ -1186,14 +1191,14 @@
"sidebarIdentityProviders": "Identity Providers", "sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients", "sidebarClients": "Clients",
"sidebarUserDevices": "User Devices", "sidebarUserDevices": "Users",
"sidebarMachineClients": "Machine Clients", "sidebarMachineClients": "Machines",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"sidebarGeneral": "General", "sidebarGeneral": "General",
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarLogsAnalytics": "Request Analytics", "sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints", "blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintAdd": "Add Blueprint", "blueprintAdd": "Add Blueprint",
@@ -1572,7 +1577,7 @@
"resourcesTableOffline": "Offline", "resourcesTableOffline": "Offline",
"resourcesTableUnknown": "Unknown", "resourcesTableUnknown": "Unknown",
"resourcesTableNotMonitored": "Not monitored", "resourcesTableNotMonitored": "Not monitored",
"editInternalResourceDialogEditClientResource": "Edit Client Resource", "editInternalResourceDialogEditClientResource": "Edit Private Resource",
"editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}", "editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}",
"editInternalResourceDialogResourceProperties": "Resource Properties", "editInternalResourceDialogResourceProperties": "Resource Properties",
"editInternalResourceDialogName": "Name", "editInternalResourceDialogName": "Name",
@@ -1606,7 +1611,7 @@
"createInternalResourceDialogNoSitesAvailable": "No Sites Available", "createInternalResourceDialogNoSitesAvailable": "No Sites Available",
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.",
"createInternalResourceDialogClose": "Close", "createInternalResourceDialogClose": "Close",
"createInternalResourceDialogCreateClientResource": "Create Client Resource", "createInternalResourceDialogCreateClientResource": "Create Private Resource",
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
"createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogResourceProperties": "Resource Properties",
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
@@ -2230,7 +2235,6 @@
"endpoint": "Endpoint", "endpoint": "Endpoint",
"Id": "Id", "Id": "Id",
"SecretKey": "Secret Key", "SecretKey": "Secret Key",
"featureDisabledTooltip": "This feature is only available in the enterprise plan and require a license to use it.",
"niceId": "Nice ID", "niceId": "Nice ID",
"niceIdUpdated": "Nice ID Updated", "niceIdUpdated": "Nice ID Updated",
"niceIdUpdatedSuccessfully": "Nice ID Updated Successfully", "niceIdUpdatedSuccessfully": "Nice ID Updated Successfully",

7743
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { cleanup as wsCleanup } from "@server/routers/ws"; import { cleanup as wsCleanup } from "#dynamic/routers/ws";
async function cleanup() { async function cleanup() {
await wsCleanup(); await wsCleanup();

View File

@@ -51,7 +51,7 @@ function createDb() {
if (!replicaConnections.length) { if (!replicaConnections.length) {
replicas.push( replicas.push(
DrizzlePostgres(primaryPool, { DrizzlePostgres(primaryPool, {
logger: process.env.NODE_ENV === "development" logger: process.env.QUERY_LOGGING == "true"
}) })
); );
} else { } else {
@@ -65,7 +65,7 @@ function createDb() {
}); });
replicas.push( replicas.push(
DrizzlePostgres(replicaPool, { DrizzlePostgres(replicaPool, {
logger: process.env.NODE_ENV === "development" logger: process.env.QUERY_LOGGING == "true"
}) })
); );
} }
@@ -73,7 +73,7 @@ function createDb() {
return withReplicas( return withReplicas(
DrizzlePostgres(primaryPool, { DrizzlePostgres(primaryPool, {
logger: process.env.QUERY_LOGGING === "true" logger: process.env.QUERY_LOGGING == "true"
}), }),
replicas as any replicas as any
); );

View File

@@ -30,7 +30,7 @@ import {
verifyUserHasAction, verifyUserHasAction,
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
verifySiteAccess, verifySiteAccess,
verifyClientAccess, verifyClientAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { import {
@@ -409,6 +409,8 @@ authenticated.get(
authenticated.post( authenticated.post(
"/re-key/:clientId/regenerate-client-secret", "/re-key/:clientId/regenerate-client-secret",
verifyValidLicense,
verifyValidSubscription,
verifyClientAccess, verifyClientAccess,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateClientSecret reKey.reGenerateClientSecret
@@ -416,15 +418,18 @@ authenticated.post(
authenticated.post( authenticated.post(
"/re-key/:siteId/regenerate-site-secret", "/re-key/:siteId/regenerate-site-secret",
verifyValidLicense,
verifyValidSubscription,
verifySiteAccess, verifySiteAccess,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateSiteSecret reKey.reGenerateSiteSecret
); );
authenticated.put( authenticated.put(
"/re-key/:orgId/reGenerate-remote-exit-node-secret", "/re-key/:orgId/regenerate-remote-exit-node-secret",
verifyValidLicense, verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateRemoteExitNode), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateExitNodeSecret reKey.reGenerateExitNodeSecret
); );

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, olms, } from "@server/db"; import { db, olms } from "@server/db";
import { clients } from "@server/db"; import { clients } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -23,16 +23,16 @@ import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { disconnectClient, sendToClient } from "#dynamic/routers/ws";
const reGenerateSecretParamsSchema = z.strictObject({ const reGenerateSecretParamsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
}); });
const reGenerateSecretBodySchema = z.strictObject({ const reGenerateSecretBodySchema = z.strictObject({
olmId: z.string().min(1).optional(), // olmId: z.string().min(1).optional(),
secret: z.string().min(1).optional(), secret: z.string().min(1)
});
});
export type ReGenerateSecretBody = z.infer<typeof reGenerateSecretBodySchema>; export type ReGenerateSecretBody = z.infer<typeof reGenerateSecretBodySchema>;
@@ -54,7 +54,6 @@ registry.registerPath({
responses: {} responses: {}
}); });
export async function reGenerateClientSecret( export async function reGenerateClientSecret(
req: Request, req: Request,
res: Response, res: Response,
@@ -71,7 +70,7 @@ export async function reGenerateClientSecret(
); );
} }
const { olmId, secret } = parsedBody.data; const { secret } = parsedBody.data;
const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -85,11 +84,7 @@ export async function reGenerateClientSecret(
const { clientId } = parsedParams.data; const { clientId } = parsedParams.data;
let secretHash = undefined; const secretHash = await hashPassword(secret);
if (secret) {
secretHash = await hashPassword(secret);
}
// Fetch the client to make sure it exists and the user has access to it // Fetch the client to make sure it exists and the user has access to it
const [client] = await db const [client] = await db
@@ -107,24 +102,51 @@ export async function reGenerateClientSecret(
); );
} }
const [existingOlm] = await db const existingOlms = await db
.select() .select()
.from(olms) .from(olms)
.where(eq(olms.clientId, clientId)) .where(eq(olms.clientId, clientId));
.limit(1);
if (existingOlm && olmId && secretHash) { if (existingOlms.length === 0) {
await db return next(
.update(olms) createHttpError(
.set({ HttpCode.NOT_FOUND,
olmId, `No OLM found for client ID ${clientId}`
secretHash )
}) );
.where(eq(olms.clientId, clientId));
} }
if (existingOlms.length > 1) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Multiple OLM entries found for client ID ${clientId}`
)
);
}
await db
.update(olms)
.set({
secretHash
})
.where(eq(olms.olmId, existingOlms[0].olmId));
const payload = {
type: `olm/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(existingOlms[0].olmId, payload).catch((error) => {
logger.error("Failed to send termination message to olm:", error);
});
disconnectClient(existingOlms[0].olmId).catch((error) => {
logger.error("Failed to disconnect olm after re-key:", error);
});
return response(res, { return response(res, {
data: existingOlm, data: existingOlms,
success: true, success: true,
error: false, error: false,
message: "Credentials regenerated successfully", message: "Credentials regenerated successfully",

View File

@@ -24,16 +24,16 @@ import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { UpdateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { UpdateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { disconnectClient } from "@server/routers/ws";
export const paramsSchema = z.object({ export const paramsSchema = z.object({
orgId: z.string() orgId: z.string()
}); });
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
remoteExitNodeId: z.string().length(15), remoteExitNodeId: z.string().length(15),
secret: z.string().length(48) secret: z.string().length(48)
}); });
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
@@ -81,12 +81,6 @@ export async function reGenerateExitNodeSecret(
const { remoteExitNodeId, secret } = parsedBody.data; const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
}
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
.from(remoteExitNodes) .from(remoteExitNodes)
@@ -94,7 +88,10 @@ export async function reGenerateExitNodeSecret(
if (!existingRemoteExitNode) { if (!existingRemoteExitNode) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, "Remote Exit Node does not exist") createHttpError(
HttpCode.NOT_FOUND,
"Remote Exit Node does not exist"
)
); );
} }
@@ -105,15 +102,21 @@ export async function reGenerateExitNodeSecret(
.set({ secretHash }) .set({ secretHash })
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
disconnectClient(existingRemoteExitNode.remoteExitNodeId).catch(
(error) => {
logger.error("Failed to disconnect newt after re-key:", error);
}
);
return response<UpdateRemoteExitNodeResponse>(res, { return response<UpdateRemoteExitNodeResponse>(res, {
data: { data: {
remoteExitNodeId, remoteExitNodeId,
secret, secret
}, },
success: true, success: true,
error: false, error: false,
message: "Remote Exit Node secret updated successfully", message: "Remote Exit Node secret updated successfully",
status: HttpCode.OK, status: HttpCode.OK
}); });
} catch (e) { } catch (e) {
logger.error("Failed to update remoteExitNode", e); logger.error("Failed to update remoteExitNode", e);

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, newts, sites } from "@server/db"; import { db, Newt, newts, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -22,38 +22,37 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { addPeer } from "@server/routers/gerbil/peers"; import { addPeer, deletePeer } from "@server/routers/gerbil/peers";
import { getAllowedIps } from "@server/routers/target/helpers";
import { disconnectClient, sendToClient } from "#dynamic/routers/ws";
const updateSiteParamsSchema = z.strictObject({ const updateSiteParamsSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive()) siteId: z.string().transform(Number).pipe(z.int().positive())
}); });
const updateSiteBodySchema = z.strictObject({ const updateSiteBodySchema = z.strictObject({
type: z.enum(["newt", "wireguard"]), type: z.enum(["newt", "wireguard"]),
newtId: z.string().min(1).max(255).optional(), secret: z.string().min(1).max(255).optional(),
newtSecret: z.string().min(1).max(255).optional(), pubKey: z.string().optional()
exitNodeId: z.int().positive().optional(), });
pubKey: z.string().optional(),
subnet: z.string().optional(),
});
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/re-key/{siteId}/regenerate-site-secret", path: "/re-key/{siteId}/regenerate-site-secret",
description: "Regenerate a site's Newt or WireGuard credentials by its site ID.", description:
"Regenerate a site's Newt or WireGuard credentials by its site ID.",
tags: [OpenAPITags.Site], tags: [OpenAPITags.Site],
request: { request: {
params: updateSiteParamsSchema, params: updateSiteParamsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: updateSiteBodySchema, schema: updateSiteBodySchema
}, }
}, }
}, }
}, },
responses: {}, responses: {}
}); });
export async function reGenerateSiteSecret( export async function reGenerateSiteSecret(
@@ -65,74 +64,141 @@ export async function reGenerateSiteSecret(
const parsedParams = updateSiteParamsSchema.safeParse(req.params); const parsedParams = updateSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()) createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
); );
} }
const parsedBody = updateSiteBodySchema.safeParse(req.body); const parsedBody = updateSiteBodySchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString()) createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
); );
} }
const { siteId } = parsedParams.data; const { siteId } = parsedParams.data;
const { type, exitNodeId, pubKey, subnet, newtId, newtSecret } = parsedBody.data; const { type, pubKey, secret } = parsedBody.data;
let updatedSite = undefined;
let existingNewt: Newt | null = null;
if (type === "newt") { if (type === "newt") {
if (!newtSecret) { if (!secret) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "newtSecret is required for newt sites")
);
}
const secretHash = await hashPassword(newtSecret);
updatedSite = await db
.update(newts)
.set({
newtId,
secretHash,
})
.where(eq(newts.siteId, siteId))
.returning();
logger.info(`Regenerated Newt credentials for site ${siteId}`);
} else if (type === "wireguard") {
if (!pubKey) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Public key is required for wireguard sites")
);
}
if (!exitNodeId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Exit node ID is required for wireguard sites" "newtSecret is required for newt sites"
)
);
}
const secretHash = await hashPassword(secret);
// get the newt to verify it exists
const existingNewts = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId));
if (existingNewts.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No Newt found for site ID ${siteId}`
)
);
}
if (existingNewts.length > 1) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Multiple Newts found for site ID ${siteId}`
)
);
}
existingNewt = existingNewts[0];
// update the secret on the existing newt
await db
.update(newts)
.set({
secretHash
})
.where(eq(newts.newtId, existingNewts[0].newtId));
const payload = {
type: `newt/wg/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(existingNewts[0].newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
disconnectClient(existingNewts[0].newtId).catch((error) => {
logger.error("Failed to disconnect newt after re-key:", error);
});
logger.info(`Regenerated Newt credentials for site ${siteId}`);
} else if (type === "wireguard") {
if (!pubKey) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Public key is required for wireguard sites"
) )
); );
} }
try { try {
updatedSite = await db.transaction(async (tx) => { const [site] = await db
await addPeer(exitNodeId, { .select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
await db
.update(sites)
.set({ pubKey })
.where(eq(sites.siteId, siteId));
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
if (site.exitNodeId && site.subnet) {
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
await addPeer(site.exitNodeId, {
publicKey: pubKey, publicKey: pubKey,
allowedIps: subnet ? [subnet] : [], allowedIps: await getAllowedIps(site.siteId)
}); });
const result = await tx }
.update(sites)
.set({ pubKey })
.where(eq(sites.siteId, siteId))
.returning();
return result; logger.info(
}); `Regenerated WireGuard credentials for site ${siteId}`
);
logger.info(`Regenerated WireGuard credentials for site ${siteId}`);
} catch (err) { } catch (err) {
logger.error( logger.error(
`Transaction failed while regenerating WireGuard secret for site ${siteId}`, `Transaction failed while regenerating WireGuard secret for site ${siteId}`,
@@ -148,17 +214,19 @@ export async function reGenerateSiteSecret(
} }
return response(res, { return response(res, {
data: updatedSite, data: existingNewt,
success: true, success: true,
error: false, error: false,
message: "Credentials regenerated successfully", message: "Credentials regenerated successfully",
status: HttpCode.OK, status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
logger.error("Unexpected error in reGenerateSiteSecret", error); logger.error("Unexpected error in reGenerateSiteSecret", error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An unexpected error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An unexpected error occurred"
)
); );
} }
} }

View File

@@ -715,6 +715,11 @@ unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
authenticated.post(
"/user/:userId/generate-password-reset-code",
verifyUserIsServerAdmin,
user.adminGeneratePasswordResetCode
);
authenticated.delete( authenticated.delete(
"/user/:userId", "/user/:userId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,

View File

@@ -104,43 +104,13 @@ export async function getOlmToken(
const resToken = generateSessionToken(); const resToken = generateSessionToken();
await createOlmSession(resToken, existingOlm.olmId); await createOlmSession(resToken, existingOlm.olmId);
let orgIdToUse = orgId;
let clientIdToUse; let clientIdToUse;
if (!orgIdToUse) { if (orgId) {
if (!existingOlm.clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Olm is not associated with a client, orgId is required"
)
);
}
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, existingOlm.clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Olm's associated client not found, orgId is required"
)
);
}
orgIdToUse = client.orgId;
clientIdToUse = client.clientId;
} else {
// we did provide the org // we did provide the org
const [client] = await db const [client] = await db
.select() .select()
.from(clients) .from(clients)
.where( .where(and(eq(clients.orgId, orgId), eq(clients.olmId, olmId))) // we want to lock on to the client with this olmId otherwise it can get assigned to a random one
and(eq(clients.orgId, orgIdToUse), eq(clients.olmId, olmId))
) // we want to lock on to the client with this olmId otherwise it can get assigned to a random one
.limit(1); .limit(1);
if (!client) { if (!client) {
@@ -167,6 +137,32 @@ export async function getOlmToken(
.where(eq(olms.olmId, existingOlm.olmId)); .where(eq(olms.olmId, existingOlm.olmId));
} }
clientIdToUse = client.clientId;
} else {
if (!existingOlm.clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Olm is not associated with a client, orgId is required"
)
);
}
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, existingOlm.clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Olm's associated client not found, orgId is required"
)
);
}
clientIdToUse = client.clientId; clientIdToUse = client.clientId;
} }

View File

@@ -136,7 +136,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
const policyCheck = await checkOrgAccessPolicy({ const policyCheck = await checkOrgAccessPolicy({
orgId: client.orgId, orgId: client.orgId,
userId: olm.userId, userId: olm.userId,
session: userToken // this is the user token passed in the message sessionId: userToken // this is the user token passed in the message
}); });
if (!policyCheck.allowed) { if (!policyCheck.allowed) {

View File

@@ -97,7 +97,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
const policyCheck = await checkOrgAccessPolicy({ const policyCheck = await checkOrgAccessPolicy({
orgId: orgId, orgId: orgId,
userId: olm.userId, userId: olm.userId,
session: userToken // this is the user token passed in the message sessionId: userToken // this is the user token passed in the message
}); });
if (!policyCheck.allowed) { if (!policyCheck.allowed) {

View File

@@ -72,7 +72,7 @@ const createSiteResourceSchema = z
}, },
{ {
message: message:
"Destination must be a valid IP address or domain name for host mode" "Destination must be a valid IP address or valid domain AND alias is required"
} }
) )
.refine( .refine(

View File

@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import {
clientSiteResources, clientSiteResources,
clientSiteResourcesAssociationsCache,
db, db,
newts, newts,
roles, roles,
@@ -59,23 +60,27 @@ const updateSiteResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host" && data.destination) { if (data.mode === "host" && data.destination) {
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
.safeParse(data.destination).success; .safeParse(data.destination).success;
if (isValidIP) {
return true;
}
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex = const domainRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const isValidDomain = domainRegex.test(data.destination); const isValidDomain = domainRegex.test(data.destination);
const isValidAlias = data.alias && domainRegex.test(data.alias);
return isValidIP || isValidDomain; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} }
return true; return true;
}, },
{ {
message: message:
"Destination must be a valid IP address or domain name for host mode" "Destination must be a valid IP address or valid domain AND alias is required"
} }
) )
.refine( .refine(
@@ -336,27 +341,67 @@ export async function updateSiteResource(
const olmJobs: Promise<void>[] = []; const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) { for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site // we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push( olmJobs.push(
updatePeerData( updatePeerData(
client.clientId, client.clientId,
updatedSiteResource.siteId, updatedSiteResource.siteId,
destinationChanged ? { destinationChanged
oldRemoteSubnets: generateRemoteSubnets([ ? {
existingSiteResource oldRemoteSubnets:
]), !oldDestinationStillInUseByASite
newRemoteSubnets: generateRemoteSubnets([ ? generateRemoteSubnets([
updatedSiteResource existingSiteResource
]) ])
} : undefined, : [],
aliasChanged ? { newRemoteSubnets: generateRemoteSubnets([
oldAliases: generateAliasConfig([ updatedSiteResource
existingSiteResource ])
]), }
newAliases: generateAliasConfig([ : undefined,
updatedSiteResource aliasChanged
]) ? {
} : undefined oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
) )
); );
} }

View File

@@ -0,0 +1,125 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db";
import { eq } from "drizzle-orm";
import { alphabet, generateRandomString } from "oslo/crypto";
import { createDate } from "oslo";
import logger from "@server/logger";
import { TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
import config from "@server/lib/config";
const adminGeneratePasswordResetCodeSchema = z.strictObject({
userId: z.string().min(1)
});
export type AdminGeneratePasswordResetCodeBody = z.infer<typeof adminGeneratePasswordResetCodeSchema>;
export type AdminGeneratePasswordResetCodeResponse = {
token: string;
email: string;
url: string;
};
export async function adminGeneratePasswordResetCode(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedParams = adminGeneratePasswordResetCodeSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId } = parsedParams.data;
try {
const existingUser = await db
.select()
.from(users)
.where(eq(users.userId, userId));
if (!existingUser || !existingUser.length) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found"
)
);
}
if (existingUser[0].type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Password reset codes can only be generated for internal users"
)
);
}
if (!existingUser[0].email) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User does not have an email address"
)
);
}
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
await db.transaction(async (trx) => {
await trx
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
const tokenHash = await hashPassword(token);
await trx.insert(passwordResetTokens).values({
userId: existingUser[0].userId,
email: existingUser[0].email!,
tokenHash,
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
});
});
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`;
logger.info(
`Admin generated password reset code for user ${existingUser[0].email} (${userId})`
);
return response<AdminGeneratePasswordResetCodeResponse>(res, {
data: {
token,
email: existingUser[0].email!,
url
},
success: true,
error: false,
message: "Password reset code generated successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to generate password reset code"
)
);
}
}

View File

@@ -8,6 +8,7 @@ export * from "./getOrgUser";
export * from "./adminListUsers"; export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./adminGeneratePasswordResetCode";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";

View File

@@ -25,7 +25,13 @@ import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsMod
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build"; import { build } from "@server/build";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
export default function CredentialsPage() { export default function CredentialsPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -36,7 +42,8 @@ export default function CredentialsPage() {
const { remoteExitNode } = useRemoteExitNodeContext(); const { remoteExitNode } = useRemoteExitNodeContext();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [credentials, setCredentials] = useState<PickRemoteExitNodeDefaultsResponse | null>(null); const [credentials, setCredentials] =
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
@@ -48,21 +55,19 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed; return isEnterpriseNotLicensed || isSaasNotSubscribed;
}; };
const handleConfirmRegenerate = async () => { const handleConfirmRegenerate = async () => {
const response = await api.get<
const response = await api.get<AxiosResponse<PickRemoteExitNodeDefaultsResponse>>( AxiosResponse<PickRemoteExitNodeDefaultsResponse>
`/org/${orgId}/pick-remote-exit-node-defaults` >(`/org/${orgId}/pick-remote-exit-node-defaults`);
);
const data = response.data.data; const data = response.data.data;
setCredentials(data); setCredentials(data);
await api.put<AxiosResponse<QuickStartRemoteExitNodeResponse>>( await api.put<AxiosResponse<QuickStartRemoteExitNodeResponse>>(
`/re-key/${orgId}/reGenerate-remote-exit-node-secret`, `/re-key/${orgId}/regenerate-remote-exit-node-secret`,
{ {
remoteExitNodeId: remoteExitNode.remoteExitNodeId, remoteExitNodeId: remoteExitNode.remoteExitNodeId,
secret: data.secret, secret: data.secret
} }
); );
@@ -85,40 +90,29 @@ export default function CredentialsPage() {
}; };
return ( return (
<SettingsContainer> <>
<SettingsSection> <SettingsContainer>
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t("generatedcredentials")} <SettingsSectionTitle>
</SettingsSectionTitle> {t("generatedcredentials")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t("regenerateCredentials")} <SettingsSectionDescription>
</SettingsSectionDescription> {t("regenerateCredentials")}
</SettingsSectionHeader> </SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<TooltipProvider> <SecurityFeaturesAlert />
<Tooltip> <Button
<TooltipTrigger asChild> onClick={() => setModalOpen(true)}
<div className="inline-block"> disabled={isSecurityFeatureDisabled()}
<Button >
onClick={() => setModalOpen(true)} {t("regeneratecredentials")}
disabled={isSecurityFeatureDisabled()} </Button>
> </SettingsSectionBody>
{t("regeneratecredentials")} </SettingsSection>
</Button> </SettingsContainer>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal <RegenerateCredentialsModal
open={modalOpen} open={modalOpen}
@@ -128,6 +122,6 @@ export default function CredentialsPage() {
dashboardUrl={env.app.dashboardUrl} dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()} credentials={getCredentials()}
/> />
</SettingsContainer> </>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@@ -59,7 +60,6 @@ export default function CredentialsPage() {
await api.post( await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`, `/re-key/${client?.clientId}/regenerate-client-secret`,
{ {
olmId: data.olmId,
secret: data.olmSecret secret: data.olmSecret
} }
); );
@@ -84,40 +84,29 @@ export default function CredentialsPage() {
}; };
return ( return (
<SettingsContainer> <>
<SettingsSection> <SettingsContainer>
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t("generatedcredentials")} <SettingsSectionTitle>
</SettingsSectionTitle> {t("generatedcredentials")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t("regenerateCredentials")} <SettingsSectionDescription>
</SettingsSectionDescription> {t("regenerateCredentials")}
</SettingsSectionHeader> </SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<TooltipProvider> <SecurityFeaturesAlert />
<Tooltip> <Button
<TooltipTrigger asChild> onClick={() => setModalOpen(true)}
<div className="inline-block"> disabled={isSecurityFeatureDisabled()}
<Button >
onClick={() => setModalOpen(true)} {t("regeneratecredentials")}
disabled={isSecurityFeatureDisabled()} </Button>
> </SettingsSectionBody>
{t("regeneratecredentials")} </SettingsSection>
</Button> </SettingsContainer>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal <RegenerateCredentialsModal
open={modalOpen} open={modalOpen}
@@ -127,6 +116,6 @@ export default function CredentialsPage() {
dashboardUrl={env.app.dashboardUrl} dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()} credentials={getCredentials()}
/> />
</SettingsContainer> </>
); );
} }

View File

@@ -140,7 +140,7 @@ export default function Page() {
}, },
{ {
title: t("run"), title: t("run"),
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint} --org ${orgId}` command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
} }
] ]
}, },
@@ -152,7 +152,7 @@ export default function Page() {
}, },
{ {
title: t("run"), title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint} --org ${orgId}` command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
} }
] ]
} }

View File

@@ -22,7 +22,13 @@ import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsMod
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build"; import { build } from "@server/build";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
export default function CredentialsPage() { export default function CredentialsPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -33,7 +39,8 @@ export default function CredentialsPage() {
const { site } = useSiteContext(); const { site } = useSiteContext();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [siteDefaults, setSiteDefaults] = useState<PickSiteDefaultsResponse | null>(null); const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [wgConfig, setWgConfig] = useState(""); const [wgConfig, setWgConfig] = useState("");
const [publicKey, setPublicKey] = useState(""); const [publicKey, setPublicKey] = useState("");
@@ -47,7 +54,6 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed; return isEnterpriseNotLicensed || isSaasNotSubscribed;
}; };
const hydrateWireGuardConfig = ( const hydrateWireGuardConfig = (
privateKey: string, privateKey: string,
publicKey: string, publicKey: string,
@@ -97,8 +103,6 @@ PersistentKeepalive = 5`;
await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, {
type: "wireguard", type: "wireguard",
subnet: res.data.data.subnet,
exitNodeId: res.data.data.exitNodeId,
pubKey: generatedPublicKey pubKey: generatedPublicKey
}); });
} }
@@ -109,11 +113,13 @@ PersistentKeepalive = 5`;
const data = res.data.data; const data = res.data.data;
setSiteDefaults(data); setSiteDefaults(data);
await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { await api.post(
type: "newt", `/re-key/${site?.siteId}/regenerate-site-secret`,
newtId: data.newtId, {
newtSecret: data.newtSecret type: "newt",
}); secret: data.newtSecret
}
);
} }
} }
@@ -145,40 +151,30 @@ PersistentKeepalive = 5`;
}; };
return ( return (
<SettingsContainer> <>
<SettingsSection> <SettingsContainer>
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t("generatedcredentials")} <SettingsSectionTitle>
</SettingsSectionTitle> {t("generatedcredentials")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t("regenerateCredentials")} <SettingsSectionDescription>
</SettingsSectionDescription> {t("regenerateCredentials")}
</SettingsSectionHeader> </SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody> <SecurityFeaturesAlert />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && ( <SettingsSectionBody>
<TooltipContent side="top"> <Button
{t("featureDisabledTooltip")} onClick={() => setModalOpen(true)}
</TooltipContent> disabled={isSecurityFeatureDisabled()}
)} >
</Tooltip> {t("regeneratecredentials")}
</TooltipProvider> </Button>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</SettingsContainer>
<RegenerateCredentialsModal <RegenerateCredentialsModal
open={modalOpen} open={modalOpen}
@@ -188,6 +184,6 @@ PersistentKeepalive = 5`;
dashboardUrl={env.app.dashboardUrl} dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()} credentials={getCredentials()}
/> />
</SettingsContainer> </>
); );
} }

View File

@@ -7,16 +7,6 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { pullEnv } from "@app/lib/pullEnv";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -32,7 +22,6 @@ export default async function Page(props: {
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser();
const t = await getTranslations(); const t = await getTranslations();
const env = pullEnv();
if (user) { if (user) {
let loggedOut = false; let loggedOut = false;
@@ -55,48 +44,6 @@ export default async function Page(props: {
redirectUrl = cleanRedirect(searchParams.redirect); redirectUrl = cleanRedirect(searchParams.redirect);
} }
// If email is not enabled, show a message instead of the form
if (!env.email.emailEnabled) {
return (
<>
<div className="w-full max-w-md">
<Card>
<CardHeader>
<CardTitle>{t("passwordReset")}</CardTitle>
<CardDescription>
{t("passwordResetDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("passwordResetSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t("passwordResetSmtpRequiredDescription")}
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
<p className="text-center text-muted-foreground mt-4">
<Link
href={
!searchParams.redirect
? `/auth/login`
: `/auth/login?redirect=${redirectUrl}`
}
className="underline"
>
{t("loginBack")}
</Link>
</p>
</>
);
}
return ( return (
<> <>
<ResetPasswordForm <ResetPasswordForm

View File

@@ -60,7 +60,8 @@ export const orgNavSections = (): SidebarNavSection[] => [
{ {
title: "sidebarClientResources", title: "sidebarClientResources",
href: "/{orgId}/settings/resources/client", href: "/{orgId}/settings/resources/client",
icon: <GlobeLock className="size-4 flex-none" /> icon: <GlobeLock className="size-4 flex-none" />,
isBeta: true
} }
] ]
}, },
@@ -104,7 +105,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
] ]
}, },
{ {
heading: "accessControls", heading: "access",
items: [ items: [
{ {
title: "sidebarUsers", title: "sidebarUsers",

View File

@@ -19,6 +19,18 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import {
Credenza,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaBody,
CredenzaFooter,
CredenzaClose
} from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { AxiosResponse } from "axios";
export type GlobalUserRow = { export type GlobalUserRow = {
id: string; id: string;
@@ -37,6 +49,12 @@ type Props = {
users: GlobalUserRow[]; users: GlobalUserRow[];
}; };
type AdminGeneratePasswordResetCodeResponse = {
token: string;
email: string;
url: string;
};
export default function UsersTable({ users }: Props) { export default function UsersTable({ users }: Props) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -48,6 +66,11 @@ export default function UsersTable({ users }: Props) {
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] =
useState(false);
const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const refreshData = async () => { const refreshData = async () => {
console.log("Data refreshed"); console.log("Data refreshed");
@@ -86,6 +109,29 @@ export default function UsersTable({ users }: Props) {
}); });
}; };
const generatePasswordResetCode = async (userId: string) => {
setIsGeneratingCode(true);
try {
const res = await api.post<
AxiosResponse<AdminGeneratePasswordResetCodeResponse>
>(`/user/${userId}/generate-password-reset-code`);
if (res.data?.data) {
setPasswordResetCodeData(res.data.data);
setIsPasswordResetCodeDialogOpen(true);
}
} catch (e) {
console.error("Failed to generate password reset code", e);
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("errorOccurred"))
});
} finally {
setIsGeneratingCode(false);
}
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [ const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{ {
accessorKey: "id", accessorKey: "id",
@@ -195,7 +241,7 @@ export default function UsersTable({ users }: Props) {
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<span> <span>
{userRow.twoFactorEnabled || {userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? ( userRow.twoFactorSetupRequested ? (
<span className="text-green-500"> <span className="text-green-500">
{t("enabled")} {t("enabled")}
</span> </span>
@@ -217,17 +263,21 @@ export default function UsersTable({ users }: Props) {
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" className="h-8 w-8 p-0">
variant="ghost" <span className="sr-only">Open menu</span>
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{r.type !== "internal" && (
<DropdownMenuItem
onClick={() => {
generatePasswordResetCode(r.id);
}}
>
{t("generatePasswordResetCode")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelected(r); setSelected(r);
@@ -295,6 +345,58 @@ export default function UsersTable({ users }: Props) {
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
/> />
<Credenza
open={isPasswordResetCodeDialogOpen}
onOpenChange={setIsPasswordResetCodeDialogOpen}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("passwordResetCodeGenerated")}
</CredenzaTitle>
<CredenzaDescription>
{t("passwordResetCodeGeneratedDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{passwordResetCodeData && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">
{t("email")}
</label>
<CopyToClipboard
text={passwordResetCodeData.email}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetCode")}
</label>
<CopyToClipboard
text={passwordResetCodeData.token}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetUrl")}
</label>
<CopyToClipboard
text={passwordResetCodeData.url}
isLink={true}
/>
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }

View File

@@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
const t = useTranslations(); const t = useTranslations();
return ( return (
<div className="flex items-center space-x-2 max-w-full"> <div className="flex items-center space-x-2 min-w-0 max-w-full">
{isLink ? ( {isLink ? (
<Link <Link
href={text} href={text}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="truncate hover:underline text-sm" className="truncate hover:underline text-sm min-w-0 max-w-full"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover title={text} // Shows full text on hover
> >
{displayValue} {displayValue}
</Link> </Link>
) : ( ) : (
<span <span
className="truncate text-sm" className="truncate text-sm min-w-0 max-w-full"
style={{ style={{
maxWidth: "100%",
display: "block",
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis" textOverflow: "ellipsis"
@@ -55,7 +52,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
)} )}
<button <button
type="button" type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer" className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy} onClick={handleCopy}
> >
{!copied ? ( {!copied ? (

View File

@@ -232,6 +232,21 @@ export default function CreateInternalResourceDialog({
const mode = form.watch("mode"); const mode = form.watch("mode");
// Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination);
};
// Helper function to clean resource name for FQDN format
const cleanForFQDN = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens
.replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
.replace(/^\.|\.$/g, ""); // Remove leading/trailing dots
};
useEffect(() => { useEffect(() => {
if (open && availableSites.length > 0) { if (open && availableSites.length > 0) {
form.reset({ form.reset({
@@ -253,6 +268,26 @@ export default function CreateInternalResourceDialog({
const handleSubmit = async (data: FormData) => { const handleSubmit = async (data: FormData) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Validate: if mode is "host" and destination is a hostname (contains letters),
// an alias is required
if (data.mode === "host" && isHostname(data.destination)) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
// Prefill alias based on destination
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
// Use resource name cleaned for FQDN with .internal suffix
const cleanedName = cleanForFQDN(data.name);
aliasValue = `${cleanedName}.internal`;
}
// Update the form with the prefilled alias
form.setValue("alias", aliasValue);
data.alias = aliasValue;
}
}
const response = await api.put<AxiosResponse<any>>( const response = await api.put<AxiosResponse<any>>(
`/org/${orgId}/site/${data.siteId}/resource`, `/org/${orgId}/site/${data.siteId}/resource`,
{ {

View File

@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// ); // );
return ( return (
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}> <div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
{children} {children}
</div> </div>
); );

View File

@@ -273,9 +273,44 @@ export default function EditInternalResourceDialog({
const mode = form.watch("mode"); const mode = form.watch("mode");
// Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination);
};
// Helper function to clean resource name for FQDN format
const cleanForFQDN = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens
.replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
.replace(/^\.|\.$/g, ""); // Remove leading/trailing dots
};
const handleSubmit = async (data: FormData) => { const handleSubmit = async (data: FormData) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Validate: if mode is "host" and destination is a hostname (contains letters),
// an alias is required
if (data.mode === "host" && isHostname(data.destination)) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
// Prefill alias based on destination
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
// Use resource name cleaned for FQDN with .internal suffix
const cleanedName = cleanForFQDN(data.name);
aliasValue = `${cleanedName}.internal`;
}
// Update the form with the prefilled alias
form.setValue("alias", aliasValue);
data.alias = aliasValue;
}
}
// Update the site resource // Update the site resource
await api.post( await api.post(
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, `/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,

View File

@@ -34,8 +34,8 @@ import {
ResetPasswordBody, ResetPasswordBody,
ResetPasswordResponse ResetPasswordResponse
} from "@server/routers/auth"; } from "@server/routers/auth";
import { Loader2 } from "lucide-react"; import { Loader2, InfoIcon } from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
@@ -84,22 +84,23 @@ export default function ResetPasswordForm({
const [state, setState] = useState<"request" | "reset" | "mfa">(getState()); const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
const formSchema = z const formSchema = z
.object({ .object({
email: z.email({ message: t('emailInvalid') }), email: z.email({ message: t("emailInvalid") }),
token: z.string().min(8, { message: t('tokenInvalid') }), token: z.string().min(8, { message: t("tokenInvalid") }),
password: passwordSchema, password: passwordSchema,
confirmPassword: passwordSchema confirmPassword: passwordSchema
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"], path: ["confirmPassword"],
message: t('passwordNotMatch') message: t("passwordNotMatch")
}); });
const mfaSchema = z.object({ const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') }) code: z.string().length(6, { message: t("pincodeInvalid") })
}); });
const form = useForm({ const form = useForm({
@@ -139,8 +140,8 @@ export default function ResetPasswordForm({
} as RequestPasswordResetBody } as RequestPasswordResetBody
) )
.catch((e) => { .catch((e) => {
setError(formatAxiosError(e, t('errorOccurred'))); setError(formatAxiosError(e, t("errorOccurred")));
console.error(t('passwordErrorRequestReset'), e); console.error(t("passwordErrorRequestReset"), e);
setIsSubmitting(false); setIsSubmitting(false);
}); });
@@ -169,8 +170,8 @@ export default function ResetPasswordForm({
} as ResetPasswordBody } as ResetPasswordBody
) )
.catch((e) => { .catch((e) => {
setError(formatAxiosError(e, t('errorOccurred'))); setError(formatAxiosError(e, t("errorOccurred")));
console.error(t('passwordErrorReset'), e); console.error(t("passwordErrorReset"), e);
setIsSubmitting(false); setIsSubmitting(false);
}); });
@@ -186,7 +187,11 @@ export default function ResetPasswordForm({
return; return;
} }
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess')); setSuccessMessage(
quickstart
? t("accountSetupSuccess")
: t("passwordResetSuccess")
);
// Auto-login after successful password reset // Auto-login after successful password reset
try { try {
@@ -208,7 +213,10 @@ export default function ResetPasswordForm({
try { try {
await api.post("/auth/verify-email/request"); await api.post("/auth/verify-email/request");
} catch (verificationError) { } catch (verificationError) {
console.error("Failed to send verification code:", verificationError); console.error(
"Failed to send verification code:",
verificationError
);
} }
if (redirect) { if (redirect) {
@@ -229,7 +237,6 @@ export default function ResetPasswordForm({
} }
setIsSubmitting(false); setIsSubmitting(false);
}, 1500); }, 1500);
} catch (loginError) { } catch (loginError) {
// Auto-login failed, but password reset was successful // Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError); console.error("Auto-login failed:", loginError);
@@ -251,47 +258,70 @@ export default function ResetPasswordForm({
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')} {quickstart
? t("completeAccountSetup")
: t("passwordReset")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{quickstart {quickstart
? t('completeAccountSetupDescription') ? t("completeAccountSetupDescription")
: t('passwordResetDescription') : t("passwordResetDescription")}
}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{state === "request" && ( {state === "request" && (
<Form {...requestForm}> <>
<form {!env.email.emailEnabled && (
onSubmit={requestForm.handleSubmit( <Alert variant="neutral">
onRequest <InfoIcon className="h-4 w-4" />
)} <AlertTitle className="font-semibold">
className="space-y-4" {t("passwordResetSmtpRequired")}
id="form" </AlertTitle>
> <AlertDescription>
<FormField {t(
control={requestForm.control} "passwordResetSmtpRequiredDescription"
name="email" )}
render={({ field }) => ( </AlertDescription>
<FormItem> </Alert>
<FormLabel>{t('email')}</FormLabel> )}
<FormControl> {env.email.emailEnabled && (
<Input {...field} /> <Form {...requestForm}>
</FormControl> <form
<FormMessage /> onSubmit={requestForm.handleSubmit(
<FormDescription> onRequest
{quickstart )}
? t('accountSetupSent') className="space-y-4"
: t('passwordResetSent') id="form"
} >
</FormDescription> <FormField
</FormItem> control={requestForm.control}
)} name="email"
/> render={({ field }) => (
</form> <FormItem>
</Form> <FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t(
"accountSetupSent"
)
: t(
"passwordResetSent"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</>
)} )}
{state === "reset" && ( {state === "reset" && (
@@ -306,11 +336,13 @@ export default function ResetPasswordForm({
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('email')}</FormLabel> <FormLabel>
{t("email")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
disabled disabled={env.email.emailEnabled}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -326,9 +358,12 @@ export default function ResetPasswordForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{quickstart {quickstart
? t('accountSetupCode') ? t(
: t('passwordResetCode') "accountSetupCode"
} )
: t(
"passwordResetCode"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -337,12 +372,17 @@ export default function ResetPasswordForm({
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> {env.email.emailEnabled && (
{quickstart <FormDescription>
? t('accountSetupCodeDescription') {quickstart
: t('passwordResetCodeDescription') ? t(
} "accountSetupCodeDescription"
</FormDescription> )
: t(
"passwordResetCodeDescription"
)}
</FormDescription>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -355,9 +395,8 @@ export default function ResetPasswordForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{quickstart {quickstart
? t('passwordCreate') ? t("passwordCreate")
: t('passwordNew') : t("passwordNew")}
}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -376,9 +415,12 @@ export default function ResetPasswordForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{quickstart {quickstart
? t('passwordCreateConfirm') ? t(
: t('passwordNewConfirm') "passwordCreateConfirm"
} )
: t(
"passwordNewConfirm"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -407,7 +449,7 @@ export default function ResetPasswordForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('pincodeAuth')} {t("pincodeAuth")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="flex justify-center"> <div className="flex justify-center">
@@ -475,26 +517,45 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
{state === "reset" {state === "reset"
? (quickstart ? t('completeSetup') : t('passwordReset')) ? quickstart
: t('pincodeSubmit2')} ? t("completeSetup")
: t("passwordReset")
: t("pincodeSubmit2")}
</Button> </Button>
)} )}
{state === "request" && ( {state === "request" && (
<Button <div className="flex flex-col gap-2">
type="submit" {env.email.emailEnabled && (
form="form" <Button
className="w-full" type="submit"
disabled={isSubmitting} form="form"
> className="w-full"
{isSubmitting && ( disabled={isSubmitting}
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> >
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{quickstart
? t("accountSetupSubmit")
: t("passwordResetSubmit")}
</Button>
)} )}
{quickstart <Button
? t('accountSetupSubmit') type="button"
: t('passwordResetSubmit') className="w-full"
} onClick={() => {
</Button> const email =
requestForm.getValues("email");
if (email) {
form.setValue("email", email);
}
setState("reset");
}}
>
{t("passwordResetAlreadyHaveCode")}
</Button>
</div>
)} )}
{state === "mfa" && ( {state === "mfa" && (
@@ -507,7 +568,7 @@ export default function ResetPasswordForm({
mfaForm.reset(); mfaForm.reset();
}} }}
> >
{t('passwordBack')} {t("passwordBack")}
</Button> </Button>
)} )}
@@ -521,7 +582,7 @@ export default function ResetPasswordForm({
form.reset(); form.reset();
}} }}
> >
{t('backToEmail')} {t("backToEmail")}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -71,20 +71,42 @@ function CollapsibleNavItem({
build, build,
isUnlocked isUnlocked
}: CollapsibleNavItemProps) { }: CollapsibleNavItemProps) {
const [isOpen, setIsOpen] = React.useState(isChildActive); const storageKey = `pangolin-sidebar-expanded-${item.title}`;
// Get initial state from localStorage or use isChildActive
const getInitialState = (): boolean => {
if (typeof window === "undefined") {
return isChildActive;
}
const saved = localStorage.getItem(storageKey);
if (saved !== null) {
return saved === "true";
}
return isChildActive;
};
// Update open state when child active state changes const [isOpen, setIsOpen] = React.useState(getInitialState);
// Update open state when child active state changes (but don't override user preference)
React.useEffect(() => { React.useEffect(() => {
if (isChildActive) { if (isChildActive) {
setIsOpen(true); setIsOpen(true);
} }
}, [isChildActive]); }, [isChildActive]);
// Save state to localStorage when it changes
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (typeof window !== "undefined") {
localStorage.setItem(storageKey, String(open));
}
};
return ( return (
<Collapsible <Collapsible
key={item.title} key={item.title}
open={isOpen} open={isOpen}
onOpenChange={setIsOpen} onOpenChange={handleOpenChange}
className="group/collapsible" className="group/collapsible"
> >
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>