Merge pull request #1172 from fosrl/dev

1.8.0
This commit is contained in:
Owen Schwartz 2025-07-30 15:07:47 -07:00 committed by GitHub
commit 1a4d34a802
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3077 additions and 897 deletions

View file

@ -42,10 +42,6 @@ _Pangolin tunnels your services to the internet so you can access anything from
</strong>
</p>
<p align="center">
<a href='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks' target="_blank"><img src='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks/upvote_embed.svg' alt='Launch YC: Pangolin Open-source secure gateway to private networks'/ ></a>
</p>
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
<img src="public/screenshots/hero.png" alt="Preview"/>

View file

@ -31,6 +31,7 @@ services:
- SYS_MODULE
ports:
- 51820:51820/udp
- 21820:21820/udp
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode

View file

@ -22,10 +22,6 @@ gerbil:
start_port: 51820
base_endpoint: "{{.DashboardDomain}}"
orgs:
block_size: 24
subnet_group: 100.89.138.0/20
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"

View file

@ -31,6 +31,7 @@ services:
- SYS_MODULE
ports:
- 51820:51820/udp
- 21820:21820/udp
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode
{{end}}

View file

@ -60,8 +60,23 @@ const (
)
func main() {
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!")
fmt.Println("This installer will help you set up Pangolin on your server.")
fmt.Println("")
fmt.Println("Please make sure you have the following prerequisites:")
fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.")
fmt.Println("- Point your domain to the VPS IP with A records.")
fmt.Println("")
fmt.Println("http://docs.fossorial.io/Getting%20Started/dns-networking")
fmt.Println("")
fmt.Println("Lets get started!")
fmt.Println("")
reader := bufio.NewReader(os.Stdin)
inputContainer := readString(reader, "Would you like to run Pangolin as docker or podman container?", "docker")
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
chosenContainer := Docker
if strings.EqualFold(inputContainer, "docker") {

View file

@ -59,7 +59,6 @@
"siteErrorCreate": "Error creating site",
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
"siteErrorCreateDefaults": "Site defaults not found",
"siteNameDescription": "This is the display name for the site.",
"method": "Method",
"siteMethodDescription": "This is how you will expose connections.",
"siteLearnNewt": "Learn how to install Newt on your system",
@ -1094,7 +1093,7 @@
"sidebarAllUsers": "All Users",
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients",
"sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
@ -1196,7 +1195,7 @@
"sidebarExpand": "Expand",
"newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Enter your domain",
"domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
@ -1206,7 +1205,7 @@
"domainPickerSortAsc": "A-Z",
"domainPickerSortDesc": "Z-A",
"domainPickerCheckingAvailability": "Checking availability...",
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
"domainPickerOrganizationDomains": "Organization Domains",
"domainPickerProvidedDomains": "Provided Domains",
"domainPickerSubdomain": "Subdomain: {subdomain}",
@ -1274,5 +1273,50 @@
"createDomainDnsPropagation": "DNS Propagation",
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
"resourcePortRequired": "Port number is required for non-HTTP resources",
"resourcePortNotAllowed": "Port number should not be set for HTTP resources"
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
"signUpTerms": {
"IAgreeToThe": "I agree to the",
"termsOfService": "terms of service",
"and": "and",
"privacyPolicy": "privacy policy"
},
"siteRequired": "Site is required.",
"olmTunnel": "Olm Tunnel",
"olmTunnelDescription": "Use Olm for client connectivity",
"errorCreatingClient": "Error creating client",
"clientDefaultsNotFound": "Client defaults not found",
"createClient": "Create Client",
"createClientDescription": "Create a new client for connecting to your sites",
"seeAllClients": "See All Clients",
"clientInformation": "Client Information",
"clientNamePlaceholder": "Client name",
"address": "Address",
"subnetPlaceholder": "Subnet",
"addressDescription": "The address that this client will use for connectivity",
"selectSites": "Select sites",
"sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Olm",
"clientInstallOlmDescription": "Get Olm running on your system",
"clientOlmCredentials": "Olm Credentials",
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
"olmEndpoint": "Olm Endpoint",
"olmId": "Olm ID",
"olmSecretKey": "Olm Secret Key",
"clientCredentialsSave": "Save Your Credentials",
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
"generalSettingsDescription": "Configure the general settings for this client",
"clientUpdated": "Client updated",
"clientUpdatedDescription": "The client has been updated.",
"clientUpdateFailed": "Failed to update client",
"clientUpdateError": "An error occurred while updating the client.",
"sitesFetchFailed": "Failed to fetch sites",
"sitesFetchError": "An error occurred while fetching sites.",
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
"remoteSubnets": "Remote Subnets",
"enterCidrRange": "Enter CIDR range",
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
"resourceEnableProxy": "Enable Public Proxy",
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
"externalProxyEnabled": "External Proxy Enabled"
}

View file

@ -5,7 +5,8 @@ import {
boolean,
integer,
bigint,
real
real,
text
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
@ -58,7 +59,8 @@ export const sites = pgTable("sites", {
publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access
});
export const resources = pgTable("resources", {
@ -92,7 +94,8 @@ export const resources = pgTable("resources", {
enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader")
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
});
export const targets = pgTable("targets", {
@ -135,6 +138,8 @@ export const users = pgTable("user", {
twoFactorSecret: varchar("twoFactorSecret"),
emailVerified: boolean("emailVerified").notNull().default(false),
dateCreated: varchar("dateCreated").notNull(),
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
termsVersion: varchar("termsVersion"),
serverAdmin: boolean("serverAdmin").notNull().default(false)
});
@ -504,8 +509,8 @@ export const clients = pgTable("clients", {
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
lastPing: varchar("lastPing"),
type: varchar("type").notNull(), // "olm"
@ -539,7 +544,7 @@ export const olmSessions = pgTable("clientSession", {
olmId: varchar("olmId")
.notNull()
.references(() => olms.olmId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull()
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const userClients = pgTable("userClients", {
@ -562,9 +567,11 @@ export const roleClients = pgTable("roleClients", {
export const securityKeys = pgTable("webauthnCredentials", {
credentialId: varchar("credentialId").primaryKey(),
userId: varchar("userId").notNull().references(() => users.userId, {
onDelete: "cascade"
}),
userId: varchar("userId")
.notNull()
.references(() => users.userId, {
onDelete: "cascade"
}),
publicKey: varchar("publicKey").notNull(),
signCount: integer("signCount").notNull(),
transports: varchar("transports"),

View file

@ -65,7 +65,8 @@ export const sites = sqliteTable("sites", {
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true)
.default(true),
remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access
});
export const resources = sqliteTable("resources", {
@ -105,7 +106,8 @@ export const resources = sqliteTable("resources", {
.notNull()
.default(false),
tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader")
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
});
export const targets = sqliteTable("targets", {
@ -154,6 +156,8 @@ export const users = sqliteTable("user", {
.notNull()
.default(false),
dateCreated: text("dateCreated").notNull(),
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
termsVersion: text("termsVersion"),
serverAdmin: integer("serverAdmin", { mode: "boolean" })
.notNull()
.default(false)

View file

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

View file

@ -229,9 +229,22 @@ export const configSchema = z
disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(),
enable_clients: z.boolean().optional()
enable_clients: z.boolean().optional().default(true),
})
.optional(),
dns: z
.object({
nameservers: z
.array(z.string().optional().optional())
.optional()
.default(["ns1.fossorial.io", "ns2.fossorial.io"]),
cname_extension: z.string().optional().default("fossorial.io")
})
.optional()
.default({
nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"],
cname_extension: "fossorial.io"
})
})
.refine(
(data) => {

View file

@ -106,21 +106,21 @@ export async function login(
);
}
// Check if user has security keys registered
const userSecurityKeys = await db
.select()
.from(securityKeys)
.where(eq(securityKeys.userId, existingUser.userId));
if (userSecurityKeys.length > 0) {
return response<LoginResponse>(res, {
data: { useSecurityKey: true },
success: true,
error: false,
message: "Security key authentication required",
status: HttpCode.OK
});
}
// // Check if user has security keys registered
// const userSecurityKeys = await db
// .select()
// .from(securityKeys)
// .where(eq(securityKeys.userId, existingUser.userId));
//
// if (userSecurityKeys.length > 0) {
// return response<LoginResponse>(res, {
// data: { useSecurityKey: true },
// success: true,
// error: false,
// message: "Security key authentication required",
// status: HttpCode.OK
// });
// }
if (
existingUser.twoFactorSetupRequested &&

View file

@ -21,15 +21,14 @@ import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { build } from "@server/build";
export const signupBodySchema = z.object({
email: z
.string()
.toLowerCase()
.email(),
email: z.string().toLowerCase().email(),
password: passwordSchema,
inviteToken: z.string().optional(),
inviteId: z.string().optional()
inviteId: z.string().optional(),
termsAcceptedTimestamp: z.string().nullable().optional()
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
@ -54,7 +53,8 @@ export async function signup(
);
}
const { email, password, inviteToken, inviteId } = parsedBody.data;
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } =
parsedBody.data;
const passwordHash = await hashPassword(password);
const userId = generateId(15);
@ -161,13 +161,24 @@ export async function signup(
}
}
if (build === "saas" && !termsAcceptedTimestamp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You must accept the terms of service and privacy policy"
)
);
}
await db.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()
dateCreated: moment().toISOString(),
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
termsVersion: "1"
});
// give the user their default permissions:

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, exitNodes, sites } from "@server/db";
import { clients, clientSites } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -17,6 +17,7 @@ import {
addPeer as olmAddPeer,
deletePeer as olmDeletePeer
} from "../olm/peers";
import axios from "axios";
const updateClientParamsSchema = z
.object({
@ -53,6 +54,11 @@ registry.registerPath({
responses: {}
});
interface PeerDestination {
destinationIP: string;
destinationPort: number;
}
export async function updateClient(
req: Request,
res: Response,
@ -124,15 +130,22 @@ export async function updateClient(
);
for (const siteId of sitesAdded) {
if (!client.subnet || !client.pubKey || !client.endpoint) {
logger.debug("Client subnet, pubKey or endpoint is not set");
logger.debug(
"Client subnet, pubKey or endpoint is not set"
);
continue;
}
// TODO: WE NEED TO HANDLE THIS BETTER. RIGHT NOW WE ARE JUST GUESSING BASED ON THE OTHER SITES
// BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS
const isRelayed = true;
const site = await newtAddPeer(siteId, {
publicKey: client.pubKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: client.endpoint
endpoint: isRelayed ? "" : client.endpoint
});
if (!site) {
logger.debug("Failed to add peer to newt - missing site");
continue;
@ -142,12 +155,49 @@ export async function updateClient(
logger.debug("Site endpoint or publicKey is not set");
continue;
}
let endpoint;
if (isRelayed) {
if (!site.exitNodeId) {
logger.warn(
`Site ${site.siteId} has no exit node, skipping`
);
return null;
}
// get the exit node for the site
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (!exitNode) {
logger.warn(
`Exit node not found for site ${site.siteId}`
);
return null;
}
endpoint = `${exitNode.endpoint}:21820`;
} else {
if (!endpoint) {
logger.warn(
`Site ${site.siteId} has no endpoint, skipping`
);
return null;
}
endpoint = site.endpoint;
}
await olmAddPeer(client.clientId, {
siteId: siteId,
endpoint: site.endpoint,
siteId: site.siteId,
endpoint: endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort
serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
});
}
@ -170,7 +220,11 @@ export async function updateClient(
logger.debug("Site endpoint or publicKey is not set");
continue;
}
await olmDeletePeer(client.clientId, site.siteId, site.publicKey);
await olmDeletePeer(
client.clientId,
site.siteId,
site.publicKey
);
}
}
@ -201,6 +255,101 @@ export async function updateClient(
}
}
if (client.endpoint) {
// get all sites for this client and join with exit nodes with site.exitNodeId
const sitesData = await db
.select()
.from(sites)
.innerJoin(
clientSites,
eq(sites.siteId, clientSites.siteId)
)
.leftJoin(
exitNodes,
eq(sites.exitNodeId, exitNodes.exitNodeId)
)
.where(eq(clientSites.clientId, client.clientId));
let exitNodeDestinations: {
reachableAt: string;
destinations: PeerDestination[];
}[] = [];
for (const site of sitesData) {
if (!site.sites.subnet) {
logger.warn(
`Site ${site.sites.siteId} has no subnet, skipping`
);
continue;
}
// find the destinations in the array
let destinations = exitNodeDestinations.find(
(d) => d.reachableAt === site.exitNodes?.reachableAt
);
if (!destinations) {
destinations = {
reachableAt: site.exitNodes?.reachableAt || "",
destinations: [
{
destinationIP:
site.sites.subnet.split("/")[0],
destinationPort: site.sites.listenPort || 0
}
]
};
} else {
// add to the existing destinations
destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0],
destinationPort: site.sites.listenPort || 0
});
}
// update it in the array
exitNodeDestinations = exitNodeDestinations.filter(
(d) => d.reachableAt !== site.exitNodes?.reachableAt
);
exitNodeDestinations.push(destinations);
}
for (const destination of exitNodeDestinations) {
try {
logger.info(
`Updating destinations for exit node at ${destination.reachableAt}`
);
const payload = {
sourceIp: client.endpoint?.split(":")[0] || "",
sourcePort: parseInt(client.endpoint?.split(":")[1]) || 0,
destinations: destination.destinations
};
logger.info(
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
);
const response = await axios.post(
`${destination.reachableAt}/update-destinations`,
payload,
{
headers: {
"Content-Type": "application/json"
}
}
);
logger.info("Destinations updated:", {
peer: response.data.status
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
);
}
throw error;
}
}
}
// Fetch the updated client
const [updatedClient] = await trx
.select()

View file

@ -11,6 +11,7 @@ import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm";
import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
import config from "@server/lib/config";
const paramsSchema = z
.object({
@ -228,15 +229,15 @@ export async function createOrgDomain(
// TODO: This needs to be cross region and not hardcoded
if (type === "ns") {
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
nsRecords = config.getRawConfig().dns.nameservers as string[];
} else if (type === "cname") {
cnameRecords = [
{
value: `${domainId}.cname.fossorial.io`,
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
baseDomain: baseDomain
},
{
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
baseDomain: `_acme-challenge.${baseDomain}`
}
];

View file

@ -233,6 +233,12 @@ authenticated.get(
resource.listResources
);
authenticated.get(
"/org/:orgId/user-resources",
verifyOrgAccess,
resource.getUserResources
);
authenticated.get(
"/org/:orgId/domains",
verifyOrgAccess,

View file

@ -8,7 +8,7 @@ export async function addPeer(exitNodeId: number, peer: {
publicKey: string;
allowedIps: string[];
}) {
logger.info(`Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}`);
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`);
@ -35,6 +35,7 @@ export async function addPeer(exitNodeId: number, peer: {
}
export async function deletePeer(exitNodeId: number, publicKey: string) {
logger.info(`Deleting peer with public key ${publicKey} from exit node ${exitNodeId}`);
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`);

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, newts, olms, Site, sites, clientSites } from "@server/db";
import { clients, newts, olms, Site, sites, clientSites, exitNodes } from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@ -9,6 +9,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
import axios from "axios";
// Define Zod schema for request validation
const updateHolePunchSchema = z.object({
@ -17,7 +18,8 @@ const updateHolePunchSchema = z.object({
token: z.string(),
ip: z.string(),
port: z.number(),
timestamp: z.number()
timestamp: z.number(),
reachableAt: z.string().optional()
});
// New response type with multi-peer destination support
@ -43,8 +45,7 @@ export async function updateHolePunch(
);
}
const { olmId, newtId, ip, port, timestamp, token } = parsedParams.data;
const { olmId, newtId, ip, port, timestamp, token, reachableAt } = parsedParams.data;
let currentSiteId: number | undefined;
let destinations: PeerDestination[] = [];
@ -95,37 +96,129 @@ export async function updateHolePunch(
);
}
// Get all sites that this client is connected to
const clientSitePairs = await db
// // Get all sites that this client is connected to
// const clientSitePairs = await db
// .select()
// .from(clientSites)
// .where(eq(clientSites.clientId, client.clientId));
// if (clientSitePairs.length === 0) {
// logger.warn(`No sites found for client: ${client.clientId}`);
// return next(
// createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
// );
// }
// // Get all sites details
// const siteIds = clientSitePairs.map(pair => pair.siteId);
// for (const siteId of siteIds) {
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, siteId));
// if (site && site.subnet && site.listenPort) {
// destinations.push({
// destinationIP: site.subnet.split("/")[0],
// destinationPort: site.listenPort
// });
// }
// }
// get all sites for this client and join with exit nodes with site.exitNodeId
const sitesData = await db
.select()
.from(clientSites)
.from(sites)
.innerJoin(clientSites, eq(sites.siteId, clientSites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(eq(clientSites.clientId, client.clientId));
if (clientSitePairs.length === 0) {
logger.warn(`No sites found for client: ${client.clientId}`);
return next(
createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
let exitNodeDestinations: {
reachableAt: string;
destinations: PeerDestination[];
}[] = [];
for (const site of sitesData) {
if (!site.sites.subnet) {
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
continue;
}
// find the destinations in the array
let destinations = exitNodeDestinations.find(
(d) => d.reachableAt === site.exitNodes?.reachableAt
);
if (!destinations) {
destinations = {
reachableAt: site.exitNodes?.reachableAt || "",
destinations: [
{
destinationIP: site.sites.subnet.split("/")[0],
destinationPort: site.sites.listenPort || 0
}
]
};
} else {
// add to the existing destinations
destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0],
destinationPort: site.sites.listenPort || 0
});
}
// update it in the array
exitNodeDestinations = exitNodeDestinations.filter(
(d) => d.reachableAt !== site.exitNodes?.reachableAt
);
exitNodeDestinations.push(destinations);
}
// Get all sites details
const siteIds = clientSitePairs.map(pair => pair.siteId);
logger.debug(JSON.stringify(exitNodeDestinations, null, 2));
for (const siteId of siteIds) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId));
for (const destination of exitNodeDestinations) {
// if its the current exit node skip it because it is replying with the same data
if (reachableAt && destination.reachableAt == reachableAt) {
logger.debug(`Skipping update for reachableAt: ${reachableAt}`);
continue;
}
if (site && site.subnet && site.listenPort) {
destinations.push({
destinationIP: site.subnet.split("/")[0],
destinationPort: site.listenPort
try {
const response = await axios.post(
`${destination.reachableAt}/update-destinations`,
{
sourceIp: client.endpoint?.split(":")[0] || "",
sourcePort: client.endpoint?.split(":")[1] || 0,
destinations: destination.destinations
},
{
headers: {
"Content-Type": "application/json"
}
}
);
logger.info("Destinations updated:", {
peer: response.data.status
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
);
}
throw error;
}
}
// Send the desinations back to the origin
destinations = exitNodeDestinations.find(
(d) => d.reachableAt === reachableAt
)?.destinations || [];
} else if (newtId) {
logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`);
const { session, newt: newtSession } =
await validateNewtSessionToken(token);
@ -174,28 +267,29 @@ export async function updateHolePunch(
}
// Find all clients that connect to this site
const sitesClientPairs = await db
.select()
.from(clientSites)
.where(eq(clientSites.siteId, newt.siteId));
// const sitesClientPairs = await db
// .select()
// .from(clientSites)
// .where(eq(clientSites.siteId, newt.siteId));
// THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING
// Get client details for each client
for (const pair of sitesClientPairs) {
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, pair.clientId));
// for (const pair of sitesClientPairs) {
// const [client] = await db
// .select()
// .from(clients)
// .where(eq(clients.clientId, pair.clientId));
if (client && client.endpoint) {
const [host, portStr] = client.endpoint.split(':');
if (host && portStr) {
destinations.push({
destinationIP: host,
destinationPort: parseInt(portStr, 10)
});
}
}
}
// if (client && client.endpoint) {
// const [host, portStr] = client.endpoint.split(':');
// if (host && portStr) {
// destinations.push({
// destinationIP: host,
// destinationPort: parseInt(portStr, 10)
// });
// }
// }
// }
// If this is a newt/site, also add other sites in the same org
// if (updatedSite.orgId) {

View file

@ -2,10 +2,18 @@ import { z } from "zod";
import { MessageHandler } from "../ws";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { db } from "@server/db";
import {
db,
ExitNode,
exitNodes,
resources,
Target,
targets
} from "@server/db";
import { clients, clientSites, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { updatePeer } from "../olm/peers";
import axios from "axios";
const inputSchema = z.object({
publicKey: z.string(),
@ -87,6 +95,48 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
return;
}
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (exitNode.reachableAt) {
try {
const response = await axios.post(
`${exitNode.reachableAt}/update-proxy-mapping`,
{
oldDestination: {
destinationIP: existingSite.subnet?.split("/")[0],
destinationPort: existingSite.listenPort
},
newDestination: {
destinationIP: site.subnet?.split("/")[0],
destinationPort: site.listenPort
}
},
{
headers: {
"Content-Type": "application/json"
}
}
);
logger.info("Destinations updated:", {
peer: response.data.status
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
);
}
throw error;
}
}
}
// Get all clients connected to this site
const clientsRes = await db
.select()
@ -107,33 +157,59 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (!client.clients.endpoint) {
return false;
}
if (!client.clients.online) {
return false;
}
return true;
})
.map(async (client) => {
// Add or update this peer on the olm if it is connected
try {
if (site.endpoint && site.publicKey) {
await updatePeer(client.clients.clientId, {
siteId: site.siteId,
endpoint: site.endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort
});
if (!site.publicKey) {
logger.warn(
`Site ${site.siteId} has no public key, skipping`
);
return null;
}
let endpoint = site.endpoint;
if (client.clientSites.isRelayed) {
if (!site.exitNodeId) {
logger.warn(
`Site ${site.siteId} has no exit node, skipping`
);
return null;
}
if (!exitNode) {
logger.warn(
`Exit node not found for site ${site.siteId}`
);
return null;
}
endpoint = `${exitNode.endpoint}:21820`;
}
if (!endpoint) {
logger.warn(
`Site ${site.siteId} has no endpoint, skipping`
);
return null;
}
await updatePeer(client.clients.clientId, {
siteId: site.siteId,
endpoint: endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
});
} catch (error) {
logger.error(
`Failed to add/update peer ${client.clients.pubKey} to newt ${newt.newtId}: ${error}`
`Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}`
);
}
return {
publicKey: client.clients.pubKey!,
allowedIps: [`${client.clients.subnet.split('/')[0]}/32`], // we want to only allow from that client
allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: client.clientSites.isRelayed
? ""
: client.clients.endpoint! // if its relayed it should be localhost
@ -144,14 +220,96 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
// Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null);
// Improved version
const allResources = await db.transaction(async (tx) => {
// First get all resources for the site
const resourcesList = await tx
.select({
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol
})
.from(resources)
.where(and(eq(resources.siteId, siteId), eq(resources.http, false)));
// Get all enabled targets for these resources in a single query
const resourceIds = resourcesList.map((r) => r.resourceId);
const allTargets =
resourceIds.length > 0
? await tx
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
})
.from(targets)
.where(
and(
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// Combine the data in JS instead of using SQL for the JSON
return resourcesList.map((resource) => ({
...resource,
targets: allTargets.filter(
(target) => target.resourceId === resource.resourceId
)
}));
});
const { tcpTargets, udpTargets } = allResources.reduce(
(acc, resource) => {
// Skip resources with no targets
if (!resource.targets?.length) return acc;
// Format valid targets into strings
const formattedTargets = resource.targets
.filter(
(target: Target) =>
resource.proxyPort && target?.ip && target?.port
)
.map(
(target: Target) =>
`${resource.proxyPort}:${target.ip}:${target.port}`
);
// Add to the appropriate protocol array
if (resource.protocol === "tcp") {
acc.tcpTargets.push(...formattedTargets);
} else {
acc.udpTargets.push(...formattedTargets);
}
return acc;
},
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
);
// Build the configuration response
const configResponse = {
ipAddress: site.address,
peers: validPeers
peers: validPeers,
targets: {
udp: udpTargets,
tcp: tcpTargets
}
};
logger.debug("Sending config: ", configResponse);
return {
message: {
type: "newt/wg/receive-config",

View file

@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
export function addTargets(
newtId: string,
targets: Target[],
protocol: string
protocol: string,
port: number | null = null
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
@ -13,19 +14,32 @@ export function addTargets(
}:${target.port}`;
});
const payload = {
sendToClient(newtId, {
type: `newt/${protocol}/add`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
});
const payloadTargetsResources = targets.map((target) => {
return `${port ? port + ":" : ""}${
target.ip
}:${target.port}`;
});
sendToClient(newtId, {
type: `newt/wg/${protocol}/add`,
data: {
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
}
});
}
export function removeTargets(
newtId: string,
targets: Target[],
protocol: string
protocol: string,
port: number | null = null
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
@ -34,11 +48,23 @@ export function removeTargets(
}:${target.port}`;
});
const payload = {
sendToClient(newtId, {
type: `newt/${protocol}/remove`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
});
const payloadTargetsResources = targets.map((target) => {
return `${port ? port + ":" : ""}${
target.ip
}:${target.port}`;
});
sendToClient(newtId, {
type: `newt/wg/${protocol}/remove`,
data: {
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
}
});
}

View file

@ -1,4 +1,4 @@
import { db } from "@server/db";
import { db, ExitNode } from "@server/db";
import { MessageHandler } from "../ws";
import {
clients,
@ -28,7 +28,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
const clientId = olm.clientId;
const { publicKey } = message.data;
const { publicKey, relay } = message.data;
logger.debug(`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`);
if (!publicKey) {
logger.warn("Public key not provided");
return;
@ -58,9 +61,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sendToClient(olm.olmId, {
type: "olm/wg/holepunch",
data: {
serverPubKey: exitNode.publicKey
serverPubKey: exitNode.publicKey,
endpoint: exitNode.endpoint,
}
});
}
if (now - (client.lastHolePunch || 0) > 6) {
@ -84,7 +89,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await db
.update(clientSites)
.set({
isRelayed: false
isRelayed: relay == true
})
.where(eq(clientSites.clientId, olm.clientId));
}
@ -97,7 +102,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.where(eq(clientSites.clientId, client.clientId));
// Prepare an array to store site configurations
const siteConfigurations = [];
let siteConfigurations = [];
logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`);
if (sitesData.length === 0) {
sendToClient(olm.olmId, {
type: "olm/register/no-sites",
data: {}
});
}
// Process each site
for (const { sites: site } of sitesData) {
@ -114,12 +127,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
continue;
}
if (site.lastHolePunch && now - site.lastHolePunch > 6) {
logger.warn(
`Site ${site.siteId} last hole punch is too old, skipping`
);
continue;
}
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
// logger.warn(
// `Site ${site.siteId} last hole punch is too old, skipping`
// );
// continue;
// }
// If public key changed, delete old peer from this site
if (client.pubKey && client.pubKey != publicKey) {
@ -142,7 +155,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await addPeer(site.siteId, {
publicKey: publicKey,
allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client
endpoint: client.endpoint
endpoint: relay ? "" : client.endpoint
});
} else {
logger.warn(
@ -150,21 +163,36 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
);
}
let endpoint = site.endpoint;
if (relay) {
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (!exitNode) {
logger.warn(`Exit node not found for site ${site.siteId}`);
continue;
}
endpoint = `${exitNode.endpoint}:21820`;
}
// Add site configuration to the array
siteConfigurations.push({
siteId: site.siteId,
endpoint: site.endpoint,
endpoint: endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort
serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
});
}
// If we have no valid site configurations, don't send a connect message
if (siteConfigurations.length === 0) {
logger.warn("No valid site configurations found");
return;
}
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
// if (siteConfigurations.length === 0) {
// logger.warn("No valid site configurations found");
// return;
// }
// Return connect message with all site configurations
return {

View file

@ -1,7 +1,7 @@
import { db } from "@server/db";
import { db, exitNodes, sites } from "@server/db";
import { MessageHandler } from "../ws";
import { clients, clientSites, Olm } from "@server/db";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { updatePeer } from "../newt/peers";
import logger from "@server/logger";
@ -30,29 +30,67 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
.limit(1);
if (!client) {
logger.warn("Site not found or does not have exit node");
logger.warn("Client not found");
return;
}
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
if (!client.pubKey) {
logger.warn("Site or client has no endpoint or listen port");
logger.warn("Client has no endpoint or listen port");
return;
}
const { siteId } = message.data;
// Get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site || !site.exitNodeId) {
logger.warn("Site not found or has no exit node");
return;
}
// get the site's exit node
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
if (!exitNode) {
logger.warn("Exit node not found for site");
return;
}
await db
.update(clientSites)
.set({
isRelayed: true
})
.where(eq(clientSites.clientId, olm.clientId));
.where(
and(
eq(clientSites.clientId, olm.clientId),
eq(clientSites.siteId, siteId)
)
);
// update the peer on the exit node
await updatePeer(siteId, client.pubKey, {
endpoint: "" // this removes the endpoint
});
sendToClient(olm.olmId, {
type: "olm/wg/peer/relay",
data: {
siteId: siteId,
endpoint: exitNode.endpoint,
publicKey: exitNode.publicKey
}
});
return;
};

View file

@ -12,6 +12,7 @@ export async function addPeer(
endpoint: string;
serverIP: string | null;
serverPort: number | null;
remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access
}
) {
const [olm] = await db
@ -30,7 +31,8 @@ export async function addPeer(
publicKey: peer.publicKey,
endpoint: peer.endpoint,
serverIP: peer.serverIP,
serverPort: peer.serverPort
serverPort: peer.serverPort,
remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access
}
});
@ -66,6 +68,7 @@ export async function updatePeer(
endpoint: string;
serverIP: string | null;
serverPort: number | null;
remoteSubnets?: string | null; // optional, comma-separated list of subnets that
}
) {
const [olm] = await db
@ -84,7 +87,8 @@ export async function updatePeer(
publicKey: peer.publicKey,
endpoint: peer.endpoint,
serverIP: peer.serverIP,
serverPort: peer.serverPort
serverPort: peer.serverPort,
remoteSubnets: peer.remoteSubnets
}
});

View file

@ -1,14 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
newts,
newtSessions,
orgs,
sites,
userActions
} from "@server/db";
import { eq } from "drizzle-orm";
import { db, domains, orgDomains, resources } from "@server/db";
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -126,6 +120,44 @@ export async function deleteOrg(
}
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
// For each domain, check if it belongs to multiple organizations
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
// Count how many organizations this domain belongs to
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
// Delete domains that belong exclusively to this organization
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
// Delete resources
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
@ -136,8 +168,11 @@ export async function deleteOrg(
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch(error => {
logger.error("Failed to send termination message to newt:", error);
sendToClient(newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
}

View file

@ -33,10 +33,7 @@ const createResourceParamsSchema = z
const createHttpResourceSchema = z
.object({
name: z.string().min(1).max(255),
subdomain: z
.string()
.nullable()
.optional(),
subdomain: z.string().nullable().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
@ -51,7 +48,7 @@ const createHttpResourceSchema = z
return true;
},
{ message: "Invalid subdomain" }
)
);
const createRawResourceSchema = z
.object({
@ -59,7 +56,8 @@ const createRawResourceSchema = z
siteId: z.number(),
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().min(1).max(65535)
proxyPort: z.number().int().min(1).max(65535),
enableProxy: z.boolean().default(true)
})
.strict()
.refine(
@ -88,12 +86,7 @@ registry.registerPath({
body: {
content: {
"application/json": {
schema:
build == "oss"
? createHttpResourceSchema.or(
createRawResourceSchema
)
: createHttpResourceSchema
schema: createHttpResourceSchema.or(createRawResourceSchema)
}
}
}
@ -156,7 +149,10 @@ export async function createResource(
{ siteId, orgId }
);
} else {
if (!config.getRawConfig().flags?.allow_raw_resources && build == "oss") {
if (
!config.getRawConfig().flags?.allow_raw_resources &&
build == "oss"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -378,7 +374,7 @@ async function createRawResource(
);
}
const { name, http, protocol, proxyPort } = parsedBody.data;
const { name, http, protocol, proxyPort, enableProxy } = 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
@ -411,7 +407,8 @@ async function createRawResource(
name,
http,
protocol,
proxyPort
proxyPort,
enableProxy
})
.returning();

View file

@ -103,7 +103,8 @@ export async function deleteResource(
removeTargets(
newt.newtId,
targetsToBeRemoved,
deletedResource.protocol
deletedResource.protocol,
deletedResource.proxyPort
);
}
}

View file

@ -0,0 +1,168 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { and, eq, or, inArray } from "drizzle-orm";
import {
resources,
userResources,
roleResources,
userOrgs,
roles,
resourcePassword,
resourcePincode,
resourceWhitelist,
sites
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
export async function getUserResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { orgId } = req.params;
const userId = req.user?.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
// First get the user's role in the organization
const userOrgResult = await db
.select({
roleId: userOrgs.roleId
})
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (userOrgResult.length === 0) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
);
}
const userRoleId = userOrgResult[0].roleId;
// Get resources accessible through direct assignment or role assignment
const directResourcesQuery = db
.select({ resourceId: userResources.resourceId })
.from(userResources)
.where(eq(userResources.userId, userId));
const roleResourcesQuery = db
.select({ resourceId: roleResources.resourceId })
.from(roleResources)
.where(eq(roleResources.roleId, userRoleId));
const [directResources, roleResourceResults] = await Promise.all([
directResourcesQuery,
roleResourcesQuery
]);
// Combine all accessible resource IDs
const accessibleResourceIds = [
...directResources.map(r => r.resourceId),
...roleResourceResults.map(r => r.resourceId)
];
if (accessibleResourceIds.length === 0) {
return response(res, {
data: { resources: [] },
success: true,
error: false,
message: "No resources found",
status: HttpCode.OK
});
}
// Get resource details for accessible resources
const resourcesData = await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
siteName: sites.name
})
.from(resources)
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
);
// Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => {
const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([
db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1),
db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1),
db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1)
]);
const hasPassword = passwordCheck.length > 0;
const hasPincode = pincodeCheck.length > 0;
const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
return {
resourceId: resource.resourceId,
name: resource.name,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
enabled: resource.enabled,
protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist),
protocol: resource.protocol,
sso: resource.sso,
password: hasPassword,
pincode: hasPincode,
whitelist: hasWhitelist,
siteName: resource.siteName
};
})
);
return response(res, {
data: { resources: resourcesWithAuth },
success: true,
error: false,
message: "User resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
console.error("Error fetching user resources:", error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error")
);
}
}
export type GetUserResourcesResponse = {
success: boolean;
data: {
resources: Array<{
resourceId: number;
name: string;
domain: string;
enabled: boolean;
protected: boolean;
protocol: string;
}>;
};
};

