diff --git a/README.md b/README.md index 1d21b8c8..982ae140 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ _Pangolin tunnels your services to the internet so you can access anything from [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) [![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) -[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) +[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) diff --git a/blueprint.py b/blueprint.py index b4f1a99c..93d21da5 100644 --- a/blueprint.py +++ b/blueprint.py @@ -8,10 +8,10 @@ import base64 YAML_FILE_PATH = 'blueprint.yaml' # The API endpoint and headers from the curl request -API_URL = 'http://localhost:3004/v1/org/test/blueprint' +API_URL = 'http://api.pangolin.fossorial.io/v1/org/test/blueprint' HEADERS = { 'accept': '*/*', - 'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr', + 'Authorization': 'Bearer ', 'Content-Type': 'application/json' } diff --git a/package-lock.json b/package-lock.json index 931e3178..098fea0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", - "@hookform/resolvers": "4.1.3", + "@hookform/resolvers": "5.2.2", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", @@ -2232,15 +2232,14 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", - "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==", - "license": "MIT", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { - "react-hook-form": "^7.0.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@humanfs/core": { diff --git a/package.json b/package.json index f2370e52..342e28a7 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", - "@hookform/resolvers": "4.1.3", + "@hookform/resolvers": "5.2.2", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6244fefa..c6ab6f40 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -138,12 +138,8 @@ export async function updateProxyResources( ? true : resourceData.ssl; let headers = ""; - for (const header of resourceData.headers || []) { - headers += `${header.name}: ${header.value},`; - } - // if there are headers, remove the trailing comma - if (headers.endsWith(",")) { - headers = headers.slice(0, -1); + if (resourceData.headers) { + headers = JSON.stringify(resourceData.headers); } if (existingResource) { @@ -169,7 +165,7 @@ export async function updateProxyResources( .update(resources) .set({ name: resourceData.name || "Unnamed Resource", - protocol: protocol || "http", + protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, @@ -461,7 +457,7 @@ export async function updateProxyResources( orgId, niceId: resourceNiceId, name: resourceData.name || "Unnamed Resource", - protocol: resourceData.protocol || "http", + protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 506c1c8d..6c13963a 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.10.1"; +export const APP_VERSION = "1.10.2"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/routers/idp/oidcAutoProvision.ts b/server/routers/idp/oidcAutoProvision.ts index 34cfcc8a..3571b59d 100644 --- a/server/routers/idp/oidcAutoProvision.ts +++ b/server/routers/idp/oidcAutoProvision.ts @@ -158,8 +158,13 @@ export async function oidcAutoProvision({ .from(userOrgs) .where(eq(userOrgs.userId, userId)); + // Filter to only auto-provisioned orgs for CRUD operations + const autoProvisionedOrgs = currentUserOrgs.filter( + (org) => org.autoProvisioned === true + ); + // Delete orgs that are no longer valid - const orgsToDelete = currentUserOrgs + const orgsToDelete = autoProvisionedOrgs .filter( (currentOrg) => !userOrgInfo.some( @@ -195,7 +200,9 @@ export async function oidcAutoProvision({ orgsToAdd.map((org) => ({ userId: userId!, orgId: org.orgId, - roleId: org.roleId + roleId: org.roleId, + autoProvisioned: true, + dateCreated: new Date().toISOString() })) ); } diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 91a0ac3f..803c3e27 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -15,7 +15,7 @@ export async function addTargets( }:${target.port}`; }); - sendToClient(newtId, { + await sendToClient(newtId, { type: `newt/${protocol}/add`, data: { targets: payloadTargets diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 806a5a58..a4311423 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -319,26 +319,6 @@ async function createRawResource( const { name, http, protocol, proxyPort } = parsedBody.data; - // if http is false check to see if there is already a resource with the same port and protocol - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); - - if (existingResource.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index d2aebedd..0fdcdd0c 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -42,7 +42,9 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) { } } -export type GetResourceResponse = NonNullable>>; +export type GetResourceResponse = Omit>>, 'headers'> & { + headers: { name: string; value: string }[] | null; +}; registry.registerPath({ method: "get", @@ -99,7 +101,10 @@ export async function getResource( } return response(res, { - data: resource, + data: { + ...resource, + headers: resource.headers ? JSON.parse(resource.headers) : resource.headers + }, success: true, error: false, message: "Resource retrieved successfully", diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 7c0f9c63..e70b9496 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -47,7 +47,7 @@ const updateHttpResourceBodySchema = z tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.number().int().positive().nullable().optional(), - headers: z.string().nullable().optional() + headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -85,18 +85,6 @@ const updateHttpResourceBodySchema = z message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } - ) - .refine( - (data) => { - if (data.headers) { - return validateHeaders(data.headers); - } - return true; - }, - { - message: - "Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons." - } ); export type UpdateResourceResponse = Resource; @@ -247,7 +235,7 @@ async function updateHttpResource( // Validate domain and construct full domain const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain); - + if (!domainResult.success) { return next( createHttpError( @@ -292,9 +280,14 @@ async function updateHttpResource( updateData.subdomain = finalSubdomain; } + let headers = null; + if (updateData.headers) { + headers = JSON.stringify(updateData.headers); + } + const updatedResource = await db .update(resources) - .set({ ...updateData }) + .set({ ...updateData, headers }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); @@ -342,31 +335,6 @@ async function updateRawResource( const updateData = parsedBody.data; - if (updateData.proxyPort) { - const proxyPort = updateData.proxyPort; - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, resource.protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); - - if ( - existingResource.length > 0 && - existingResource[0].resourceId !== resource.resourceId - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - } - const updatedResource = await db .update(resources) .set(updateData) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index a1a2a7a3..5101de84 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -306,17 +306,25 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - if (resource.headers && resource.headers.length > 0) { + if (resource.headers || resource.setHostHeader) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; - const headersArr = resource.headers.split(","); - for (const header of headersArr) { - const [key, value] = header - .split(":") - .map((s: string) => s.trim()); - if (key && value) { - headersObj[key] = value; + if (resource.headers) { + let headersArr: { name: string; value: string }[] = []; + try { + headersArr = JSON.parse(resource.headers) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse headers for resource ${resource.resourceId}: ${e}` + ); } + + headersArr.forEach((header) => { + headersObj[header.name] = header.value; + }); } if (resource.setHostHeader) { diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c5950e1d..04779f30 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -10,6 +10,7 @@ import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; import m4 from "./scriptsPg/1.9.0"; import m5 from "./scriptsPg/1.10.0"; +import m6 from "./scriptsPg/1.10.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -21,6 +22,7 @@ const migrations = [ { version: "1.8.0", run: m3 }, { version: "1.9.0", run: m4 }, { version: "1.10.0", run: m5 }, + { version: "1.10.2", run: m6 }, // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index b8fa64f0..654c2716 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -28,6 +28,7 @@ import m23 from "./scriptsSqlite/1.8.0"; import m24 from "./scriptsSqlite/1.9.0"; import m25 from "./scriptsSqlite/1.10.0"; import m26 from "./scriptsSqlite/1.10.1"; +import m27 from "./scriptsSqlite/1.10.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -55,6 +56,7 @@ const migrations = [ { version: "1.9.0", run: m24 }, { version: "1.10.0", run: m25 }, { version: "1.10.1", run: m26 }, + { version: "1.10.2", run: m27 }, // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.10.2.ts b/server/setup/scriptsPg/1.10.2.ts new file mode 100644 index 00000000..e59901a5 --- /dev/null +++ b/server/setup/scriptsPg/1.10.2.ts @@ -0,0 +1,47 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; + +const version = "1.10.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + const resources = await db.execute(sql` + SELECT * FROM "resources" + `); + + await db.execute(sql`BEGIN`); + + for (const resource of resources.rows) { + const headers = resource.headers as string | null; + if (headers && headers !== "") { + // lets convert it to json + // fist split at commas + const headersArray = headers + .split(",") + .map((header: string) => { + const [name, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return { name: name.trim(), value }; + }); + + await db.execute(sql` + UPDATE "resources" SET "headers" = ${JSON.stringify(headersArray)} WHERE "resourceId" = ${resource.resourceId} + `); + + console.log( + `Updated resource ${resource.resourceId} headers to JSON format` + ); + } + } + + await db.execute(sql`COMMIT`); + console.log(`Migrated database`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Failed to migrate db:", e); + throw e; + } +} diff --git a/server/setup/scriptsSqlite/1.10.2.ts b/server/setup/scriptsSqlite/1.10.2.ts new file mode 100644 index 00000000..7978e262 --- /dev/null +++ b/server/setup/scriptsSqlite/1.10.2.ts @@ -0,0 +1,54 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.10.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + const resources = db.prepare("SELECT * FROM resources").all() as Array<{ + resourceId: number; + headers: string | null; + }>; + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + for (const resource of resources) { + const headers = resource.headers; + if (headers && headers !== "") { + // lets convert it to json + // fist split at commas + const headersArray = headers + .split(",") + .map((header: string) => { + const [name, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return { name: name.trim(), value }; + }); + + db.prepare( + ` + UPDATE "resources" SET "headers" = ? WHERE "resourceId" = ?` + ).run(JSON.stringify(headersArray), resource.resourceId); + + console.log( + `Updated resource ${resource.resourceId} headers to JSON format` + ); + } + } + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } +} diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index fdce89eb..c92f2638 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -68,7 +68,7 @@ export default function AccessControlsPage() { autoProvisioned: z.boolean() }); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { username: user.username!, diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 47bff1e1..2df8413f 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -161,7 +161,7 @@ export default function Page() { { hours: 168, name: t("day", { count: 7 }) } ]; - const internalForm = useForm>({ + const internalForm = useForm({ resolver: zodResolver(internalFormSchema), defaultValues: { email: "", @@ -170,7 +170,7 @@ export default function Page() { } }); - const googleAzureForm = useForm>({ + const googleAzureForm = useForm({ resolver: zodResolver(googleAzureFormSchema), defaultValues: { email: "", @@ -179,7 +179,7 @@ export default function Page() { } }); - const genericOidcForm = useForm>({ + const genericOidcForm = useForm({ resolver: zodResolver(genericOidcFormSchema), defaultValues: { username: "", diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx index 57822a26..b62c2628 100644 --- a/src/app/[orgId]/settings/api-keys/create/page.tsx +++ b/src/app/[orgId]/settings/api-keys/create/page.tsx @@ -91,14 +91,14 @@ export default function Page() { type CopiedFormValues = z.infer; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { name: "" } }); - const copiedForm = useForm({ + const copiedForm = useForm({ resolver: zodResolver(copiedFormSchema), defaultValues: { copied: true diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx index 27d708a4..55d7c0d3 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx @@ -58,7 +58,7 @@ export default function GeneralPage() { const [clientSites, setClientSites] = useState([]); const [activeSitesTagIndex, setActiveSitesTagIndex] = useState(null); - const form = useForm({ + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: client?.name, diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx index ac2a1c66..8155a2d6 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -265,7 +265,7 @@ export default function Page() { } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createClientFormSchema), defaultValues: { name: "", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 0eba0a3d..c4bb3ccc 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -59,7 +59,7 @@ export default function GeneralPage() { const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); - const form = useForm({ + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: org?.org.name, diff --git a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx index 4705550e..d53cb0c0 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/authentication/page.tsx @@ -138,12 +138,12 @@ export default function ResourceAuthenticationPage() { const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const usersRolesForm = useForm>({ + const usersRolesForm = useForm({ resolver: zodResolver(UsersRolesFormSchema), defaultValues: { roles: [], users: [] } }); - const whitelistForm = useForm>({ + const whitelistForm = useForm({ resolver: zodResolver(whitelistSchema), defaultValues: { emails: [] } }); diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 0f201a1a..21d601ed 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -119,7 +119,7 @@ export default function GeneralForm() { type GeneralFormValues = z.infer; - const form = useForm({ + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { enabled: resource.enabled, diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index ba71a765..4c2eedf5 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -227,7 +227,7 @@ export default function ReverseProxyTargets(props: { message: t("proxyErrorInvalidHeader") } ), - headers: z.string().optional() + headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable() }); const tlsSettingsSchema = z.object({ @@ -260,7 +260,7 @@ export default function ReverseProxyTargets(props: { port: "" as any as number, path: null, pathMatchType: null - } as z.infer + } }); const watchedIp = addTargetForm.watch("ip"); @@ -274,7 +274,7 @@ export default function ReverseProxyTargets(props: { } }; - const tlsSettingsForm = useForm({ + const tlsSettingsForm = useForm({ resolver: zodResolver(tlsSettingsSchema), defaultValues: { ssl: resource.ssl, @@ -282,15 +282,15 @@ export default function ReverseProxyTargets(props: { } }); - const proxySettingsForm = useForm({ + const proxySettingsForm = useForm({ resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers || "" + headers: resource.headers } }); - const targetsSettingsForm = useForm({ + const targetsSettingsForm = useForm({ resolver: zodResolver(targetsSettingsSchema), defaultValues: { stickySession: resource.stickySession @@ -1479,7 +1479,7 @@ export default function ReverseProxyTargets(props: { { field.onChange( diff --git a/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx index 8b5e4709..284573b2 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx @@ -114,7 +114,7 @@ export default function ResourceRules(props: { CIDR: t('ipAddressRange') } as const; - const addRuleForm = useForm>({ + const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 71628ce7..f551e418 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -211,7 +211,7 @@ export default function Page() { ]) ]; - const baseForm = useForm({ + const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), defaultValues: { name: "", @@ -219,12 +219,12 @@ export default function Page() { } }); - const httpForm = useForm({ + const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), defaultValues: {} }); - const tcpUdpForm = useForm({ + const tcpUdpForm = useForm({ resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", @@ -241,7 +241,7 @@ export default function Page() { port: "" as any as number, path: null, pathMatchType: null - } as z.infer + } }); const watchedIp = addTargetForm.watch("ip"); diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 8bd8dc4b..432d4bd3 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -64,7 +64,7 @@ export default function GeneralPage() { const router = useRouter(); const t = useTranslations(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 2e5d4e45..ad5438f7 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -425,7 +425,7 @@ WantedBy=default.target` } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createSiteFormSchema), defaultValues: { name: "", diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx index b5a61306..65f8e46a 100644 --- a/src/app/admin/api-keys/create/page.tsx +++ b/src/app/admin/api-keys/create/page.tsx @@ -89,14 +89,14 @@ export default function Page() { type CopiedFormValues = z.infer; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { name: "" } }); - const copiedForm = useForm({ + const copiedForm = useForm({ resolver: zodResolver(copiedFormSchema), defaultValues: { copied: true diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index 8aa4f084..1eca54e7 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -71,7 +71,7 @@ export default function GeneralPage() { type GeneralFormValues = z.infer; - const form = useForm({ + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: "", diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 01b186bf..8c895b8b 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -102,7 +102,7 @@ export default function PoliciesPage() { type PolicyFormValues = z.infer; type DefaultMappingsValues = z.infer; - const form = useForm({ + const form = useForm({ resolver: zodResolver(policyFormSchema), defaultValues: { orgId: "", @@ -111,7 +111,7 @@ export default function PoliciesPage() { } }); - const defaultMappingsForm = useForm({ + const defaultMappingsForm = useForm({ resolver: zodResolver(defaultMappingsSchema), defaultValues: { defaultRoleMapping: "", diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 3a645d27..27c26ed6 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -76,7 +76,7 @@ export default function Page() { } ]; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { name: "", diff --git a/src/app/auth/initial-setup/page.tsx b/src/app/auth/initial-setup/page.tsx index e1dd3f06..4a443896 100644 --- a/src/app/auth/initial-setup/page.tsx +++ b/src/app/auth/initial-setup/page.tsx @@ -51,7 +51,7 @@ export default function InitialSetupPage() { const [error, setError] = useState(null); const [checking, setChecking] = useState(true); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { setupToken: "", diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 3d456bd9..14199493 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -102,7 +102,7 @@ export default function ResetPasswordForm({ code: z.string().length(6, { message: t('pincodeInvalid') }) }); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { email: emailParam || "", @@ -112,14 +112,14 @@ export default function ResetPasswordForm({ } }); - const mfaForm = useForm>({ + const mfaForm = useForm({ resolver: zodResolver(mfaSchema), defaultValues: { code: "" } }); - const requestForm = useForm>({ + const requestForm = useForm({ resolver: zodResolver(requestSchema), defaultValues: { email: emailParam || "" diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 42c64b16..65ffc786 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -50,7 +50,7 @@ export default function StepperForm() { subnet: z.string().min(1, { message: t("subnetRequired") }) }); - const orgForm = useForm>({ + const orgForm = useForm({ resolver: zodResolver(orgSchema), defaultValues: { orgName: "", diff --git a/src/components/HeadersInput.tsx b/src/components/HeadersInput.tsx index 35f3e587..4a93ddc8 100644 --- a/src/components/HeadersInput.tsx +++ b/src/components/HeadersInput.tsx @@ -1,66 +1,118 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { Textarea } from "@/components/ui/textarea"; + interface HeadersInputProps { - value?: string; - onChange: (value: string) => void; + value?: { name: string, value: string }[] | null; + onChange: (value: { name: string, value: string }[] | null) => void; placeholder?: string; rows?: number; className?: string; } -export function HeadersInput({ - value = "", - onChange, +export function HeadersInput({ + value = [], + onChange, placeholder = `X-Example-Header: example-value X-Another-Header: another-value`, rows = 4, className }: HeadersInputProps) { const [internalValue, setInternalValue] = useState(""); + const textareaRef = useRef(null); + const isUserEditingRef = useRef(false); - // Convert comma-separated to newline-separated for display - const convertToNewlineSeparated = (commaSeparated: string): string => { - if (!commaSeparated || commaSeparated.trim() === "") return ""; - - return commaSeparated - .split(',') - .map(header => header.trim()) - .filter(header => header.length > 0) + // Convert header objects array to newline-separated string for display + const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => { + if (!headers || headers.length === 0) return ""; + + return headers + .map(header => `${header.name}: ${header.value}`) .join('\n'); }; - // Convert newline-separated to comma-separated for output - const convertToCommaSeparated = (newlineSeparated: string): string => { - if (!newlineSeparated || newlineSeparated.trim() === "") return ""; - + // Convert newline-separated string to header objects array + const convertToHeadersArray = (newlineSeparated: string): { name: string, value: string }[] | null => { + if (!newlineSeparated || newlineSeparated.trim() === "") return []; + return newlineSeparated .split('\n') - .map(header => header.trim()) - .filter(header => header.length > 0) - .join(', '); + .map(line => line.trim()) + .filter(line => line.length > 0 && line.includes(':')) + .map(line => { + const colonIndex = line.indexOf(':'); + const name = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + + // Ensure header name conforms to HTTP header requirements + // Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens + const normalizedName = name.replace(/[^a-zA-Z0-9\-]/g, '').toLowerCase(); + + return { name: normalizedName, value }; + }) + .filter(header => header.name.length > 0); // Filter out headers with invalid names }; // Update internal value when external value changes + // But only if the user is not currently editing (textarea not focused) useEffect(() => { - setInternalValue(convertToNewlineSeparated(value)); + if (!isUserEditingRef.current) { + setInternalValue(convertToNewlineSeparated(value)); + } }, [value]); const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setInternalValue(newValue); - - // Convert back to comma-separated format for the parent - const commaSeparatedValue = convertToCommaSeparated(newValue); - onChange(commaSeparatedValue); + + // Mark that user is actively editing + isUserEditingRef.current = true; + + // Only update parent if the input is in a valid state + // Valid states: empty/whitespace only, or contains properly formatted headers + + if (newValue.trim() === "") { + // Empty input is valid - represents no headers + onChange([]); + } else { + // Check if all non-empty lines are properly formatted (contain ':') + const lines = newValue.split('\n'); + const nonEmptyLines = lines + .map(line => line.trim()) + .filter(line => line.length > 0); + + // If there are no non-empty lines, or all non-empty lines contain ':', it's valid + const isValid = nonEmptyLines.length === 0 || nonEmptyLines.every(line => line.includes(':')); + + if (isValid) { + // Safe to convert and update parent + const headersArray = convertToHeadersArray(newValue); + onChange(headersArray); + } + // If not valid, don't call onChange - let user continue typing + } + }; + + const handleFocus = () => { + isUserEditingRef.current = true; + }; + + const handleBlur = () => { + // Small delay to allow any final change events to process + setTimeout(() => { + isUserEditingRef.current = false; + }, 100); }; return (