Compare commits

...

31 Commits

Author SHA1 Message Date
Lokowitz
873408270e removed unused gomod code 2026-01-28 15:06:23 -08:00
Lokowitz
8fec8f35bc removed unused code 2026-01-28 15:06:23 -08:00
Owen
141c846fe2 Properly insert PANGOLIN_SETUP_TOKEN into db
Fixes #2361
2026-01-28 15:04:17 -08:00
Lokowitz
1497469016 revert format:write 2026-01-28 14:50:42 -08:00
Lokowitz
e356a6d33b fix lable error and make dockerfile readable 2026-01-28 14:50:42 -08:00
miloschwartz
12aea2901d fix depreated zod warning 2026-01-26 14:11:03 -08:00
miloschwartz
5ff56467ea error response improvements to logo url 2026-01-26 14:00:22 -08:00
miloschwartz
3a8718a4b0 remove archive confirmtion on account devices dialog 2026-01-26 13:36:25 -08:00
Owen
37c4a7b690 Retry verify 2026-01-24 11:55:32 -08:00
Owen
b735e7c34d Fix #2314 2026-01-24 11:47:17 -08:00
Owen
5f85c3b3b8 Remove extra rebuild command 2026-01-24 11:35:45 -08:00
miloschwartz
5d9cb9fa21 fix clear olmId from client on archive 2026-01-24 11:11:25 -08:00
miloschwartz
643d56958d fix saas private import 2026-01-23 10:07:05 -08:00
miloschwartz
f378d6f040 fix input border 2026-01-22 21:24:28 -08:00
Milo Schwartz
bb57794388 Merge pull request #2306 from Fredkiss3/fix/tab-from-host-port
fix: tab between host & port in resource target address column
2026-01-22 21:14:20 -08:00
miloschwartz
a9ca49b8a2 Merge branch 'main' into dev 2026-01-22 21:10:40 -08:00
Fred KISSIE
c1b473294e 🔥 remove useless useEffect 2026-01-23 04:54:24 +01:00
Fred KISSIE
e3e4bdfe09 🚸 fix target item tabbing by memoizing the getColumns (and its dependencies) 2026-01-23 04:40:19 +01:00
miloschwartz
bfbeace2e2 fix import in list approvals 2026-01-22 17:54:53 -08:00
miloschwartz
efcf46ce8a fix policy check on olm register 2026-01-22 16:28:15 -08:00
miloschwartz
2085715965 fix wrong redirect url when idp login with custom auth domain 2026-01-22 15:46:48 -08:00
Owen
5f19918ca0 Show the source in the UI 2026-01-22 15:16:41 -08:00
Owen
2959ad0e70 Fix the source of the cli blueprint 2026-01-22 15:03:04 -08:00
miloschwartz
a76eec7bb7 add ios and android to readme 2026-01-22 11:27:24 -08:00
miloschwartz
068b2a0dcd clean up paid features check 2026-01-22 11:16:27 -08:00
Owen
316b7e5653 Hiring 2026-01-22 10:38:32 -08:00
miloschwartz
00fc1da33c dont include posture in repsonse if not licensed or subscribed 2026-01-22 10:36:52 -08:00
miloschwartz
9ef93df54f add mobile links to download banner 2026-01-21 18:16:16 -08:00
miloschwartz
fd9fdf6399 remove biometric support from ios 2026-01-21 18:13:12 -08:00
miloschwartz
8fa1701e06 rename windowsDefenderEnabled 2026-01-21 17:57:20 -08:00
Owen
4abe83f8a9 Dont show bio info on android 2026-01-21 16:36:35 -08:00
23 changed files with 683 additions and 3160 deletions

View File

@@ -44,19 +44,9 @@ updates:
schedule: schedule:
interval: "daily" interval: "daily"
groups: groups:
dev-patch-updates: patch-updates:
dependency-type: "development"
update-types: update-types:
- "patch" - "patch"
dev-minor-updates: minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types: update-types:
- "minor" - "minor"

View File