View file

@ -22,3 +22,4 @@ export * from "./createResourceRule";
export * from "./deleteResourceRule";
export * from "./listResourceRules";
export * from "./updateResourceRule";
export * from "./getUserResources";

View file

@ -168,7 +168,8 @@ export async function transferResource(
removeTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
updatedResource.protocol,
updatedResource.proxyPort
);
}
}
@ -190,7 +191,8 @@ export async function transferResource(
addTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
updatedResource.protocol,
updatedResource.proxyPort
);
}
}

View file

@ -34,9 +34,7 @@ const updateResourceParamsSchema = z
const updateHttpResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
subdomain: subdomainSchema
.nullable()
.optional(),
subdomain: subdomainSchema.nullable().optional(),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
@ -93,7 +91,8 @@ const updateRawResourceBodySchema = z
name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(),
enabled: z.boolean().optional()
enabled: z.boolean().optional(),
enableProxy: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@ -121,12 +120,9 @@ registry.registerPath({
body: {
content: {
"application/json": {
schema:
build == "oss"
? updateHttpResourceBodySchema.and(
updateRawResourceBodySchema
)
: updateHttpResourceBodySchema
schema: updateHttpResourceBodySchema.and(
updateRawResourceBodySchema
)
}
}
}
@ -288,7 +284,9 @@ async function updateHttpResource(
} else if (domainRes.domains.type == "wildcard") {
if (updateData.subdomain !== undefined) {
// the subdomain cant have a dot in it
const parsedSubdomain = subdomainSchema.safeParse(updateData.subdomain);
const parsedSubdomain = subdomainSchema.safeParse(
updateData.subdomain
);
if (!parsedSubdomain.success) {
return next(
createHttpError(
@ -341,7 +339,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({...updateData, })
.set({ ...updateData })
.where(eq(resources.resourceId, resource.resourceId))
.returning();

View file

@ -9,6 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
const updateSiteParamsSchema = z
.object({
@ -20,6 +21,9 @@ const updateSiteBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z
.string()
.optional()
// subdomain: z
// .string()
// .min(1)
@ -85,6 +89,21 @@ export async function updateSite(
const { siteId } = parsedParams.data;
const updateData = parsedBody.data;
// if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
if (updateData.remoteSubnets) {
const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim());
for (const subnet of subnets) {
if (!isValidCIDR(subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Invalid CIDR format: ${subnet}`
)
);
}
}
}
const updatedSite = await db
.update(sites)
.set(updateData)

View file

@ -173,7 +173,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget, resource.protocol);
addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort);
}
}
}

View file

@ -105,7 +105,7 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
}
}

View file

@ -157,7 +157,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, [updatedTarget], resource.protocol);
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
}
}
return response(res, {

View file

@ -66,7 +66,8 @@ export async function traefikConfigProvider(
enabled: resources.enabled,
stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
@ -365,6 +366,10 @@ export async function traefikConfigProvider(
}
} else {
// Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) {
continue;
}
const protocol = resource.protocol.toLowerCase();
const port = resource.proxyPort;

View file

@ -7,6 +7,7 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import path from "path";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -14,7 +15,8 @@ import m2 from "./scriptsPg/1.7.0";
// Define the migration list with versions and their corresponding functions
const migrations = [
{ version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 }
{ version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 }
// Add new migrations here as they are created
] as {
version: string;

View file

@ -24,6 +24,7 @@ import m19 from "./scriptsSqlite/1.3.0";
import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -47,6 +48,7 @@ const migrations = [
{ version: "1.5.0", run: m20 },
{ version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 },
// Add new migrations here as they are created
] as const;

View file

@ -0,0 +1,32 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.8.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`
BEGIN;
ALTER TABLE "clients" ALTER COLUMN "bytesIn" SET DATA TYPE real;
ALTER TABLE "clients" ALTER COLUMN "bytesOut" SET DATA TYPE real;
ALTER TABLE "clientSession" ALTER COLUMN "expiresAt" SET DATA TYPE bigint;
ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true;
ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text;
ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar;
ALTER TABLE "user" ADD COLUMN "termsVersion" varchar;
COMMIT;
`);
console.log(`Migrated database schema`);
} catch (e) {
console.log("Unable to migrate database schema");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,30 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.8.0";
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);
try {
db.transaction(() => {
db.exec(`
ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT 1;
ALTER TABLE 'sites' ADD 'remoteSubnets' text;
ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text;
ALTER TABLE 'user' ADD 'termsVersion' text;
`);
})();
console.log("Migrated database schema");
} catch (e) {
console.log("Unable to migrate database schema");
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -0,0 +1,718 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
ExternalLink,
Globe,
Search,
RefreshCw,
AlertCircle,
ChevronLeft,
ChevronRight,
Key,
KeyRound,
Fingerprint,
AtSign,
Copy,
InfoIcon,
Combine
} from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useToast } from "@app/hooks/useToast";
import { InfoPopup } from "@/components/ui/info-popup";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
// Update Resource type to include site information
type Resource = {
resourceId: number;
name: string;
domain: string;
enabled: boolean;
protected: boolean;
protocol: string;
// Auth method fields
sso?: boolean;
password?: boolean;
pincode?: boolean;
whitelist?: boolean;
// Site information
siteName?: string | null;
};
type MemberResourcesPortalProps = {
orgId: string;
};
// Favicon component with fallback
const ResourceFavicon = ({
domain,
enabled
}: {
domain: string;
enabled: boolean;
}) => {
const [faviconError, setFaviconError] = useState(false);
const [faviconLoaded, setFaviconLoaded] = useState(false);
// Extract domain for favicon URL
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
const handleFaviconLoad = () => {
setFaviconLoaded(true);
setFaviconError(false);
};
const handleFaviconError = () => {
setFaviconError(true);
setFaviconLoaded(false);
};
if (faviconError || !enabled) {
return (
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
}
return (
<div className="relative h-4 w-4 flex-shrink-0">
{!faviconLoaded && (
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
)}
<img
src={faviconUrl}
alt={`${cleanDomain} favicon`}
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={handleFaviconLoad}
onError={handleFaviconError}
/>
</div>
);
};
// Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => {
const hasAuthMethods =
resource.sso ||
resource.password ||
resource.pincode ||
resource.whitelist;
const infoContent = (
<div className="flex flex-col gap-3">
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
</div>
</div>
)}
{/* Authentication Methods */}
{hasAuthMethods && (
<div
className={
resource.siteName ? "border-t border-border pt-2" : ""
}
>
<div className="text-xs font-medium mb-1.5">
Authentication Methods
</div>
<div className="flex flex-col gap-1.5">
{resource.sso && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span className="text-sm">
Single Sign-On (SSO)
</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">
Password Protected
</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
</div>
)}
</div>
</div>
)}
{/* Resource Status - if disabled */}
{!resource.enabled && (
<div
className={`${resource.siteName || hasAuthMethods ? "border-t border-border pt-2" : ""}`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">
Resource Disabled
</span>
</div>
</div>
)}
</div>
);
return <InfoPopup>{infoContent}</InfoPopup>;
};
// Pagination component
const PaginationControls = ({
currentPage,
totalPages,
onPageChange,
totalItems,
itemsPerPage
}: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
itemsPerPage: number;
}) => {
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
if (totalPages <= 1) return null;
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
<div className="text-sm text-muted-foreground">
Showing {startItem}-{endItem} of {totalItems} resources
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => {
// Show first page, last page, current page, and 2 pages around current
const showPage =
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 1;
const showEllipsis =
(page === 2 && currentPage > 4) ||
(page === totalPages - 1 &&
currentPage < totalPages - 3);
if (!showPage && !showEllipsis) return null;
if (showEllipsis) {
return (
<span
key={page}
className="px-2 text-muted-foreground"
>
...
</span>
);
}
return (
<Button
key={page}
variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => onPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
);
}
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="gap-1"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
};
// Loading skeleton component
const ResourceCardSkeleton = () => (
<Card className="rounded-lg bg-card text-card-foreground flex flex-col w-full animate-pulse">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="h-6 bg-muted rounded w-3/4"></div>
<div className="h-5 bg-muted rounded w-16"></div>
</div>
</CardHeader>
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
<div className="flex items-center space-x-2">
<div className="h-4 w-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
<div className="mt-4">
<div className="h-8 bg-muted rounded w-full"></div>
</div>
</CardContent>
</Card>
);
export default function MemberResourcesPortal({
orgId
}: MemberResourcesPortalProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("name-asc");
const [refreshing, setRefreshing] = useState(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 12; // 3x4 grid on desktop
const fetchUserResources = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
const response = await api.get<GetUserResourcesResponse>(
`/org/${orgId}/user-resources`
);
if (response.data.success) {
setResources(response.data.data.resources);
setFilteredResources(response.data.data.resources);
} else {
setError("Failed to load resources");
}
} catch (err) {
console.error("Error fetching user resources:", err);
setError(
"Failed to load resources. Please check your connection and try again."
);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchUserResources();
}, [orgId, api]);
// Filter and sort resources
useEffect(() => {
const filtered = resources.filter(
(resource) =>
resource.name
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resource.domain
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
// Sort resources
filtered.sort((a, b) => {
switch (sortBy) {
case "name-asc":
return a.name.localeCompare(b.name);
case "name-desc":
return b.name.localeCompare(a.name);
case "domain-asc":
return a.domain.localeCompare(b.domain);
case "domain-desc":
return b.domain.localeCompare(a.domain);
case "status-enabled":
// Enabled first, then protected vs unprotected
if (a.enabled !== b.enabled) return b.enabled ? 1 : -1;
return b.protected ? 1 : -1;
case "status-disabled":
// Disabled first, then unprotected vs protected
if (a.enabled !== b.enabled) return a.enabled ? 1 : -1;
return a.protected ? 1 : -1;
default:
return a.name.localeCompare(b.name);
}
});
setFilteredResources(filtered);
// Reset to first page when search/sort changes
setCurrentPage(1);
}, [resources, searchQuery, sortBy]);
// Calculate pagination
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedResources = filteredResources.slice(
startIndex,
startIndex + itemsPerPage
);
const handleOpenResource = (resource: Resource) => {
// Open the resource in a new tab
window.open(resource.domain, "_blank");
};
const handleRefresh = () => {
fetchUserResources(true);
};
const handleRetry = () => {
fetchUserResources();
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
// Scroll to top when page changes
window.scrollTo({ top: 0, behavior: "smooth" });
};
if (loading) {
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
{/* Search and Sort Controls - Skeleton */}
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
<div className="relative w-full sm:w-80">
<div className="h-10 bg-muted rounded animate-pulse"></div>
</div>
<div className="w-full sm:w-36">
<div className="h-10 bg-muted rounded animate-pulse"></div>
</div>
</div>
{/* Loading Skeletons */}
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
{Array.from({ length: 12 }).map((_, index) => (
<ResourceCardSkeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6">
<AlertCircle className="h-16 w-16 text-destructive/60" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">
Unable to Load Resources
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{error}
</p>
<Button
onClick={handleRetry}
variant="outline"
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
Try Again
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
{/* Search and Sort Controls with Refresh */}
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
{/* Search */}
<div className="relative w-full sm:w-80">
<Input
placeholder="Search resources..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 bg-card"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
{/* Sort */}
<div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">
Name A-Z
</SelectItem>
<SelectItem value="name-desc">
Name Z-A
</SelectItem>
<SelectItem value="domain-asc">
Domain A-Z
</SelectItem>
<SelectItem value="domain-desc">
Domain Z-A
</SelectItem>
<SelectItem value="status-enabled">
Enabled First
</SelectItem>
<SelectItem value="status-disabled">
Disabled First
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Refresh Button */}
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={refreshing}
className="gap-2 shrink-0"
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{/* Resources Content */}
{filteredResources.length === 0 ? (
/* Enhanced Empty State */
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
{searchQuery ? (
<Search className="h-12 w-12 text-muted-foreground/70" />
) : (
<Globe className="h-12 w-12 text-muted-foreground/70" />
)}
</div>
<h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery
? "No Resources Found"
: "No Resources Available"}
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? (
<Button
onClick={() => setSearchQuery("")}
variant="outline"
className="gap-2"
>
Clear Search
</Button>
) : (
<Button
onClick={handleRefresh}
variant="outline"
disabled={refreshing}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh Resources
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<>
{/* Resources Grid */}
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
{paginatedResources.map((resource) => (
<Card key={resource.resourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{resource.name}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{resource.name}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<ResourceInfo resource={resource} />
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() =>
handleOpenResource(resource)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</Card>
))}
</div>
{/* Pagination Controls */}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
totalItems={filteredResources.length}
itemsPerPage={itemsPerPage}
/>
</>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { cache } from "react";
import OrganizationLandingCard from "./OrganizationLandingCard";
import MemberResourcesPortal from "./MemberResourcesPortal";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { ListUserOrgsResponse } from "@server/routers/org";
import { pullEnv } from "@app/lib/pullEnv";
import EnvProvider from "@app/providers/EnvProvider";
import { orgLangingNavItems } from "@app/app/navigation";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@ -17,6 +21,7 @@ type OrgPageProps = {
export default async function OrgPage(props: OrgPageProps) {
const params = await props.params;
const orgId = params.orgId;
const env = pullEnv();
const getUser = cache(verifySession);
const user = await getUser();
@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) {
redirect("/");
}
let redirectToSettings = false;
let overview: GetOrgOverviewResponse | undefined;
try {
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) {
await authCookieHeader()
);
overview = res.data.data;
if (overview.isAdmin || overview.isOwner) {
redirectToSettings = true;
}
} catch (e) {}
if (redirectToSettings) {
// If user is admin or owner, redirect to settings
if (overview?.isAdmin || overview?.isOwner) {
redirect(`/${orgId}/settings`);
}
// For non-admin users, show the member resources portal
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
@ -60,24 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
return (
<UserProvider user={user}>
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard
overview={{
orgId: overview.orgId,
orgName: overview.orgName,
stats: {
users: overview.numUsers,
sites: overview.numSites,
resources: overview.numResources
},
isAdmin: overview.isAdmin,
isOwner: overview.isOwner,
userRole: overview.userRoleName
}}
/>
</div>
)}
{overview && <MemberResourcesPortal orgId={orgId} />}
</Layout>
</UserProvider>
);

View file

@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateClientFormModal from "./CreateClientsModal";
export type ClientRow = {
id: number;
@ -76,42 +75,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
};
const columns: ColumnDef<ClientRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const clientRow = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
@ -243,6 +206,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const clientRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
@ -259,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
return (
<>
<CreateClientFormModal
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreate={(val) => {
setRows([val, ...rows]);
}}
orgId={orgId}
/>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
@ -309,7 +290,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
columns={columns}
data={rows}
addClient={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/clients/create`)
}}
/>
</>

View file

@ -1,349 +0,0 @@
"use client";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import { Checkbox } from "@app/components/ui/checkbox";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { ClientRow } from "./ClientsTable";
import {
CreateClientBody,
CreateClientResponse,
PickClientDefaultsResponse
} from "@server/routers/client";
import { ListSitesResponse } from "@server/routers/site";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { ScrollArea } from "@app/components/ui/scroll-area";
import { Badge } from "@app/components/ui/badge";
import { X } from "lucide-react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const createClientFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(30, {
message: "Name must not be longer than 30 characters."
}),
siteIds: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: "At least one site is required."
}),
subnet: z.string().min(1, {
message: "Subnet is required."
})
});
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
const defaultValues: Partial<CreateClientFormValues> = {
name: "",
siteIds: [],
subnet: ""
};
type CreateClientFormProps = {
onCreate?: (client: ClientRow) => void;
setLoading?: (loading: boolean) => void;
setChecked?: (checked: boolean) => void;
orgId: string;
};
export default function CreateClientForm({
onCreate,
setLoading,
setChecked,
orgId
}: CreateClientFormProps) {
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const [sites, setSites] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const [olmCommand, setOlmCommand] = useState<string | null>(null);
const [selectedSites, setSelectedSites] = useState<
Array<{ id: number; name: string }>
>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked);
if (setChecked) {
setChecked(checked);
}
};
const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema),
defaultValues
});
useEffect(() => {
if (!open) return;
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setClientDefaults(null);
setSelectedSites([]);
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet
);
setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
};
const fetchDefaults = async () => {
api.get(`/org/${orgId}/pick-client-defaults`)
.catch((e) => {
toast({
variant: "destructive",
title: `Error fetching client defaults`,
description: formatAxiosError(e)
});
})
.then((res) => {
if (res && res.status === 200) {
const data = res.data.data;
setClientDefaults(data);
const olmConfig = `olm --id ${data?.olmId} --secret ${data?.olmSecret} --endpoint ${env.app.dashboardUrl}`;
setOlmCommand(olmConfig);
// Set the subnet value from client defaults
if (data?.subnet) {
form.setValue("subnet", data.subnet);
}
}
});
};
fetchSites();
fetchDefaults();
}, [open]);
async function onSubmit(data: CreateClientFormValues) {
setLoading?.(true);
setIsLoading(true);
if (!clientDefaults) {
toast({
variant: "destructive",
title: "Error creating client",
description: "Client defaults not found"
});
setLoading?.(false);
setIsLoading(false);
return;
}
const payload = {
name: data.name,
siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret,
subnet: data.subnet,
type: "olm"
} as CreateClientBody;
const res = await api
.put<
AxiosResponse<CreateClientResponse>
>(`/org/${orgId}/client`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating client",
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
onCreate?.({
name: data.name,
id: data.clientId,
subnet: data.subnet,
mbIn: "0 MB",
mbOut: "0 MB",
orgId: orgId as string,
online: false
});
}
setLoading?.(false);
setIsLoading(false);
}
return (
<div className="space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Client name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Subnet"
{...field}
/>
</FormControl>
<FormDescription>
The address that this client will use for
connectivity.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder="Select sites"
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
form.setValue(
"siteIds",
newTags as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={sites}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
<FormDescription>
The client will have connectivity to the
selected sites. The sites must be configured
to accept client connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{olmCommand && (
<div className="w-full">
<div className="mb-2">
<div className="mx-auto">
<CopyTextBox
text={olmCommand}
wrapText={false}
/>
</div>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the configuration
once.
</span>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
</label>
</div>
</form>
</Form>
</div>
);
}

View file

@ -1,80 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import CreateClientForm from "./CreateClientsForm";
import { ClientRow } from "./ClientsTable";
type CreateClientFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreate?: (client: ClientRow) => void;
orgId: string;
};
export default function CreateClientFormModal({
open,
setOpen,
onCreate,
orgId
}: CreateClientFormProps) {
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Client</CredenzaTitle>
<CredenzaDescription>
Create a new client to connect to your sites
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="max-w-md">
<CreateClientForm
setLoading={(val) => setLoading(val)}
setChecked={(val) => setIsChecked(val)}
onCreate={onCreate}
orgId={orgId}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="create-client-form"
loading={loading}
disabled={loading || !isChecked}
onClick={() => {
setOpen(false);
}}
>
Create Client
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -9,38 +9,40 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type ClientInfoCardProps = {};
export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext();
const t = useTranslations();
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Client Information</AlertTitle>
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{client.subnet.split("/")[0]}
</InfoSectionContent>

View file

@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { ListSitesResponse } from "@server/routers/site";
import { useTranslations } from "next-intl";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const t = useTranslations();
const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
@ -119,18 +121,18 @@ export default function GeneralPage() {
updateClient({ name: data.name });
toast({
title: "Client updated",
description: "The client has been updated."
title: t("clientUpdated"),
description: t("clientUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to update client",
title: t("clientUpdateFailed"),
description: formatAxiosError(
e,
"An error occurred while updating the client."
t("clientUpdateError")
)
});
} finally {
@ -143,10 +145,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
{t("generalSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this client
{t("generalSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@ -163,15 +165,11 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
client.
</FormDescription>
</FormItem>
)}
/>
@ -181,12 +179,12 @@ export default function GeneralPage() {
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel>
<FormLabel>{t("sites")}</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder="Select sites"
placeholder={t("selectSites")}
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
@ -202,9 +200,7 @@ export default function GeneralPage() {
sortTags={true}
/>
<FormDescription>
The client will have connectivity to the
selected sites. The sites must be configured
to accept client connections.
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
@ -222,7 +218,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save Settings
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View file

@ -0,0 +1,708 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { InfoIcon, Terminal } from "lucide-react";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
FaApple,
FaCubes,
FaDocker,
FaFreebsd,
FaWindows
} from "react-icons/fa";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
CreateClientBody,
CreateClientResponse,
PickClientDefaultsResponse
} from "@server/routers/client";
import { ListSitesResponse } from "@server/routers/site";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useTranslations } from "next-intl";
type ClientType = "olm";
interface TunnelTypeOption {
id: ClientType;
title: string;
description: string;
disabled?: boolean;
}
type Commands = {
mac: Record<string, string[]>;
linux: Record<string, string[]>;
windows: Record<string, string[]>;
};
const platforms = ["linux", "mac", "windows"] as const;
type Platform = (typeof platforms)[number];
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const createClientFormSchema = z.object({
name: z
.string()
.min(2, { message: t("nameMin", { len: 2 }) })
.max(30, { message: t("nameMax", { len: 30 }) }),
method: z.enum(["olm"]),
siteIds: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: t("siteRequired")
}),
subnet: z.string().ip().min(1, {
message: t("subnetRequired")
})
});
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
const [tunnelTypes, setTunnelTypes] = useState<
ReadonlyArray<TunnelTypeOption>
>([
{
id: "olm",
title: t("olmTunnel"),
description: t("olmTunnelDescription"),
disabled: true
}
]);
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState("amd64");
const [commands, setCommands] = useState<Commands | null>(null);
const [olmId, setOlmId] = useState("");
const [olmSecret, setOlmSecret] = useState("");
const [olmCommand, setOlmCommand] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const hydrateCommands = (
id: string,
secret: string,
endpoint: string,
version: string
) => {
const commands = {
mac: {
"Apple Silicon (arm64)": [
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
"Intel x64 (amd64)": [
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
linux: {
amd64: [
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm64: [
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32: [
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32v6: [
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
riscv64: [
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`,
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
windows: {
x64: [
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
`# Run the installer to install olm and wintun`,
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
}
};
setCommands(commands);
};
const getArchitectures = () => {
switch (platform) {
case "linux":
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
case "mac":
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
case "windows":
return ["x64"];
default:
return ["x64"];
}
};
const getPlatformName = (platformName: string) => {
switch (platformName) {
case "windows":
return "Windows";
case "mac":
return "macOS";
case "docker":
return "Docker";
default:
return "Linux";
}
};
const getCommand = () => {
const placeholder = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
let platformCommands = commands[platform as keyof Commands];
if (!platformCommands) {
// get first key
const firstPlatform = Object.keys(commands)[0] as Platform;
platformCommands = commands[firstPlatform as keyof Commands];
setPlatform(firstPlatform);
}
let architectureCommands = platformCommands[architecture];
if (!architectureCommands) {
// get first key
const firstArchitecture = Object.keys(platformCommands)[0];
architectureCommands = platformCommands[firstArchitecture];
setArchitecture(firstArchitecture);
}
return architectureCommands || placeholder;
};
const getPlatformIcon = (platformName: string) => {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "mac":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "podman":
return <FaCubes className="h-4 w-4 mr-2" />;
case "freebsd":
return <FaFreebsd className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
}
};
const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema),
defaultValues: {
name: "",
method: "olm",
siteIds: [],
subnet: ""
}
});
async function onSubmit(data: CreateClientFormValues) {
setCreateLoading(true);
if (!clientDefaults) {
toast({
variant: "destructive",
title: t("errorCreatingClient"),
description: t("clientDefaultsNotFound")
});
setCreateLoading(false);
return;
}
let payload: CreateClientBody = {
name: data.name,
type: data.method as "olm",
siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret,
subnet: data.subnet
};
const res = await api
.put<
AxiosResponse<CreateClientResponse>
>(`/org/${orgId}/client`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: t("errorCreatingClient"),
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
router.push(`/${orgId}/settings/clients/${data.clientId}`);
}
setCreateLoading(false);
}
useEffect(() => {
const load = async () => {
setLoadingPage(true);
// Fetch available sites
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet
);
setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
let olmVersion = "latest";
try {
const response = await fetch(
`https://api.github.com/repos/fosrl/olm/releases/latest`
);
if (!response.ok) {
throw new Error(
t("olmErrorFetchReleases", {
err: response.statusText
})
);
}
const data = await response.json();
const latestVersion = data.tag_name;
olmVersion = latestVersion;
} catch (error) {
console.error(
t("olmErrorFetchLatest", {
err:
error instanceof Error
? error.message
: String(error)
})
);
}
await api
.get(`/org/${orgId}/pick-client-defaults`)
.catch((e) => {
form.setValue("method", "olm");
})
.then((res) => {
if (res && res.status === 200) {
const data = res.data.data;
setClientDefaults(data);
const olmId = data.olmId;
const olmSecret = data.olmSecret;
const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`;
setOlmId(olmId);
setOlmSecret(olmSecret);
setOlmCommand(olmCommand);
hydrateCommands(
olmId,
olmSecret,
env.app.dashboardUrl,
olmVersion
);
if (data.subnet) {
form.setValue("subnet", data.subnet);
}
setTunnelTypes((prev: any) => {
return prev.map((item: any) => {
return { ...item, disabled: false };
});
});
}
});
setLoadingPage(false);
};
load();
}, []);
return (
<>
<div className="flex justify-between">
<HeaderTitle
title={t("createClient")}
description={t("createClientDescription")}
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/clients`);
}}
>
{t("seeAllClients")}
</Button>
</div>
{!loadingPage && (
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("clientInformation")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("address")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t("subnetPlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("addressDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("sites")}
</FormLabel>
<TagInput
{...field}
activeTagIndex={
activeSitesTagIndex
}
setActiveTagIndex={
setActiveSitesTagIndex
}
placeholder={t("selectSites")}
size="sm"
tags={
form.getValues()
.siteIds
}
setTags={(
olmags
) => {
form.setValue(
"siteIds",
olmags as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
sites
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{form.watch("method") === "olm" && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("clientOlmCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("clientOlmCredentialsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("olmEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={
env.app.dashboardUrl
}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("olmId")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={olmId}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("olmSecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={olmSecret}
/>
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("clientCredentialsSave")}
</AlertTitle>
<AlertDescription>
{t(
"clientCredentialsSaveDescription"
)}
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("clientInstallOlm")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("clientInstallOlmDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">
{t("operatingSystem")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{platforms.map((os) => (
<Button
key={os}
variant={
platform === os
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<div>
<p className="font-bold mb-3">
{["docker", "podman"].includes(
platform
)
? t("method")
: t("architecture")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures().map(
(arch) => (
<Button
key={arch}
variant={
architecture ===
arch
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() =>
setArchitecture(
arch
)
}
>
{arch}
</Button>
)
)}
</div>
<div className="pt-4">
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
</div>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/clients`);
}}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("createClient")}
</Button>
</div>
</div>
)}
</>
);
}

View file

@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
return (
<>
<SettingsSectionTitle
title="Manage Clients"
title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites"
/>

View file

@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
type ResourceInfoBoxType = {};
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('resourceInfo')}
{t("resourceInfo")}
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<>
<InfoSection>
<InfoSectionTitle>
{t('authentication')}
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t('protected')}</span>
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span>
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : (
<>
<InfoSection>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
<InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/>
</InfoSectionContent>
</InfoSection>
{build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)}
</>
)}
<InfoSection>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled ? t('enabled') : t('disabled')}
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>

View file

@ -66,6 +66,7 @@ import {
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Credenza,
CredenzaBody,
@ -78,6 +79,7 @@ import {
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
const TransferFormSchema = z.object({
siteId: z.number()
@ -118,25 +120,31 @@ export default function GeneralForm() {
fullDomain: string;
} | null>(null);
const GeneralFormSchema = z.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
domainId: z.string().optional(),
proxyPort: z.number().int().min(1).max(65535).optional()
}).refine((data) => {
// For non-HTTP resources, proxyPort should be defined
if (!resource.http) {
return data.proxyPort !== undefined;
}
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
}, {
message: !resource.http
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
});
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
domainId: z.string().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
enableProxy: z.boolean().optional()
})
.refine(
(data) => {
// For non-HTTP resources, proxyPort should be defined
if (!resource.http) {
return data.proxyPort !== undefined;
}
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
},
{
message: !resource.http
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -147,7 +155,8 @@ export default function GeneralForm() {
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined
proxyPort: resource.proxyPort || undefined,
enableProxy: resource.enableProxy || false
},
mode: "onChange"
});
@ -211,7 +220,10 @@ export default function GeneralForm() {
name: data.name,
subdomain: data.subdomain,
domainId: data.domainId,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
...(!resource.http && {
enableProxy: data.enableProxy
})
}
)
.catch((e) => {
@ -238,7 +250,10 @@ export default function GeneralForm() {
name: data.name,
subdomain: data.subdomain,
fullDomain: resource.fullDomain,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
...(!resource.http && {
enableProxy: data.enableProxy
}),
});
router.refresh();
@ -357,16 +372,29 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t("resourcePortNumber")}
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) =>
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e.target.value
? parseInt(e.target.value)
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
@ -374,11 +402,49 @@ export default function GeneralForm() {
</FormControl>
<FormMessage />
<FormDescription>
{t("resourcePortNumberDescription")}
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{build == "oss" && (
<FormField
control={form.control}
name="enableProxy"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
</>
)}
@ -574,6 +640,7 @@ export default function GeneralForm() {
<CredenzaBody>
<DomainPicker
orgId={orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,

View file

@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
@ -64,6 +65,7 @@ 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";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@ -72,13 +74,14 @@ const baseResourceFormSchema = z.object({
});
const httpResourceFormSchema = z.object({
domainId: z.string().optional(),
domainId: z.string().nonempty(),
subdomain: z.string().optional()
});
const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
proxyPort: z.number().int().min(1).max(65535)
proxyPort: z.number().int().min(1).max(65535),
enableProxy: z.boolean().default(false)
});
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
@ -144,7 +147,8 @@ export default function Page() {
resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: {
protocol: "tcp",
proxyPort: undefined
proxyPort: undefined,
enableProxy: false
}
});
@ -163,16 +167,17 @@ export default function Page() {
if (isHttp) {
const httpData = httpForm.getValues();
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
protocol: "tcp",
});
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
protocol: "tcp"
});
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
protocol: tcpUdpData.protocol,
proxyPort: tcpUdpData.proxyPort
proxyPort: tcpUdpData.proxyPort,
enableProxy: tcpUdpData.enableProxy
});
}
@ -198,8 +203,15 @@ export default function Page() {
if (isHttp) {
router.push(`/${orgId}/settings/resources/${id}`);
} else {
setShowSnippets(true);
router.refresh();
const tcpUdpData = tcpUdpForm.getValues();
// Only show config snippets if enableProxy is explicitly true
if (tcpUdpData.enableProxy === true) {
setShowSnippets(true);
router.refresh();
} else {
// If enableProxy is false or undefined, go directly to resource page
router.push(`/${orgId}/settings/resources/${id}`);
}
}
}
} catch (e) {
@ -265,9 +277,9 @@ export default function Page() {
if (res?.status === 200) {
const domains = res.data.data.domains;
setBaseDomains(domains);
if (domains.length) {
httpForm.setValue("domainId", domains[0].domainId);
}
// if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId);
// }
}
};
@ -603,6 +615,46 @@ export default function Page() {
</FormItem>
)}
/>
{build == "oss" && (
<FormField
control={
tcpUdpForm.control
}
name="enableProxy"
render={({
field
}) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
@ -632,6 +684,8 @@ export default function Page() {
? await httpForm.trigger()
: await tcpUdpForm.trigger();
console.log(httpForm.getValues());
if (baseValid && settingsValid) {
onSubmit();
}

View file

@ -33,10 +33,17 @@ import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
dockerSocketEnabled: z.boolean().optional()
dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -44,9 +51,11 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { site, updateSite } = useSiteContext();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
const router = useRouter();
const t = useTranslations();
@ -55,7 +64,13 @@ export default function GeneralPage() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
remoteSubnets: site?.remoteSubnets
? site.remoteSubnets.split(',').map((subnet, index) => ({
id: subnet.trim(),
text: subnet.trim()
}))
: []
},
mode: "onChange"
});
@ -66,7 +81,8 @@ export default function GeneralPage() {
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
})
.catch((e) => {
toast({
@ -81,7 +97,8 @@ export default function GeneralPage() {
updateSite({
name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
});
toast({
@ -124,12 +141,47 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t("siteNameDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="remoteSubnets"
render={({ field }) => (
<FormItem>
<FormLabel>{t("remoteSubnets")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeCidrTagIndex}
setActiveTagIndex={setActiveCidrTagIndex}
placeholder={t("enterCidrRange")}
size="sm"
tags={form.getValues().remoteSubnets || []}
setTags={(newSubnets) => {
form.setValue(
"remoteSubnets",
newSubnets as Tag[]
);
}}
validateTag={(tag) => {
// Basic CIDR validation regex
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
return cidrRegex.test(tag);
}}
allowDuplicates={false}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t("remoteSubnetsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{site && site.type === "newt" && (
<FormField
control={form.control}

View file

@ -42,6 +42,7 @@ import {
FaFreebsd,
FaWindows
} from "react-icons/fa";
import { SiNixos } from "react-icons/si";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
@ -74,6 +75,7 @@ type Commands = {
windows: Record<string, string[]>;
docker: Record<string, string[]>;
podman: Record<string, string[]>;
nixos: Record<string, string[]>;
};
const platforms = [
@ -82,7 +84,8 @@ const platforms = [
"podman",
"mac",
"windows",
"freebsd"
"freebsd",
"nixos"
] as const;
type Platform = (typeof platforms)[number];
@ -285,6 +288,14 @@ WantedBy=default.target`
"Podman Run": [
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
nixos: {
x86_64: [
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
aarch64: [
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
}
};
setCommands(commands);
@ -304,6 +315,8 @@ WantedBy=default.target`
return ["Podman Quadlet", "Podman Run"];
case "freebsd":
return ["amd64", "arm64"];
case "nixos":
return ["x86_64", "aarch64"];
default:
return ["x64"];
}
@ -321,6 +334,8 @@ WantedBy=default.target`
return "Podman";
case "freebsd":
return "FreeBSD";
case "nixos":
return "NixOS";
default:
return "Linux";
}
@ -365,6 +380,8 @@ WantedBy=default.target`
return <FaCubes className="h-4 w-4 mr-2" />;
case "freebsd":
return <FaFreebsd className="h-4 w-4 mr-2" />;
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
}
@ -587,11 +604,6 @@ WantedBy=default.target`
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>

View file

@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@ -33,6 +34,7 @@ import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build";
type SignupFormProps = {
redirect?: string;
@ -44,7 +46,19 @@ const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema,
confirmPassword: passwordSchema
confirmPassword: passwordSchema,
agreeToTerms: z.boolean().refine(
(val) => {
if (build === "saas") {
val === true;
}
return true;
},
{
message:
"You must agree to the terms of service and privacy policy"
}
)
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
@ -64,13 +78,15 @@ export default function SignupForm({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: ""
confirmPassword: "",
agreeToTerms: false
}
});
@ -85,7 +101,8 @@ export default function SignupForm({
email,
password,
inviteId,
inviteToken
inviteToken,
termsAcceptedTimestamp: termsAgreedAt
})
.catch((e) => {
console.error(e);
@ -120,14 +137,23 @@ export default function SignupForm({
return t("authCreateAccount");
}
const handleTermsChange = (checked: boolean) => {
if (checked) {
const isoNow = new Date().toISOString();
console.log("Terms agreed at:", isoNow);
setTermsAgreedAt(isoNow);
form.setValue("agreeToTerms", true);
} else {
form.setValue("agreeToTerms", false);
setTermsAgreedAt(null);
}
};
return (
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={58}
width={175}
/>
<BrandingLogo height={58} width={175} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@ -180,6 +206,54 @@ export default function SignupForm({
</FormItem>
)}
/>
{build === "saas" && (
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpTerms.IAgreeToThe")}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}
</a>
{t("signUpTerms.and")}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
)}
{error && (
<Alert variant="destructive">

View file

@ -12,15 +12,24 @@ import {
KeyRound,
TicketCheck,
User,
Globe,
MonitorUp
Globe, // Added from 'dev' branch
MonitorUp // Added from 'dev' branch
} from "lucide-react";
export type SidebarNavSection = {
export type SidebarNavSection = { // Added from 'dev' branch
heading: string;
items: SidebarNavItem[];
};
// Merged from 'user-management-and-resources' branch
export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "sidebarAccount",
href: "/{orgId}",
icon: <User className="h-4 w-4" />
}
];
export const orgNavSections = (
enableClients: boolean = true
): SidebarNavSection[] => [

View file

@ -49,6 +49,7 @@ type DomainOption = {
interface DomainPickerProps {
orgId: string;
cols?: number;
onDomainChange?: (domainInfo: {
domainId: string;
domainNamespaceId?: string;
@ -61,6 +62,7 @@ interface DomainPickerProps {
export default function DomainPicker({
orgId,
cols,
onDomainChange
}: DomainPickerProps) {
const { env } = useEnvContext();
@ -127,9 +129,6 @@ export default function DomainPicker({
if (!userInput.trim()) return options;
// Check if input is more than one level deep (contains multiple dots)
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
// Add organization domain options
organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") {
@ -309,6 +308,7 @@ export default function DomainPicker({
<Input
id="domain-input"
value={userInput}
className="max-w-xl"
onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods
const validInput = e.target.value.replace(
@ -316,6 +316,8 @@ export default function DomainPicker({
""
);
setUserInput(validInput);
// Clear selection when input changes
setSelectedOption(null);
}}
/>
<p className="text-sm text-muted-foreground">
@ -382,7 +384,7 @@ export default function DomainPicker({
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNoMatchingDomains", { userInput })}
{t("domainPickerNoMatchingDomains")}
</AlertDescription>
</Alert>
)}
@ -393,23 +395,25 @@ export default function DomainPicker({
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4" />
<h4 className="text-sm font-medium">
{t("domainPickerOrganizationDomains")}
</h4>
</div>
<div className="grid gap-2">
{build !== "oss" && (
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4" />
<h4 className="text-sm font-medium">
{t("domainPickerOrganizationDomains")}
</h4>
</div>
)}
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{organizationOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
: "border-input",
? "border-primary bg-primary/10"
: "border-input hover:bg-accent",
option.verified
? "cursor-pointer hover:bg-accent"
? "cursor-pointer"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
@ -456,10 +460,6 @@ export default function DomainPicker({
</p>
)}
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
@ -476,14 +476,14 @@ export default function DomainPicker({
{t("domainPickerProvidedDomains")}
</div>
</div>
<div className="grid gap-2">
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{providedOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
? "border-primary bg-primary/10"
: "border-input",
"cursor-pointer hover:bg-accent"
)}

View file

@ -63,7 +63,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [securityKeyLoading, setSecurityKeyLoading] = useState(false);
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const t = useTranslations();
const formSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
password: z
.string()
.min(8, { message: t('passwordRequirementsChars') })
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const form = useForm<z.infer<typeof formSchema>>({
@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true);
setSecurityKeyLoading(true);
setLoading(true);
setError(null);
try {
// Start WebAuthn authentication without email
const startRes = await api.post("/auth/security-key/authenticate/start", {});
const startRes = await api.post(
"/auth/security-key/authenticate/start",
{}
);
if (!startRes) {
setError(t('securityKeyAuthError', {
defaultValue: "Failed to start security key authentication"
}));
setError(
t("securityKeyAuthError", {
defaultValue:
"Failed to start security key authentication"
})
);
return;
}
@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{ credential },
{
headers: {
'X-Temp-Session-Id': tempSessionId
"X-Temp-Session-Id": tempSessionId
}
}
);
@ -136,39 +139,61 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
} catch (error: any) {
if (error.name === 'NotAllowedError') {
if (error.message.includes('denied permission')) {
setError(t('securityKeyPermissionDenied', {
defaultValue: "Please allow access to your security key to continue signing in."
}));
if (error.name === "NotAllowedError") {
if (error.message.includes("denied permission")) {
setError(
t("securityKeyPermissionDenied", {
defaultValue:
"Please allow access to your security key to continue signing in."
})
);
} else {
setError(t('securityKeyRemovedTooQuickly', {
defaultValue: "Please keep your security key connected until the sign-in process completes."
}));
setError(
t("securityKeyRemovedTooQuickly", {
defaultValue:
"Please keep your security key connected until the sign-in process completes."
})
);
}
} else if (error.name === 'NotSupportedError') {
setError(t('securityKeyNotSupported', {
defaultValue: "Your security key may not be compatible. Please try a different security key."
}));
} else if (error.name === "NotSupportedError") {
setError(
t("securityKeyNotSupported", {
defaultValue:
"Your security key may not be compatible. Please try a different security key."
})
);
} else {
setError(t('securityKeyUnknownError', {
defaultValue: "There was a problem using your security key. Please try again."
}));
setError(
t("securityKeyUnknownError", {
defaultValue:
"There was a problem using your security key. Please try again."
})
);
}
}
} catch (e: any) {
if (e.isAxiosError) {
setError(formatAxiosError(e, t('securityKeyAuthError', {
defaultValue: "Failed to authenticate with security key"
})));
setError(
formatAxiosError(
e,
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
)
);
} else {
console.error(e);
setError(e.message || t('securityKeyAuthError', {
defaultValue: "Failed to authenticate with security key"
}));
setError(
e.message ||
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
}
} finally {
setSecurityKeyLoading(false);
setLoading(false);
setShowSecurityKeyPrompt(false);
}
}
@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
setShowSecurityKeyPrompt(false);
try {
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password,
code
});
const res = await api.post<AxiosResponse<LoginResponse>>(
"/auth/login",
{
email,
password,
code
}
);
const data = res.data.data;
@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
if (data?.twoFactorSetupRequired) {
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
router.push(setupUrl);
return;
}
@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
} catch (e: any) {
if (e.isAxiosError) {
const errorMessage = formatAxiosError(e, t('loginError', {
defaultValue: "Failed to log in"
}));
const errorMessage = formatAxiosError(
e,
t("loginError", {
defaultValue: "Failed to log in"
})
);
setError(errorMessage);
return;
} else {
console.error(e);
setError(e.message || t('loginError', {
defaultValue: "Failed to log in"
}));
setError(
e.message ||
t("loginError", {
defaultValue: "Failed to log in"
})
);
return;
}
} finally {
@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res);
if (!res) {
setError(t('loginError'));
setError(t("loginError"));
return;
}
@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
<Alert>
<FingerprintIcon className="w-5 h-5 mr-2" />
<AlertDescription>
{t('securityKeyPrompt', {
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
{t("securityKeyPrompt", {
defaultValue:
"Please verify your identity using your security key. Make sure your security key is connected and ready."
})}
</AlertDescription>
</Alert>
@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
@ -320,18 +357,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
{t('passwordForgot')}
{t("passwordForgot")}
</Link>
</div>
</div>
<div className="flex flex-col space-y-2">
<Button type="submit" disabled={loading}>
{loading ? t('idpConnectingToProcess', {
defaultValue: "Connecting..."
}) : t('login', {
defaultValue: "Log in"
})}
<Button
type="submit"
disabled={loading}
loading={loading}
>
{t("login")}
</Button>
</div>
</form>
@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{mfaRequested && (
<>
<div className="text-center">
<h3 className="text-lg font-medium">
{t('otpAuth')}
</h3>
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
<p className="text-sm text-muted-foreground">
{t('otpAuthDescription')}
{t("otpAuthDescription")}
</p>
</div>
<Form {...mfaForm}>
@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(value: string) => {
onChange={(
value: string
) => {
field.onChange(value);
if (value.length === 6) {
mfaForm.handleSubmit(onSubmit)();
if (
value.length === 6
) {
mfaForm.handleSubmit(
onSubmit
)();
}
}}
>
@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading}
disabled={loading}
>
{t('otpAuthSubmit')}
{t("otpAuthSubmit")}
</Button>
)}
@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
variant="outline"
className="w-full"
onClick={initiateSecurityKeyAuth}
loading={securityKeyLoading}
disabled={securityKeyLoading || showSecurityKeyPrompt}
loading={loading}
disabled={loading || showSecurityKeyPrompt}
>
<FingerprintIcon className="w-4 h-4 mr-2" />
{t('securityKeyLogin', {
{t("securityKeyLogin", {
defaultValue: "Sign in with security key"
})}
</Button>
@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
{t('idpContinue')}
{t("idpContinue")}
</span>
</div>
</div>
@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset();
}}
>
{t('otpAuthBack')}
{t("otpAuthBack")}
</Button>
)}
</div>

View file

@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
interface InfoPopupProps {
text?: string;
info: string;
info?: string;
trigger?: React.ReactNode;
children?: React.ReactNode;
}
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const defaultTrigger = (
<Button
variant="ghost"
@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
{trigger ?? defaultTrigger}
</PopoverTrigger>
<PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p>
{children ||
(info && (
<p className="text-sm text-muted-foreground">
{info}
</p>
))}
</PopoverContent>
</Popover>
</div>

View file

@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:top-0 md:bottom-auto md:left-1/2 md:-translate-x-1/2 md:max-w-[420px]",
className
)}
{...props}