Compare commits

..

No commits in common. "19e7f6613564fae6bdd61d00d7f79b58fc5163d9" and "cbe1e4decb637d2e8f8193464afae535666cf0f5" have entirely different histories.

48 changed files with 184 additions and 300 deletions

View file

@ -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) [![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) ![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) [![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)
</div> </div>

View file

@ -8,10 +8,10 @@ import base64
YAML_FILE_PATH = 'blueprint.yaml' YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request # The API endpoint and headers from the curl request
API_URL = 'http://api.pangolin.fossorial.io/v1/org/test/blueprint' API_URL = 'http://localhost:3004/v1/org/test/blueprint'
HEADERS = { HEADERS = {
'accept': '*/*', 'accept': '*/*',
'Authorization': 'Bearer <your_token_here>', 'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }

11
package-lock.json generated
View file

@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE AND README.md", "license": "SEE LICENSE IN LICENSE AND README.md",
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4", "@asteasolutions/zod-to-openapi": "^7.3.4",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "4.1.3",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
@ -2232,14 +2232,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "5.2.2", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/utils": "^0.3.0" "@standard-schema/utils": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react-hook-form": "^7.55.0" "react-hook-form": "^7.0.0"
} }
}, },
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {

View file

@ -27,7 +27,7 @@
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4", "@asteasolutions/zod-to-openapi": "^7.3.4",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "4.1.3",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",

View file

@ -138,8 +138,12 @@ export async function updateProxyResources(
? true ? true
: resourceData.ssl; : resourceData.ssl;
let headers = ""; let headers = "";
if (resourceData.headers) { for (const header of resourceData.headers || []) {
headers = JSON.stringify(resourceData.headers); headers += `${header.name}: ${header.value},`;
}
// if there are headers, remove the trailing comma
if (headers.endsWith(",")) {
headers = headers.slice(0, -1);
} }
if (existingResource) { if (existingResource) {
@ -165,7 +169,7 @@ export async function updateProxyResources(
.update(resources) .update(resources)
.set({ .set({
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
protocol: protocol || "tcp", protocol: protocol || "http",
http: http, http: http,
proxyPort: http ? null : resourceData["proxy-port"], proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http ? resourceData["full-domain"] : null, fullDomain: http ? resourceData["full-domain"] : null,
@ -457,7 +461,7 @@ export async function updateProxyResources(
orgId, orgId,
niceId: resourceNiceId, niceId: resourceNiceId,
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
protocol: protocol || "tcp", protocol: resourceData.protocol || "http",
http: http, http: http,
proxyPort: http ? null : resourceData["proxy-port"], proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http ? resourceData["full-domain"] : null, fullDomain: http ? resourceData["full-domain"] : null,

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.10.2"; export const APP_VERSION = "1.10.1";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -158,13 +158,8 @@ export async function oidcAutoProvision({
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .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 // Delete orgs that are no longer valid
const orgsToDelete = autoProvisionedOrgs const orgsToDelete = currentUserOrgs
.filter( .filter(
(currentOrg) => (currentOrg) =>
!userOrgInfo.some( !userOrgInfo.some(
@ -200,9 +195,7 @@ export async function oidcAutoProvision({
orgsToAdd.map((org) => ({ orgsToAdd.map((org) => ({
userId: userId!, userId: userId!,
orgId: org.orgId, orgId: org.orgId,
roleId: org.roleId, roleId: org.roleId
autoProvisioned: true,
dateCreated: new Date().toISOString()
})) }))
); );
} }

View file

@ -15,7 +15,7 @@ export async function addTargets(
}:${target.port}`; }:${target.port}`;
}); });
await sendToClient(newtId, { sendToClient(newtId, {
type: `newt/${protocol}/add`, type: `newt/${protocol}/add`,
data: { data: {
targets: payloadTargets targets: payloadTargets

View file

@ -319,6 +319,26 @@ async function createRawResource(
const { name, http, protocol, proxyPort } = parsedBody.data; 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; let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId); const niceId = await getUniqueResourceName(orgId);

View file

@ -42,9 +42,7 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) {
} }
} }
export type GetResourceResponse = Omit<NonNullable<Awaited<ReturnType<typeof query>>>, 'headers'> & { export type GetResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
headers: { name: string; value: string }[] | null;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@ -101,10 +99,7 @@ export async function getResource(
} }
return response<GetResourceResponse>(res, { return response<GetResourceResponse>(res, {
data: { data: resource,
...resource,
headers: resource.headers ? JSON.parse(resource.headers) : resource.headers
},
success: true, success: true,
error: false, error: false,
message: "Resource retrieved successfully", message: "Resource retrieved successfully",

View file

@ -47,7 +47,7 @@ const updateHttpResourceBodySchema = z
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(),
skipToIdpId: z.number().int().positive().nullable().optional(), skipToIdpId: z.number().int().positive().nullable().optional(),
headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), headers: z.string().nullable().optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@ -85,6 +85,18 @@ const updateHttpResourceBodySchema = z
message: message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." "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; export type UpdateResourceResponse = Resource;
@ -280,14 +292,9 @@ async function updateHttpResource(
updateData.subdomain = finalSubdomain; updateData.subdomain = finalSubdomain;
} }
let headers = null;
if (updateData.headers) {
headers = JSON.stringify(updateData.headers);
}
const updatedResource = await db const updatedResource = await db
.update(resources) .update(resources)
.set({ ...updateData, headers }) .set({ ...updateData })
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))
.returning(); .returning();
@ -335,6 +342,31 @@ async function updateRawResource(
const updateData = parsedBody.data; 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 const updatedResource = await db
.update(resources) .update(resources)
.set(updateData) .set(updateData)

View file

@ -306,25 +306,17 @@ export async function getTraefikConfig(
...additionalMiddlewares ...additionalMiddlewares
]; ];
if (resource.headers || resource.setHostHeader) { if (resource.headers && resource.headers.length > 0) {
// if there are headers, parse them into an object // if there are headers, parse them into an object
const headersObj: { [key: string]: string } = {}; const headersObj: { [key: string]: string } = {};
if (resource.headers) { const headersArr = resource.headers.split(",");
let headersArr: { name: string; value: string }[] = []; for (const header of headersArr) {
try { const [key, value] = header
headersArr = JSON.parse(resource.headers) as { .split(":")
name: string; .map((s: string) => s.trim());
value: string; if (key && value) {
}[]; headersObj[key] = value;
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
} }
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
} }
if (resource.setHostHeader) { if (resource.setHostHeader) {

View file

@ -10,7 +10,6 @@ import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0"; import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0"; import m4 from "./scriptsPg/1.9.0";
import m5 from "./scriptsPg/1.10.0"; import m5 from "./scriptsPg/1.10.0";
import m6 from "./scriptsPg/1.10.2";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -22,7 +21,6 @@ const migrations = [
{ version: "1.8.0", run: m3 }, { version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 }, { version: "1.9.0", run: m4 },
{ version: "1.10.0", run: m5 }, { version: "1.10.0", run: m5 },
{ version: "1.10.2", run: m6 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;

View file

@ -28,7 +28,6 @@ import m23 from "./scriptsSqlite/1.8.0";
import m24 from "./scriptsSqlite/1.9.0"; import m24 from "./scriptsSqlite/1.9.0";
import m25 from "./scriptsSqlite/1.10.0"; import m25 from "./scriptsSqlite/1.10.0";
import m26 from "./scriptsSqlite/1.10.1"; import m26 from "./scriptsSqlite/1.10.1";
import m27 from "./scriptsSqlite/1.10.2";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -56,7 +55,6 @@ const migrations = [
{ version: "1.9.0", run: m24 }, { version: "1.9.0", run: m24 },
{ version: "1.10.0", run: m25 }, { version: "1.10.0", run: m25 },
{ version: "1.10.1", run: m26 }, { version: "1.10.1", run: m26 },
{ version: "1.10.2", run: m27 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -1,47 +0,0 @@
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;
}
}

View file

@ -1,54 +0,0 @@
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;
}
}

View file

@ -68,7 +68,7 @@ export default function AccessControlsPage() {
autoProvisioned: z.boolean() autoProvisioned: z.boolean()
}); });
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,

View file

@ -161,7 +161,7 @@ export default function Page() {
{ hours: 168, name: t("day", { count: 7 }) } { hours: 168, name: t("day", { count: 7 }) }
]; ];
const internalForm = useForm({ const internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(internalFormSchema), resolver: zodResolver(internalFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
@ -170,7 +170,7 @@ export default function Page() {
} }
}); });
const googleAzureForm = useForm({ const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
resolver: zodResolver(googleAzureFormSchema), resolver: zodResolver(googleAzureFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
@ -179,7 +179,7 @@ export default function Page() {
} }
}); });
const genericOidcForm = useForm({ const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
resolver: zodResolver(genericOidcFormSchema), resolver: zodResolver(genericOidcFormSchema),
defaultValues: { defaultValues: {
username: "", username: "",

View file

@ -91,14 +91,14 @@ export default function Page() {
type CopiedFormValues = z.infer<typeof copiedFormSchema>; type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({ const form = useForm<CreateFormValues>({
resolver: zodResolver(createFormSchema), resolver: zodResolver(createFormSchema),
defaultValues: { defaultValues: {
name: "" name: ""
} }
}); });
const copiedForm = useForm({ const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema), resolver: zodResolver(copiedFormSchema),
defaultValues: { defaultValues: {
copied: true copied: true

View file

@ -58,7 +58,7 @@ export default function GeneralPage() {
const [clientSites, setClientSites] = useState<Tag[]>([]); const [clientSites, setClientSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<number | null>(null); const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<number | null>(null);
const form = useForm({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: client?.name, name: client?.name,

View file

@ -265,7 +265,7 @@ export default function Page() {
} }
}; };
const form = useForm({ const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema), resolver: zodResolver(createClientFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",

View file

@ -59,7 +59,7 @@ export default function GeneralPage() {
const [loadingDelete, setLoadingDelete] = useState(false); const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false); const [loadingSave, setLoadingSave] = useState(false);
const form = useForm({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: org?.org.name, name: org?.org.name,

View file

@ -138,12 +138,12 @@ export default function ResourceAuthenticationPage() {
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
const usersRolesForm = useForm({ const usersRolesForm = useForm<z.infer<typeof UsersRolesFormSchema>>({
resolver: zodResolver(UsersRolesFormSchema), resolver: zodResolver(UsersRolesFormSchema),
defaultValues: { roles: [], users: [] } defaultValues: { roles: [], users: [] }
}); });
const whitelistForm = useForm({ const whitelistForm = useForm<z.infer<typeof whitelistSchema>>({
resolver: zodResolver(whitelistSchema), resolver: zodResolver(whitelistSchema),
defaultValues: { emails: [] } defaultValues: { emails: [] }
}); });

View file

@ -119,7 +119,7 @@ export default function GeneralForm() {
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const form = useForm({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
enabled: resource.enabled, enabled: resource.enabled,

View file

@ -227,7 +227,7 @@ export default function ReverseProxyTargets(props: {
message: t("proxyErrorInvalidHeader") message: t("proxyErrorInvalidHeader")
} }
), ),
headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable() headers: z.string().optional()
}); });
const tlsSettingsSchema = z.object({ const tlsSettingsSchema = z.object({
@ -260,7 +260,7 @@ export default function ReverseProxyTargets(props: {
port: "" as any as number, port: "" as any as number,
path: null, path: null,
pathMatchType: null pathMatchType: null
} } as z.infer<typeof addTargetSchema>
}); });
const watchedIp = addTargetForm.watch("ip"); const watchedIp = addTargetForm.watch("ip");
@ -274,7 +274,7 @@ export default function ReverseProxyTargets(props: {
} }
}; };
const tlsSettingsForm = useForm({ const tlsSettingsForm = useForm<TlsSettingsValues>({
resolver: zodResolver(tlsSettingsSchema), resolver: zodResolver(tlsSettingsSchema),
defaultValues: { defaultValues: {
ssl: resource.ssl, ssl: resource.ssl,
@ -282,15 +282,15 @@ export default function ReverseProxyTargets(props: {
} }
}); });
const proxySettingsForm = useForm({ const proxySettingsForm = useForm<ProxySettingsValues>({
resolver: zodResolver(proxySettingsSchema), resolver: zodResolver(proxySettingsSchema),
defaultValues: { defaultValues: {
setHostHeader: resource.setHostHeader || "", setHostHeader: resource.setHostHeader || "",
headers: resource.headers headers: resource.headers || ""
} }
}); });
const targetsSettingsForm = useForm({ const targetsSettingsForm = useForm<TargetsSettingsValues>({
resolver: zodResolver(targetsSettingsSchema), resolver: zodResolver(targetsSettingsSchema),
defaultValues: { defaultValues: {
stickySession: resource.stickySession stickySession: resource.stickySession
@ -1479,7 +1479,7 @@ export default function ReverseProxyTargets(props: {
<FormControl> <FormControl>
<HeadersInput <HeadersInput
value={ value={
field.value field.value || ""
} }
onChange={(value) => { onChange={(value) => {
field.onChange( field.onChange(

View file

@ -114,7 +114,7 @@ export default function ResourceRules(props: {
CIDR: t('ipAddressRange') CIDR: t('ipAddressRange')
} as const; } as const;
const addRuleForm = useForm({ const addRuleForm = useForm<z.infer<typeof addRuleSchema>>({
resolver: zodResolver(addRuleSchema), resolver: zodResolver(addRuleSchema),
defaultValues: { defaultValues: {
action: "ACCEPT", action: "ACCEPT",

View file

@ -211,7 +211,7 @@ export default function Page() {
]) ])
]; ];
const baseForm = useForm({ const baseForm = useForm<BaseResourceFormValues>({
resolver: zodResolver(baseResourceFormSchema), resolver: zodResolver(baseResourceFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",
@ -219,12 +219,12 @@ export default function Page() {
} }
}); });
const httpForm = useForm({ const httpForm = useForm<HttpResourceFormValues>({
resolver: zodResolver(httpResourceFormSchema), resolver: zodResolver(httpResourceFormSchema),
defaultValues: {} defaultValues: {}
}); });
const tcpUdpForm = useForm({ const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
resolver: zodResolver(tcpUdpResourceFormSchema), resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: { defaultValues: {
protocol: "tcp", protocol: "tcp",
@ -241,7 +241,7 @@ export default function Page() {
port: "" as any as number, port: "" as any as number,
path: null, path: null,
pathMatchType: null pathMatchType: null
} } as z.infer<typeof addTargetSchema>
}); });
const watchedIp = addTargetForm.watch("ip"); const watchedIp = addTargetForm.watch("ip");

View file

@ -64,7 +64,7 @@ export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const form = useForm({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: site?.name, name: site?.name,

View file

@ -425,7 +425,7 @@ WantedBy=default.target`
} }
}; };
const form = useForm({ const form = useForm<CreateSiteFormValues>({
resolver: zodResolver(createSiteFormSchema), resolver: zodResolver(createSiteFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",

View file

@ -89,14 +89,14 @@ export default function Page() {
type CopiedFormValues = z.infer<typeof copiedFormSchema>; type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({ const form = useForm<CreateFormValues>({
resolver: zodResolver(createFormSchema), resolver: zodResolver(createFormSchema),
defaultValues: { defaultValues: {
name: "" name: ""
} }
}); });
const copiedForm = useForm({ const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema), resolver: zodResolver(copiedFormSchema),
defaultValues: { defaultValues: {
copied: true copied: true

View file

@ -71,7 +71,7 @@ export default function GeneralPage() {
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const form = useForm({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",

View file

@ -102,7 +102,7 @@ export default function PoliciesPage() {
type PolicyFormValues = z.infer<typeof policyFormSchema>; type PolicyFormValues = z.infer<typeof policyFormSchema>;
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>; type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
const form = useForm({ const form = useForm<PolicyFormValues>({
resolver: zodResolver(policyFormSchema), resolver: zodResolver(policyFormSchema),
defaultValues: { defaultValues: {
orgId: "", orgId: "",
@ -111,7 +111,7 @@ export default function PoliciesPage() {
} }
}); });
const defaultMappingsForm = useForm({ const defaultMappingsForm = useForm<DefaultMappingsValues>({
resolver: zodResolver(defaultMappingsSchema), resolver: zodResolver(defaultMappingsSchema),
defaultValues: { defaultValues: {
defaultRoleMapping: "", defaultRoleMapping: "",

View file

@ -76,7 +76,7 @@ export default function Page() {
} }
]; ];
const form = useForm({ const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema), resolver: zodResolver(createIdpFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",

View file

@ -51,7 +51,7 @@ export default function InitialSetupPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
setupToken: "", setupToken: "",

View file

@ -102,7 +102,7 @@ export default function ResetPasswordForm({
code: z.string().length(6, { message: t('pincodeInvalid') }) code: z.string().length(6, { message: t('pincodeInvalid') })
}); });
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: emailParam || "", email: emailParam || "",
@ -112,14 +112,14 @@ export default function ResetPasswordForm({
} }
}); });
const mfaForm = useForm({ const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema), resolver: zodResolver(mfaSchema),
defaultValues: { defaultValues: {
code: "" code: ""
} }
}); });
const requestForm = useForm({ const requestForm = useForm<z.infer<typeof requestSchema>>({
resolver: zodResolver(requestSchema), resolver: zodResolver(requestSchema),
defaultValues: { defaultValues: {
email: emailParam || "" email: emailParam || ""

View file

@ -50,7 +50,7 @@ export default function StepperForm() {
subnet: z.string().min(1, { message: t("subnetRequired") }) subnet: z.string().min(1, { message: t("subnetRequired") })
}); });
const orgForm = useForm({ const orgForm = useForm<z.infer<typeof orgSchema>>({
resolver: zodResolver(orgSchema), resolver: zodResolver(orgSchema),
defaultValues: { defaultValues: {
orgName: "", orgName: "",

View file

@ -1,19 +1,18 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
interface HeadersInputProps { interface HeadersInputProps {
value?: { name: string, value: string }[] | null; value?: string;
onChange: (value: { name: string, value: string }[] | null) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
export function HeadersInput({ export function HeadersInput({
value = [], value = "",
onChange, onChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
@ -21,98 +20,47 @@ X-Another-Header: another-value`,
className className
}: HeadersInputProps) { }: HeadersInputProps) {
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false);
// Convert header objects array to newline-separated string for display // Convert comma-separated to newline-separated for display
const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => { const convertToNewlineSeparated = (commaSeparated: string): string => {
if (!headers || headers.length === 0) return ""; if (!commaSeparated || commaSeparated.trim() === "") return "";
return headers return commaSeparated
.map(header => `${header.name}: ${header.value}`) .split(',')
.map(header => header.trim())
.filter(header => header.length > 0)
.join('\n'); .join('\n');
}; };
// Convert newline-separated string to header objects array // Convert newline-separated to comma-separated for output
const convertToHeadersArray = (newlineSeparated: string): { name: string, value: string }[] | null => { const convertToCommaSeparated = (newlineSeparated: string): string => {
if (!newlineSeparated || newlineSeparated.trim() === "") return []; if (!newlineSeparated || newlineSeparated.trim() === "") return "";
return newlineSeparated return newlineSeparated
.split('\n') .split('\n')
.map(line => line.trim()) .map(header => header.trim())
.filter(line => line.length > 0 && line.includes(':')) .filter(header => header.length > 0)
.map(line => { .join(', ');
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 // Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused)
useEffect(() => { useEffect(() => {
if (!isUserEditingRef.current) { setInternalValue(convertToNewlineSeparated(value));
setInternalValue(convertToNewlineSeparated(value));
}
}, [value]); }, [value]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
setInternalValue(newValue); setInternalValue(newValue);
// Mark that user is actively editing // Convert back to comma-separated format for the parent
isUserEditingRef.current = true; const commaSeparatedValue = convertToCommaSeparated(newValue);
onChange(commaSeparatedValue);
// 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 ( return (
<Textarea <Textarea
ref={textareaRef}
value={internalValue} value={internalValue}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
className={className} className={className}

View file

@ -84,7 +84,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
} }
]; ];
const form = useForm({ const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema), resolver: zodResolver(createIdpFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",

View file

@ -80,7 +80,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
code: z.string().length(6, { message: t("pincodeInvalid") }) code: z.string().length(6, { message: t("pincodeInvalid") })
}); });
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: "", email: "",
@ -88,7 +88,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
} }
}); });
const mfaForm = useForm({ const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema), resolver: zodResolver(mfaSchema),
defaultValues: { defaultValues: {
code: "" code: ""

View file

@ -6,7 +6,7 @@ import {
ArrowUpDown, ArrowUpDown,
MoreHorizontal, MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import { PolicyDataTable } from "@app/components/PolicyDataTable"; import { PolicyDataTable } from "./PolicyDataTable";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View file

@ -102,7 +102,7 @@ export default function ResetPasswordForm({
code: z.string().length(6, { message: t('pincodeInvalid') }) code: z.string().length(6, { message: t('pincodeInvalid') })
}); });
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: emailParam || "", email: emailParam || "",
@ -112,14 +112,14 @@ export default function ResetPasswordForm({
} }
}); });
const mfaForm = useForm({ const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema), resolver: zodResolver(mfaSchema),
defaultValues: { defaultValues: {
code: "" code: ""
} }
}); });
const requestForm = useForm({ const requestForm = useForm<z.infer<typeof requestSchema>>({
resolver: zodResolver(requestSchema), resolver: zodResolver(requestSchema),
defaultValues: { defaultValues: {
email: emailParam || "" email: emailParam || ""

View file

@ -132,28 +132,28 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod()); const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod());
const pinForm = useForm({ const pinForm = useForm<z.infer<typeof pinSchema>>({
resolver: zodResolver(pinSchema), resolver: zodResolver(pinSchema),
defaultValues: { defaultValues: {
pin: "" pin: ""
} }
}); });
const passwordForm = useForm({ const passwordForm = useForm<z.infer<typeof passwordSchema>>({
resolver: zodResolver(passwordSchema), resolver: zodResolver(passwordSchema),
defaultValues: { defaultValues: {
password: "" password: ""
} }
}); });
const requestOtpForm = useForm({ const requestOtpForm = useForm<z.infer<typeof requestOtpSchema>>({
resolver: zodResolver(requestOtpSchema), resolver: zodResolver(requestOtpSchema),
defaultValues: { defaultValues: {
email: "" email: ""
} }
}); });
const submitOtpForm = useForm({ const submitOtpForm = useForm<z.infer<typeof submitOtpSchema>>({
resolver: zodResolver(submitOtpSchema), resolver: zodResolver(submitOtpSchema),
defaultValues: { defaultValues: {
email: "", email: "",

View file

@ -119,7 +119,7 @@ export default function SecurityKeyForm({
code: z.string().optional() code: z.string().optional()
}); });
const registerForm = useForm({ const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema), resolver: zodResolver(registerSchema),
defaultValues: { defaultValues: {
name: "", name: "",
@ -128,7 +128,7 @@ export default function SecurityKeyForm({
} }
}); });
const deleteForm = useForm({ const deleteForm = useForm<DeleteFormValues>({
resolver: zodResolver(deleteSchema), resolver: zodResolver(deleteSchema),
defaultValues: { defaultValues: {
password: "", password: "",

View file

@ -39,6 +39,10 @@ const setPasswordFormSchema = z.object({
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>; type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: ""
};
type SetPasswordFormProps = { type SetPasswordFormProps = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
@ -57,11 +61,9 @@ export default function SetResourcePasswordForm({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm({ const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema), resolver: zodResolver(setPasswordFormSchema),
defaultValues: { defaultValues
password: ""
}
}); });
useEffect(() => { useEffect(() => {

View file

@ -44,6 +44,10 @@ const setPincodeFormSchema = z.object({
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>; type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: ""
};
type SetPincodeFormProps = { type SetPincodeFormProps = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
@ -61,11 +65,9 @@ export default function SetResourcePincodeForm({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const form = useForm({ const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema), resolver: zodResolver(setPincodeFormSchema),
defaultValues: { defaultValues
pincode: ""
}
}); });
const t = useTranslations(); const t = useTranslations();

View file

@ -117,7 +117,7 @@ export default function SignupForm({
const [passwordValue, setPasswordValue] = useState(""); const [passwordValue, setPasswordValue] = useState("");
const [confirmPasswordValue, setConfirmPasswordValue] = useState(""); const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
const form = useForm({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: emailParam || "", email: emailParam || "",

View file

@ -91,14 +91,14 @@ const TwoFactorSetupForm = forwardRef<
code: z.string().length(6, { message: t("pincodeInvalid") }) code: z.string().length(6, { message: t("pincodeInvalid") })
}); });
const enableForm = useForm({ const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema), resolver: zodResolver(enableSchema),
defaultValues: { defaultValues: {
password: initialPassword || "" password: initialPassword || ""
} }
}); });
const confirmForm = useForm({ const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema), resolver: zodResolver(confirmSchema),
defaultValues: { defaultValues: {
code: "" code: ""

View file

@ -80,7 +80,7 @@ export default function VerifyEmailForm({
}) })
}); });
const form = useForm({ const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema), resolver: zodResolver(FormSchema),
defaultValues: { defaultValues: {
email: email, email: email,