mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-22 21:06:37 +00:00
Merge branch 'dev' into refactor/show-product-updates-conditionnally
This commit is contained in:
@@ -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
7743
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
server/routers/user/adminGeneratePasswordResetCode.ts
Normal file
125
server/routers/user/adminGeneratePasswordResetCode.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user