@@ -482,14 +482,77 @@ jobs:
echo "==> cosign sign (key) --recursive ${REF}" echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}" echo "==> cosign verify (public key) ${REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}" echo "==> cosign verify (keyless policy) ${REF}"
cosign verify \ if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
--certificate-oidc-issuer "${issuer}" \ VERIFIED_INDEX_KEYLESS=true
--certificate-identity-regexp "${id_regex}" \ else
"${REF}" -o text VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 10
fi
fi
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
done done

View File

@@ -1,21 +1,11 @@
FROM node:24-alpine AS builder FROM node:24-alpine AS builder
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
WORKDIR /app WORKDIR /app
ARG BUILD=oss ARG BUILD=oss
ARG DATABASE=sqlite ARG DATABASE=sqlite
# Derive title and description based on BUILD type RUN apk add --no-cache python3 make g++
ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
RUN apk add --no-cache curl tzdata python3 make g++
# COPY package.json package-lock.json ./ # COPY package.json package-lock.json ./
COPY package*.json ./ COPY package*.json ./
@@ -23,41 +13,31 @@ RUN npm ci
COPY . . COPY . .
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts npm run set:$DATABASE && \
npm run set:$BUILD && \
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts npm run db:$DATABASE:generate && \
npm run build:$DATABASE && \
# Copy the appropriate TypeScript configuration based on build type npm run build:cli
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
fi
# if the build is oss then remove the server/private directory
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
RUN mkdir -p dist
RUN npm run next:build
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
RUN if [ "$DATABASE" = "pg" ]; then \
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
else \
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
fi
# test to make sure the build output is there and error if not # test to make sure the build output is there and error if not
RUN test -f dist/server.mjs RUN test -f dist/server.mjs
RUN npm run build:cli
# Prune dev dependencies and clean up to prepare for copy to runner # Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
# Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
WORKDIR /app WORKDIR /app
# Only curl and tzdata needed at runtime - no build tools! # Only curl and tzdata needed at runtime - no build tools!
@@ -66,11 +46,10 @@ RUN apk add --no-cache curl tzdata
# Copy pre-built node_modules from builder (already pruned to production only) # Copy pre-built node_modules from builder (already pruned to production only)
# This includes the compiled native modules like better-sqlite3 # This includes the compiled native modules like better-sqlite3
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init COPY --from=builder /app/server/migrations ./dist/init
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl COPY ./cli/wrapper.sh /usr/local/bin/pangctl

