From d32505a833253dc937781bde5d2bc67f9750fff9 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 26 Oct 2025 21:00:46 +0530 Subject: [PATCH] Option to regenerate Newt keys --- messages/en-US.json | 8 +- server/auth/actions.ts | 1 + server/routers/client/index.ts | 3 +- .../routers/client/reGenerateClientSecret.ts | 130 +++++++++++ server/routers/client/updateClient.ts | 26 +-- server/routers/external.ts | 15 ++ server/routers/site/index.ts | 1 + server/routers/site/reGenerateSiteSecret.ts | 106 +++++++++ .../clients/[clientId]/credentials/page.tsx | 12 +- .../settings/clients/[clientId]/layout.tsx | 7 +- .../sites/[niceId]/credentials/page.tsx | 212 ++++++++++++++++++ .../settings/sites/[niceId]/layout.tsx | 4 + src/components/ClientInfoCard.tsx | 7 +- 13 files changed, 491 insertions(+), 41 deletions(-) create mode 100644 server/routers/client/reGenerateClientSecret.ts create mode 100644 server/routers/site/reGenerateSiteSecret.ts create mode 100644 src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3f4083b1..a7d825f5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2099,8 +2099,12 @@ "credentials": "Credentials", "savecredentials": "Save Credentials", "regeneratecredentials": "Regenerate Credentials", - "regenerateClientCredentials": "Regenerate and save your managed credentials", + "regenerateCredentials": "Regenerate and save your credentials", "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", - "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now." + "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", + "credentialsSaved" : "Credentials Saved", + "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", + "credentialsSaveError": "Credentials Save Error", + "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials." } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index d08457e5..4608757b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -19,6 +19,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + reGenerateSecret = "reGenerateSecret", createResource = "createResource", deleteResource = "deleteResource", getResource = "getResource", diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 385c7bed..9f97446e 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -3,4 +3,5 @@ export * from "./createClient"; export * from "./deleteClient"; export * from "./listClients"; export * from "./updateClient"; -export * from "./getClient"; \ No newline at end of file +export * from "./getClient"; +export * from "./reGenerateClientSecret"; \ No newline at end of file diff --git a/server/routers/client/reGenerateClientSecret.ts b/server/routers/client/reGenerateClientSecret.ts new file mode 100644 index 00000000..2bce396a --- /dev/null +++ b/server/routers/client/reGenerateClientSecret.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, olms, } from "@server/db"; +import { clients } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { hashPassword } from "@server/auth/password"; + +const reGenerateSecretParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const reGenerateSecretBodySchema = z + .object({ + olmId: z.string().min(1).optional(), + secret: z.string().min(1).optional(), + + }) + .strict(); + +export type ReGenerateSecretBody = z.infer; + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/regenerate-secret", + description: "Regenerate a client's OLM credentials by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: reGenerateSecretParamsSchema, + body: { + content: { + "application/json": { + schema: reGenerateSecretBodySchema + } + } + } + }, + responses: {} +}); + + +export async function reGenerateClientSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = reGenerateSecretBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { olmId, secret } = parsedBody.data; + + const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + let secretHash = undefined; + if (secret) { + secretHash = await hashPassword(secret); + } + + + // Fetch the client to make sure it exists and the user has access to it + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + + if (existingOlm && olmId && secretHash) { + await db + .update(olms) + .set({ + olmId, + secretHash + }) + .where(eq(olms.clientId, clientId)); + } + + return response(res, { + data: existingOlm, + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 84e5f619..d458c4f8 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -32,9 +32,6 @@ const updateClientSchema = z siteIds: z .array(z.number().int().positive()) .optional(), - olmId: z.string().min(1).optional(), - secret: z.string().min(1).optional(), - }) .strict(); @@ -79,7 +76,7 @@ export async function updateClient( ); } - const { name, siteIds, olmId, secret } = parsedBody.data; + const { name, siteIds } = parsedBody.data; const parsedParams = updateClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -93,11 +90,6 @@ export async function updateClient( const { clientId } = parsedParams.data; - let secretHash = undefined; - if (secret) { - secretHash = await hashPassword(secret); - } - // Fetch the client to make sure it exists and the user has access to it const [client] = await db @@ -146,22 +138,6 @@ export async function updateClient( .where(eq(clients.clientId, clientId)); } - const [existingOlm] = await trx - .select() - .from(olms) - .where(eq(olms.clientId, clientId)) - .limit(1); - - if (existingOlm && olmId && secretHash) { - await trx - .update(olms) - .set({ - olmId, - secretHash - }) - .where(eq(olms.clientId, clientId)); - } - // Update site associations if provided // Remove sites that are no longer associated for (const siteId of sitesRemoved) { diff --git a/server/routers/external.ts b/server/routers/external.ts index 5c235902..c2c518fa 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -178,6 +178,14 @@ authenticated.post( client.updateClient, ); +authenticated.post( + "/client/:clientId/regenerate-secret", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + client.reGenerateClientSecret +); + // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -191,6 +199,13 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite, ); + +authenticated.post( + "/site/:siteId/regenerate-secret", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + site.reGenerateSiteSecret +); authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c1..9b8b89cb 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -6,3 +6,4 @@ export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; +export * from "./reGenerateSiteSecret"; \ No newline at end of file diff --git a/server/routers/site/reGenerateSiteSecret.ts b/server/routers/site/reGenerateSiteSecret.ts new file mode 100644 index 00000000..979212a4 --- /dev/null +++ b/server/routers/site/reGenerateSiteSecret.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { hashPassword } from "@server/auth/password"; + +const updateSiteParamsSchema = z + .object({ + siteId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const updateSiteBodySchema = z + .object({ + newtId: z.string().min(1).max(255).optional(), + newtSecret: z.string().min(1).max(255).optional(), + }) + .strict() + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/regenerate-secret", + description: + "Regenerate a site's Newt credentials by its site ID.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteBodySchema + } + } + } + }, + responses: {} +}); + +export async function reGenerateSiteSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateSiteBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + const { newtId, newtSecret } = parsedBody.data; + + const secretHash = await hashPassword(newtSecret!); + const updatedSite = await db + .update(newts) + .set({ + newtId, + secretHash + }) + .where(eq(newts.siteId, siteId)) + .returning(); + + if (updatedSite.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + return response(res, { + data: updatedSite[0], + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index 1de1e696..7d34b5eb 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -74,24 +74,24 @@ export default function CredentialsPage() { setLoading(true); try { - await api.post(`/client/${client?.clientId}`, { + await api.post(`/client/${client?.clientId}/regenerate-secret`, { olmId: clientDefaults?.olmId, secret: clientDefaults?.olmSecret, }); toast({ - title: t("clientUpdated"), - description: t("clientUpdatedDescription") + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", - title: t("clientUpdateFailed"), + title: t("credentialsSaveError"), description: formatAxiosError( e, - t("clientUpdateError") + t("credentialsSaveErrorDescription") ) }); } finally { @@ -107,7 +107,7 @@ export default function CredentialsPage() { {t("generatedcredentials")} - {t("regenerateClientCredentials")} + {t("regenerateCredentials")} diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index e597f90d..dc4ef0b4 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -7,6 +7,7 @@ import ClientInfoCard from "../../../../../components/ClientInfoCard"; import ClientProvider from "@app/providers/ClientProvider"; import { redirect } from "next/navigation"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; type SettingsLayoutProps = { children: React.ReactNode; @@ -30,13 +31,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect(`/${params.orgId}/settings/clients`); } + const t = await getTranslations(); + const navItems = [ { - title: "General", + title: t('general'), href: `/{orgId}/settings/clients/{clientId}/general` }, { - title: "Credentials", + title: t('credentials'), href: `/{orgId}/settings/clients/{clientId}/credentials` } ]; diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx new file mode 100644 index 00000000..14840b66 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { PickSiteDefaultsResponse } from "@server/routers/site"; +import { useSiteContext } from "@app/hooks/useSiteContext"; + +export default function CredentialsPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const [newtId, setNewtId] = useState(""); + const [newtSecret, setNewtSecret] = useState(""); + const { site, updateSite } = useSiteContext(); + + const [siteDefaults, setSiteDefaults] = + useState(null); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Clear credentials when user leaves/reloads + useEffect(() => { + const clearCreds = () => { + setNewtId(""); + setNewtSecret(""); + }; + window.addEventListener("beforeunload", clearCreds); + return () => window.removeEventListener("beforeunload", clearCreds); + }, []); + + const handleRegenerate = async () => { + try { + setLoading(true); + await api + .get(`/org/${orgId}/pick-site-defaults`) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setSiteDefaults(data); + + const newtId = data.newtId; + const newtSecret = data.newtSecret; + setNewtId(newtId); + setNewtSecret(newtSecret); + + } + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setLoading(true); + + try { + await api.post(`/site/${site?.siteId}/regenerate-secret`, { + newtId: siteDefaults?.newtId, + newtSecret: siteDefaults?.newtSecret, + }); + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("credentialsSaveError"), + description: formatAxiosError( + e, + t("credentialsSaveErrorDescription") + ) + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateCredentials")} + + + + + {!siteDefaults ? ( + + ) : ( + <> + + + + {t("siteNewtCredentials")} + + + {t( + "siteNewtCredentialsDescription" + )} + + + + + + + {t("newtEndpoint")} + + + + + + + + {t("newtId")} + + + + + + + + {t("newtSecretKey")} + + + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t( + "copyandsavethesecredentialsdescription" + )} + + + + + +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 039deebb..abd9aefb 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -36,6 +36,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: t('general'), href: "/{orgId}/settings/sites/{niceId}/general" + }, + { + title: t('credentials'), + href: "/{orgId}/settings/sites/{niceId}/credentials" } ]; diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index ec8ecacf..f8d96158 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -1,7 +1,6 @@ "use client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { useClientContext } from "@app/hooks/useClientContext"; import { InfoSection, @@ -19,9 +18,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - - {t("clientInformation")} - + <>