2194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@
"license": "SEE LICENSE IN LICENSE AND README.md", "license": "SEE LICENSE IN LICENSE AND README.md",
"scripts": { "scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts",
@@ -24,12 +26,13 @@
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", "set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts", "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts", "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
"next:build": "next build", "build:next": "next build",
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3005", "email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
"format:check": "prettier --check .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
@@ -75,9 +78,7 @@
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
"cookie": "1.1.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cookies": "0.9.1",
"cors": "2.8.5", "cors": "2.8.5",
"crypto-js": "4.2.0", "crypto-js": "4.2.0",
"d3": "7.9.0", "d3": "7.9.0",
@@ -90,7 +91,6 @@
"glob": "13.0.0", "glob": "13.0.0",
"helmet": "8.1.0", "helmet": "8.1.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"i": "0.3.7",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.9.2", "ioredis": "5.9.2",
"jmespath": "0.16.0", "jmespath": "0.16.0",
@@ -104,10 +104,7 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "7.0.11", "nodemailer": "7.0.11",
"npm": "11.7.0",
"nprogress": "0.2.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.17.1", "pg": "8.17.1",
"posthog-node": "5.23.0", "posthog-node": "5.23.0",
@@ -118,7 +115,6 @@
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.71.1", "react-hook-form": "7.71.1",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"rebuild": "0.1.2",
"recharts": "2.15.4", "recharts": "2.15.4",
"reodotdev": "1.0.0", "reodotdev": "1.0.0",
"resend": "6.8.0", "resend": "6.8.0",

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { import {
approvals, approvals,

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import { build } from "@server/build"; import { build } from "@server/build";
import { approvals, clients, db, orgs, type Approval } from "@server/db"; import { approvals, clients, db, orgs, type Approval } from "@server/db";
import { getOrgTierData } from "@server/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { and, eq, type InferInsertModel } from "drizzle-orm"; import { and, eq, type InferInsertModel } from "drizzle-orm";

View File

@@ -37,27 +37,55 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
logoUrl: z logoUrl: z
.union([ .union([
z.string().length(0), z.literal(""),
z.url().refine( z
async (url) => { .url("Must be a valid URL")
.superRefine(async (url, ctx) => {
try { try {
const response = await fetch(url); const response = await fetch(url, {
return ( method: "HEAD"
response.status === 200 && }).catch(() => {
( // If HEAD fails (CORS or method not allowed), try GET
response.headers.get("content-type") ?? "" return fetch(url, { method: "GET" });
).startsWith("image/") });
);
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) { } catch (error) {
return false; let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
} }
}, })
{
error: "Invalid logo URL, must be a valid image URL"
}
)
]) ])
.optional(), .transform((val) => (val === "" ? null : val))
.nullish(),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(), resourceTitle: z.string(),
@@ -78,7 +106,7 @@ export async function upsertLoginPageBranding(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedBody = bodySchema.safeParse(req.body); const parsedBody = await bodySchema.safeParseAsync(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
return next( return next(
createHttpError( createHttpError(
@@ -117,9 +145,8 @@ export async function upsertLoginPageBranding(
typeof loginPageBranding typeof loginPageBranding
>; >;
if ((updateData.logoUrl ?? "").trim().length === 0) { // Empty strings are transformed to null by the schema, which will clear the logo URL in the database
updateData.logoUrl = undefined; // We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
}
if ( if (
build !== "saas" && build !== "saas" &&

View File

@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({ const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -77,9 +74,6 @@ export async function archiveClient(
.update(clients) .update(clients)
.set({ archived: true }) .set({ archived: true })
.where(eq(clients.clientId, clientId)); .where(eq(clients.clientId, clientId));
// Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx);
}); });
return response(res, { return response(res, {

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { olms, clients } from "@server/db"; import { olms } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
const { olmId } = parsedParams.data; const { olmId } = parsedParams.data;
// Archive the OLM and disconnect associated clients in a transaction
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Find all clients associated with this OLM
const associatedClients = await trx
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
// Disconnect clients from the OLM (set olmId to null)
for (const client of associatedClients) {
await trx
.update(clients)
.set({ olmId: null })
.where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
}
// Archive the OLM (set archived to true)
await trx await trx
.update(olms) .update(olms)
.set({ archived: true }) .set({ archived: true })

View File

@@ -149,7 +149,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
if (!policyCheck.policies?.passwordAge?.compliant === false) { if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}` `Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
); );
@@ -159,7 +159,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
); );
return; return;
} else if ( } else if (
!policyCheck.policies?.maxSessionLength?.compliant === false policyCheck.policies?.maxSessionLength?.compliant === false
) { ) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}` `Olm user ${olm.userId} has non-compliant session length for org ${orgId}`

View File

@@ -64,16 +64,20 @@ export async function ensureSetupToken() {
); );
} }
if (existingToken?.token !== envSetupToken) { if (existingToken) {
console.warn( // Token exists in DB - update it if different
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set" if (existingToken.token !== envSetupToken) {
); console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db await db
.update(setupTokens) .update(setupTokens)
.set({ token: envSetupToken }) .set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId)); .where(eq(setupTokens.tokenId, existingToken.tokenId));
}
} else { } else {
// No existing token - insert new one
const tokenId = generateId(15); const tokenId = generateId(15);
await db.insert(setupTokens).values({ await db.insert(setupTokens).values({

View File

@@ -19,17 +19,6 @@ export interface ApprovalFeedPageProps {
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
const params = await props.params; const params = await props.params;
let approvals: ApprovalItem[] = [];
const res = await internal
.get<
AxiosResponse<{ approvals: ApprovalItem[] }>
>(`/org/${params.orgId}/approvals`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
approvals = res.data.data.approvals;
}
let org: GetOrgResponse | null = null; let org: GetOrgResponse | null = null;
const orgRes = await getCachedOrg(params.orgId); const orgRes = await getCachedOrg(params.orgId);

View File

@@ -11,7 +11,6 @@ import {
SelectValue SelectValue
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { HeadersInput } from "@app/components/HeadersInput"; import { HeadersInput } from "@app/components/HeadersInput";
import { import {
PathMatchDisplay, PathMatchDisplay,
@@ -19,6 +18,7 @@ import {
PathRewriteDisplay, PathRewriteDisplay,
PathRewriteModal PathRewriteModal
} from "@app/components/PathMatchRenameModal"; } from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@@ -30,15 +30,6 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Badge } from "@app/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { import {
Form, Form,
FormControl, FormControl,
@@ -48,11 +39,6 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { import {
Table, Table,
TableBody, TableBody,
@@ -73,12 +59,9 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { cn } from "@app/lib/cn";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { orgQueries, resourceQueries } from "@app/lib/queries"; import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { tlsNameSchema } from "@server/lib/schemas"; import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource"; import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
@@ -98,7 +81,6 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
AlertTriangle, AlertTriangle,
CheckIcon,
CircleCheck, CircleCheck,
CircleX, CircleX,
Info, Info,
@@ -107,7 +89,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react"; import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -202,7 +184,7 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = async (siteId: number) => { const refreshContainersForSite = useCallback(async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
@@ -214,9 +196,9 @@ function ProxyResourceTargetsForm({
} }
return newMap; return newMap;
}); });
}; }, [api]);
const getDockerStateForSite = (siteId: number): DockerState => { const getDockerStateForSite = useCallback((siteId: number): DockerState => {
return ( return (
dockerStates.get(siteId) || { dockerStates.get(siteId) || {
isEnabled: false, isEnabled: false,
@@ -224,7 +206,7 @@ function ProxyResourceTargetsForm({
containers: [] containers: []
} }
); );
}; }, [dockerStates]);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -234,8 +216,40 @@ function ProxyResourceTargetsForm({
return false; return false;
}); });
const getColumns = (): ColumnDef<LocalTarget>[] => { const isHttp = resource.http;
const isHttp = resource.http;
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]);
}
return prevTargets.filter((target) => target.targetId !== targetId);
});
}, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
}, [sites]);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
}, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
@@ -419,213 +433,15 @@ function ProxyResourceTargetsForm({
accessorKey: "address", accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>, header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return ( return (
<div className="flex items-center w-full"> <ResourceTargetAddressItem
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md"> isHttp={isHttp}
{selectedSite && sites={sites}
selectedSite.type === "newt" && getDockerStateForSite={getDockerStateForSite}
(() => { proxyTarget={row.original}
const dockerState = getDockerStateForSite( refreshContainersForSite={refreshContainersForSite}
selectedSite.siteId updateTarget={updateTarget}
); />
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{resource.http && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{resource.http && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
); );
}, },
size: 400, size: 400,
@@ -765,7 +581,7 @@ function ProxyResourceTargetsForm({
actionsColumn actionsColumn
]; ];
} }
}; }, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http; const isHttp = resource.http;
@@ -806,32 +622,6 @@ function ProxyResourceTargetsForm({
setTargets((prev) => [...prev, newTarget]); setTargets((prev) => [...prev, newTarget]);
} }
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
function updateTargetHealthCheck(targetId: number, config: any) { function updateTargetHealthCheck(targetId: number, config: any) {
setTargets( setTargets(
targets.map((target) => targets.map((target) =>
@@ -846,14 +636,6 @@ function ProxyResourceTargetsForm({
); );
} }
const openHealthCheckDialog = (target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
const columns = getColumns();
const table = useReactTable({ const table = useReactTable({
data: targets, data: targets,
columns, columns,

View File

@@ -1,5 +1,14 @@
"use client"; "use client";
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@@ -9,6 +18,10 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@@ -18,22 +31,7 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { StrategySelect } from "@app/components/StrategySelect";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -41,48 +39,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { ListDomainsResponse } from "@server/routers/domain"; import { Switch } from "@app/components/ui/switch";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
import { ContainersSelector } from "@app/components/ContainersSelector";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender,
Row
} from "@tanstack/react-table";
import { import {
Table, Table,
TableBody, TableBody,
@@ -91,30 +48,49 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from "@app/components/ui/table"; } from "@app/components/ui/table";
import { Switch } from "@app/components/ui/switch";
import { ArrayElement } from "@server/types/ArrayElement";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "@app/components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import { import {
PathMatchDisplay, ColumnDef,
PathMatchModal, flexRender,
PathRewriteDisplay, getCoreRowModel,
PathRewriteModal getFilteredRowModel,
} from "@app/components/PathMatchRenameModal"; getPaginationRowModel,
import { Badge } from "@app/components/ui/badge"; getSortedRowModel,
import HealthCheckDialog from "@app/components/HealthCheckDialog"; useReactTable
import { SwitchInput } from "@app/components/SwitchInput"; } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
CircleCheck,
CircleX,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { toASCII } from "punycode";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@@ -204,10 +180,6 @@ const addTargetSchema = z
} }
); );
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
type ResourceType = "http" | "raw"; type ResourceType = "http" | "raw";
interface ResourceTypeOption { interface ResourceTypeOption {
@@ -217,7 +189,7 @@ interface ResourceTypeOption {
disabled?: boolean; disabled?: boolean;
} }
type LocalTarget = Omit< export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & { ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean; new?: boolean;
updated?: boolean; updated?: boolean;
@@ -233,18 +205,16 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true); const { data: sites = [], isLoading: loadingPage } = useQuery(
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); orgQueries.sites({ orgId: orgId as string })
const [baseDomains, setBaseDomains] = useState< );
{ domainId: string; baseDomain: string }[]
>([]);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>(""); const [niceId, setNiceId] = useState<string>("");
// Target management state // Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>( const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map() new Map()
); );
@@ -405,102 +375,60 @@ export default function Page() {
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = async (siteId: number) => { const refreshContainersForSite = useCallback(
const dockerManager = new DockerManager(api, siteId); async (siteId: number) => {
const containers = await dockerManager.fetchContainers(); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
if (existingState) { if (existingState) {
newMap.set(siteId, { ...existingState, containers }); newMap.set(siteId, { ...existingState, containers });
} }
return newMap; return newMap;
});
},
[api]
);
const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
},
[dockerStates]
);
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
return prevTargets.filter((target) => target.targetId !== targetId);
}); });
}; }, []);
const getDockerStateForSite = (siteId: number): DockerState => { const updateTarget = useCallback(
return ( (targetId: number, data: Partial<LocalTarget>) => {
dockerStates.get(siteId) || { setTargets((prevTargets) => {
isEnabled: false, const site = sites.find((site) => site.siteId === data.siteId);
isAvailable: false, return prevTargets.map((target) =>
containers: [] target.targetId === targetId
} ? {
); ...target,
}; ...data,
updated: true,
async function addTarget(data: z.infer<typeof addTargetSchema>) { siteType: site ? site.type : target.siteType
const site = sites.find((site) => site.siteId === data.siteId); }
: target
const isHttp = baseForm.watch("http"); );
});
const newTarget: LocalTarget = { },
...data, [sites]
path: isHttp ? data.path || null : null, );
pathMatchType: isHttp ? data.pathMatchType || null : null,
rewritePath: isHttp ? data.rewritePath || null : null,
rewritePathType: isHttp ? data.rewritePathType || null : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: isHttp ? data.priority || 100 : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null
};
setTargets([...targets, newTarget]);
addTargetForm.reset({
ip: "",
method: baseForm.watch("http") ? "http" : null,
port: "" as any as number,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: isHttp ? 100 : undefined
});
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
async function onSubmit() { async function onSubmit() {
setCreateLoading(true); setCreateLoading(true);
@@ -638,82 +566,18 @@ export default function Page() {
} }
useEffect(() => { useEffect(() => {
const load = async () => { // Initialize Docker for newt sites
setLoadingPage(true); for (const site of sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
const fetchSites = async () => { // If there's at least one site, set it as the default in the form
const res = await api if (sites.length > 0) {
.get< addTargetForm.setValue("siteId", sites[0].siteId);
AxiosResponse<ListSitesResponse> }
>(`/org/${orgId}/sites/`) }, [sites]);
.catch((e) => {
toast({
variant: "destructive",
title: t("sitesErrorFetch"),
description: formatAxiosError(
e,
t("sitesErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setSites(res.data.data.sites);
// Initialize Docker for newt sites
for (const site of res.data.data.sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
// If there's only one site, set it as the default in the form
if (res.data.data.sites.length) {
addTargetForm.setValue(
"siteId",
res.data.data.sites[0].siteId
);
}
}
};
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("domainsErrorFetch"),
description: formatAxiosError(
e,
t("domainsErrorFetchDescription")
)
});
});
if (res?.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
// if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId);
// }
}
};
await fetchSites();
await fetchDomains();
setLoadingPage(false);
};
load();
}, []);
function TargetHealthCheck(targetId: number, config: any) { function TargetHealthCheck(targetId: number, config: any) {
setTargets( setTargets(
@@ -729,16 +593,15 @@ export default function Page() {
); );
} }
const openHealthCheckDialog = (target: LocalTarget) => { const openHealthCheckDialog = useCallback((target: LocalTarget) => {
console.log(target); console.log(target);
setSelectedTargetForHealthCheck(target); setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true); setHealthCheckDialogOpen(true);
}; }, []);
const getColumns = (): ColumnDef<LocalTarget>[] => { const isHttp = baseForm.watch("http");
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
header: () => ( header: () => (
@@ -875,7 +738,7 @@ export default function Page() {
trigger={ trigger={
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]" className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-50"
> >
<PathMatchDisplay <PathMatchDisplay
value={{ value={{
@@ -899,7 +762,7 @@ export default function Page() {
trigger={ trigger={
<Button <Button
variant="outline" variant="outline"
className="w-full max-w-[200px]" className="w-full max-w-50"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t("matchPath")} {t("matchPath")}
@@ -918,216 +781,16 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = { const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address", accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>, header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => { cell: ({ row }) => (
const selectedSite = sites.find( <ResourceTargetAddressItem
(site) => site.siteId === row.original.siteId isHttp={isHttp}
); sites={sites}
getDockerStateForSite={getDockerStateForSite}
const handleContainerSelectForTarget = ( proxyTarget={row.original}
hostname: string, refreshContainersForSite={refreshContainersForSite}
port?: number updateTarget={updateTarget}
) => { />
updateTarget(row.original.targetId, { ),
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
);
},
size: 400, size: 400,
minSize: 350, minSize: 350,
maxSize: 500 maxSize: 500
@@ -1186,7 +849,7 @@ export default function Page() {
<Button <Button
variant="outline" variant="outline"
disabled={noPathMatch} disabled={noPathMatch}
className="w-full max-w-[200px]" className="w-full max-w-50"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t("rewritePath")} {t("rewritePath")}
@@ -1265,9 +928,17 @@ export default function Page() {
actionsColumn actionsColumn
]; ];
} }
}; }, [
isAdvancedMode,
const columns = getColumns(); isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
const table = useReactTable({ const table = useReactTable({
data: targets, data: targets,
@@ -1649,9 +1320,6 @@ export default function Page() {
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table> </Table>
</div> </div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

@@ -43,25 +43,52 @@ export type AuthPageCustomizationProps = {
const AuthPageFormSchema = z.object({ const AuthPageFormSchema = z.object({
logoUrl: z.union([ logoUrl: z.union([
z.string().length(0), z.literal(""),
z.url().refine( z.url("Must be a valid URL").superRefine(async (url, ctx) => {
async (url) => { try {
try { const response = await fetch(url, {
const response = await fetch(url); method: "HEAD"
return ( }).catch(() => {
response.status === 200 && // If HEAD fails (CORS or method not allowed), try GET
(response.headers.get("content-type") ?? "").startsWith( return fetch(url, { method: "GET" });
"image/" });
)
); if (response.status !== 200) {
} catch (error) { ctx.addIssue({
return false; code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
} }
},
{ const contentType = response.headers.get("content-type") ?? "";
error: "Invalid logo URL, must be a valid image URL" if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
} }
) })
]), ]),
logoWidth: z.coerce.number<number>().min(1), logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1), logoHeight: z.coerce.number<number>().min(1),
@@ -405,9 +432,7 @@ export default function AuthPageBrandingForm({
<Button <Button
variant="destructive" variant="destructive"
type="submit" type="submit"
loading={ loading={isDeletingBranding}
isUpdatingBranding || isDeletingBranding
}
disabled={ disabled={
isUpdatingBranding || isUpdatingBranding ||
isDeletingBranding || isDeletingBranding ||
@@ -422,7 +447,7 @@ export default function AuthPageBrandingForm({
<Button <Button
type="submit" type="submit"
form="auth-page-branding-form" form="auth-page-branding-form"
loading={isUpdatingBranding || isDeletingBranding} loading={isUpdatingBranding}
disabled={ disabled={
isUpdatingBranding || isUpdatingBranding ||
isDeletingBranding || isDeletingBranding ||

View File

@@ -94,12 +94,6 @@ export default function DomainPicker({
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
console.log({
defaultFullDomain,
defaultSubdomain,
defaultDomainId
});
const { data = [], isLoading: loadingDomains } = useQuery( const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId }) orgQueries.domains({ orgId })
); );
@@ -369,9 +363,6 @@ export default function DomainPicker({
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
} }
console.log({
setSelectedBaseDomain: option
});
setSelectedBaseDomain(option); setSelectedBaseDomain(option);
setOpen(false); setOpen(false);
@@ -442,9 +433,6 @@ export default function DomainPicker({
0, 0,
providedDomainsShown providedDomainsShown
); );
console.log({
displayedProvidedOptions
});
const selectedDomainNamespaceId = const selectedDomainNamespaceId =
selectedProvidedDomain?.domainNamespaceId ?? selectedProvidedDomain?.domainNamespaceId ??

View File

@@ -143,7 +143,6 @@ export default function LoginOrgSelector({
<IdpLoginButtons <IdpLoginButtons
idps={idps} idps={idps}
redirect={redirect} redirect={redirect}
orgId={org.orgId}
/> />
</div> </div>
</div> </div>

View File

@@ -1,41 +1,13 @@
"use client"; "use client";
import * as React from "react";
import * as NProgress from "nprogress";
import NextTopLoader from "nextjs-toploader"; import NextTopLoader from "nextjs-toploader";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() { export function TopLoader() {
return ( return (
<> <NextTopLoader
<NextTopLoader showSpinner={false} color="var(--color-primary)" /> color="var(--color-primary)"
<FinishingLoader /> showSpinner={false}
</> height={2}
); />
} );
function FinishingLoader() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
React.useEffect(() => {
NProgress.done();
}, [pathname, router, searchParams]);
React.useEffect(() => {
const linkClickListener = (ev: MouseEvent) => {
const element = ev.target as HTMLElement;
const closestlink = element.closest("a");
const isOpenToNewTabClick =
ev.ctrlKey ||
ev.shiftKey ||
ev.metaKey || // apple
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
if (closestlink && isOpenToNewTabClick) {
NProgress.done();
}
};
window.addEventListener("click", linkClickListener);
return () => window.removeEventListener("click", linkClickListener);
}, []);
return null;
} }

View File

@@ -28,7 +28,6 @@ import {
TableRow TableRow
} from "@app/components/ui/table"; } from "@app/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment"; import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@@ -59,8 +58,6 @@ export default function ViewDevicesDialog({
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [activeTab, setActiveTab] = useState<"available" | "archived">("available"); const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
const fetchDevices = async () => { const fetchDevices = async () => {
@@ -108,8 +105,6 @@ export default function ViewDevicesDialog({
d.olmId === olmId ? { ...d, archived: true } : d d.olmId === olmId ? { ...d, archived: true } : d
) )
); );
setIsArchiveModalOpen(false);
setSelectedDevice(null);
} catch (error: any) { } catch (error: any) {
console.error("Error archiving device:", error); console.error("Error archiving device:", error);
toast({ toast({
@@ -153,8 +148,6 @@ export default function ViewDevicesDialog({
function reset() { function reset() {
setDevices([]); setDevices([]);
setSelectedDevice(null);
setIsArchiveModalOpen(false);
} }
return ( return (
@@ -263,12 +256,7 @@ export default function ViewDevicesDialog({
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setSelectedDevice( archiveDevice(device.olmId);
device
);
setIsArchiveModalOpen(
true
);
}} }}
> >
{t( {t(
@@ -361,34 +349,6 @@ export default function ViewDevicesDialog({
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
{selectedDevice && (
<ConfirmDeleteDialog
open={isArchiveModalOpen}
setOpen={(val) => {
setIsArchiveModalOpen(val);
if (!val) {
setSelectedDevice(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("deviceQuestionArchive") ||
"Are you sure you want to archive this device?"}
</p>
<p>
{t("deviceMessageArchive") ||
"The device will be archived and removed from your active devices list."}
</p>
</div>
}
buttonText={t("deviceArchiveConfirm") || "Archive Device"}
onConfirm={async () => archiveDevice(selectedDevice.olmId)}
string={selectedDevice.name || selectedDevice.olmId}
title={t("archiveDevice") || "Archive Device"}
/>
)}
</> </>
); );
} }

View File

@@ -0,0 +1,241 @@
import { cn } from "@app/lib/cn";
import type { DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { CaretSortIcon } from "@radix-ui/react-icons";
import type { ListSitesResponse } from "@server/routers/site";
import { type ListTargetsResponse } from "@server/routers/target";
import type { ArrayElement } from "@server/types/ArrayElement";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { ContainersSelector } from "./ContainersSelector";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
siteType: string | null;
},
"protocol"
>;
export type ResourceTargetAddressItemProps = {
getDockerStateForSite: (siteId: number) => DockerState;
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
sites: SiteWithUpdateAvailable[];
proxyTarget: LocalTarget;
isHttp: boolean;
refreshContainersForSite: (siteId: number) => void;
};
export function ResourceTargetAddressItem({
sites,
getDockerStateForSite,
updateTarget,
proxyTarget,
isHttp,
refreshContainersForSite
}: ResourceTargetAddressItemProps) {
const t = useTranslations();
const selectedSite = sites.find(
(site) => site.siteId === proxyTarget.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full" key={proxyTarget.targetId}>
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
"rounded-l-md rounded-r-xs",
!proxyTarget.siteId && "text-muted-foreground"
)}
>
<span className="truncate max-w-37.5">
{proxyTarget.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command>
<CommandInput placeholder={t("siteSearch")} />
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
proxyTarget.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
proxyTarget.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={proxyTarget.method ?? "http"}
onValueChange={(value) =>
updateTarget(proxyTarget.targetId, {
...proxyTarget,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={proxyTarget.ip}
placeholder="Host"
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
method: hasProtocol
? parsed.protocol
: proxyTarget.method,
ip: parsed.host,
port: hasPort
? parsed.port
: proxyTarget.port
});
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: input
});
}
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
proxyTarget.port === 0 ? "" : proxyTarget.port
}
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
port: value
});
} else {
updateTarget(proxyTarget.targetId, {
...proxyTarget,
port: 0
});
}
}}
/>
</div>
</div>
);
}

View File

@@ -44,8 +44,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
className className
)} )}
ref={ref} ref={ref}

View File

@@ -36,7 +36,9 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full", "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
// "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
className className
)} )}
{...props} {...props}
@@ -60,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
@@ -73,7 +75,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)} )}
> >
{children} {children}