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> </strong>
</p> </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. 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"/> <img src="public/screenshots/hero.png" alt="Preview"/>

View file

@ -31,6 +31,7 @@ services:
- SYS_MODULE - SYS_MODULE
ports: ports:
- 51820:51820/udp - 51820:51820/udp
- 21820:21820/udp
- 443:443 # Port for traefik because of the network_mode - 443:443 # Port for traefik because of the network_mode
- 80:80 # 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 start_port: 51820
base_endpoint: "{{.DashboardDomain}}" base_endpoint: "{{.DashboardDomain}}"
orgs:
block_size: 24
subnet_group: 100.89.138.0/20
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"

View file

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

View file

@ -60,8 +60,23 @@ const (
) )
func main() { 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) 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 chosenContainer := Docker
if strings.EqualFold(inputContainer, "docker") { if strings.EqualFold(inputContainer, "docker") {

View file

@ -59,7 +59,6 @@
"siteErrorCreate": "Error creating site", "siteErrorCreate": "Error creating site",
"siteErrorCreateKeyPair": "Key pair or site defaults not found", "siteErrorCreateKeyPair": "Key pair or site defaults not found",
"siteErrorCreateDefaults": "Site defaults not found", "siteErrorCreateDefaults": "Site defaults not found",
"siteNameDescription": "This is the display name for the site.",
"method": "Method", "method": "Method",
"siteMethodDescription": "This is how you will expose connections.", "siteMethodDescription": "This is how you will expose connections.",
"siteLearnNewt": "Learn how to install Newt on your system", "siteLearnNewt": "Learn how to install Newt on your system",
@ -1094,7 +1093,7 @@
"sidebarAllUsers": "All Users", "sidebarAllUsers": "All Users",
"sidebarIdentityProviders": "Identity Providers", "sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients", "sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket", "enableDockerSocket": "Enable Docker Socket",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
@ -1196,7 +1195,7 @@
"sidebarExpand": "Expand", "sidebarExpand": "Expand",
"newtUpdateAvailable": "Update Available", "newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "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", "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
"domainPickerDescription": "Enter the full domain of the resource to see available options.", "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", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
@ -1206,7 +1205,7 @@
"domainPickerSortAsc": "A-Z", "domainPickerSortAsc": "A-Z",
"domainPickerSortDesc": "Z-A", "domainPickerSortDesc": "Z-A",
"domainPickerCheckingAvailability": "Checking availability...", "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", "domainPickerOrganizationDomains": "Organization Domains",
"domainPickerProvidedDomains": "Provided Domains", "domainPickerProvidedDomains": "Provided Domains",
"domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerSubdomain": "Subdomain: {subdomain}",
@ -1274,5 +1273,50 @@
"createDomainDnsPropagation": "DNS Propagation", "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.", "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", "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, boolean,
integer, integer,
bigint, bigint,
real real,
text
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm"; import { InferSelectModel } from "drizzle-orm";
@ -58,7 +59,8 @@ export const sites = pgTable("sites", {
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), 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", { export const resources = pgTable("resources", {
@ -92,7 +94,8 @@ export const resources = pgTable("resources", {
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false), stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"), tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader") setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {
@ -135,6 +138,8 @@ export const users = pgTable("user", {
twoFactorSecret: varchar("twoFactorSecret"), twoFactorSecret: varchar("twoFactorSecret"),
emailVerified: boolean("emailVerified").notNull().default(false), emailVerified: boolean("emailVerified").notNull().default(false),
dateCreated: varchar("dateCreated").notNull(), dateCreated: varchar("dateCreated").notNull(),
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
termsVersion: varchar("termsVersion"),
serverAdmin: boolean("serverAdmin").notNull().default(false) serverAdmin: boolean("serverAdmin").notNull().default(false)
}); });
@ -504,8 +509,8 @@ export const clients = pgTable("clients", {
name: varchar("name").notNull(), name: varchar("name").notNull(),
pubKey: varchar("pubKey"), pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(), subnet: varchar("subnet").notNull(),
megabytesIn: integer("bytesIn"), megabytesIn: real("bytesIn"),
megabytesOut: integer("bytesOut"), megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
lastPing: varchar("lastPing"), lastPing: varchar("lastPing"),
type: varchar("type").notNull(), // "olm" type: varchar("type").notNull(), // "olm"
@ -539,7 +544,7 @@ export const olmSessions = pgTable("clientSession", {
olmId: varchar("olmId") olmId: varchar("olmId")
.notNull() .notNull()
.references(() => olms.olmId, { onDelete: "cascade" }), .references(() => olms.olmId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const userClients = pgTable("userClients", { export const userClients = pgTable("userClients", {
@ -562,9 +567,11 @@ export const roleClients = pgTable("roleClients", {
export const securityKeys = pgTable("webauthnCredentials", { export const securityKeys = pgTable("webauthnCredentials", {
credentialId: varchar("credentialId").primaryKey(), credentialId: varchar("credentialId").primaryKey(),
userId: varchar("userId").notNull().references(() => users.userId, { userId: varchar("userId")
onDelete: "cascade" .notNull()
}), .references(() => users.userId, {
onDelete: "cascade"
}),
publicKey: varchar("publicKey").notNull(), publicKey: varchar("publicKey").notNull(),
signCount: integer("signCount").notNull(), signCount: integer("signCount").notNull(),
transports: varchar("transports"), transports: varchar("transports"),

View file

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

View file

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

View file

@ -229,9 +229,22 @@ export const configSchema = z
disable_local_sites: z.boolean().optional(), disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: 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() .optional()
.default({
nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"],
cname_extension: "fossorial.io"
})
}) })
.refine( .refine(
(data) => { (data) => {

View file

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

View file

@ -21,15 +21,14 @@ import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite"; import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { build } from "@server/build";
export const signupBodySchema = z.object({ export const signupBodySchema = z.object({
email: z email: z.string().toLowerCase().email(),
.string()
.toLowerCase()
.email(),
password: passwordSchema, password: passwordSchema,
inviteToken: z.string().optional(), inviteToken: z.string().optional(),
inviteId: z.string().optional() inviteId: z.string().optional(),
termsAcceptedTimestamp: z.string().nullable().optional()
}); });
export type SignUpBody = z.infer<typeof signupBodySchema>; 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 passwordHash = await hashPassword(password);
const userId = generateId(15); 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({ await db.insert(users).values({
userId: userId, userId: userId,
type: UserType.Internal, type: UserType.Internal,
username: email, username: email,
email: email, email: email,
passwordHash, passwordHash,
dateCreated: moment().toISOString() dateCreated: moment().toISOString(),
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
termsVersion: "1"
}); });
// give the user their default permissions: // give the user their default permissions:

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, exitNodes, sites } from "@server/db";
import { clients, clientSites } from "@server/db"; import { clients, clientSites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -17,6 +17,7 @@ import {
addPeer as olmAddPeer, addPeer as olmAddPeer,
deletePeer as olmDeletePeer deletePeer as olmDeletePeer
} from "../olm/peers"; } from "../olm/peers";
import axios from "axios";
const updateClientParamsSchema = z const updateClientParamsSchema = z
.object({ .object({
@ -53,6 +54,11 @@ registry.registerPath({
responses: {} responses: {}
}); });
interface PeerDestination {
destinationIP: string;
destinationPort: number;
}
export async function updateClient( export async function updateClient(
req: Request, req: Request,
res: Response, res: Response,
@ -124,15 +130,22 @@ export async function updateClient(
); );
for (const siteId of sitesAdded) { for (const siteId of sitesAdded) {
if (!client.subnet || !client.pubKey || !client.endpoint) { 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; 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, { const site = await newtAddPeer(siteId, {
publicKey: client.pubKey, publicKey: client.pubKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: client.endpoint endpoint: isRelayed ? "" : client.endpoint
}); });
if (!site) { if (!site) {
logger.debug("Failed to add peer to newt - missing site"); logger.debug("Failed to add peer to newt - missing site");
continue; continue;
@ -142,12 +155,49 @@ export async function updateClient(
logger.debug("Site endpoint or publicKey is not set"); logger.debug("Site endpoint or publicKey is not set");
continue; 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, { await olmAddPeer(client.clientId, {
siteId: siteId, siteId: site.siteId,
endpoint: site.endpoint, endpoint: endpoint,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, 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"); logger.debug("Site endpoint or publicKey is not set");
continue; 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 // Fetch the updated client
const [updatedClient] = await trx const [updatedClient] = await trx
.select() .select()

View file

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

View file

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

View file

@ -8,7 +8,7 @@ export async function addPeer(exitNodeId: number, peer: {
publicKey: string; publicKey: string;
allowedIps: 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); const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
if (!exitNode) { if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`); 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) { 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); const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
if (!exitNode) { if (!exitNode) {
throw new Error(`Exit node with ID ${exitNodeId} not found`); throw new Error(`Exit node with ID ${exitNodeId} not found`);

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; 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 { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -9,6 +9,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateNewtSessionToken } from "@server/auth/sessions/newt";
import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { validateOlmSessionToken } from "@server/auth/sessions/olm";
import axios from "axios";
// Define Zod schema for request validation // Define Zod schema for request validation
const updateHolePunchSchema = z.object({ const updateHolePunchSchema = z.object({
@ -17,7 +18,8 @@ const updateHolePunchSchema = z.object({
token: z.string(), token: z.string(),
ip: z.string(), ip: z.string(),
port: z.number(), port: z.number(),
timestamp: z.number() timestamp: z.number(),
reachableAt: z.string().optional()
}); });
// New response type with multi-peer destination support // New response type with multi-peer destination support
@ -43,9 +45,8 @@ 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 currentSiteId: number | undefined;
let destinations: PeerDestination[] = []; let destinations: PeerDestination[] = [];
@ -95,37 +96,129 @@ export async function updateHolePunch(
); );
} }
// Get all sites that this client is connected to // // Get all sites that this client is connected to
const clientSitePairs = await db // const clientSitePairs = await db
.select() // .select()
.from(clientSites) // .from(clientSites)
.where(eq(clientSites.clientId, client.clientId)); // .where(eq(clientSites.clientId, client.clientId));
if (clientSitePairs.length === 0) { // if (clientSitePairs.length === 0) {
logger.warn(`No sites found for client: ${client.clientId}`); // logger.warn(`No sites found for client: ${client.clientId}`);
return next( // return next(
createHttpError(HttpCode.NOT_FOUND, "No sites found for client") // createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
); // );
} // }
// Get all sites details // // Get all sites details
const siteIds = clientSitePairs.map(pair => pair.siteId); // const siteIds = clientSitePairs.map(pair => pair.siteId);
for (const siteId of siteIds) { // for (const siteId of siteIds) {
const [site] = await db // const [site] = await db
.select() // .select()
.from(sites) // .from(sites)
.where(eq(sites.siteId, siteId)); // .where(eq(sites.siteId, siteId));
if (site && site.subnet && site.listenPort) { // if (site && site.subnet && site.listenPort) {
destinations.push({ // destinations.push({
destinationIP: site.subnet.split("/")[0], // destinationIP: site.subnet.split("/")[0],
destinationPort: site.listenPort // destinationPort: site.listenPort
// });
// }
// }
// 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);
}
logger.debug(JSON.stringify(exitNodeDestinations, null, 2));
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;
}
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) { } else if (newtId) {
logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`);
const { session, newt: newtSession } = const { session, newt: newtSession } =
await validateNewtSessionToken(token); await validateNewtSessionToken(token);
@ -174,28 +267,29 @@ export async function updateHolePunch(
} }
// Find all clients that connect to this site // Find all clients that connect to this site
const sitesClientPairs = await db // const sitesClientPairs = await db
.select() // .select()
.from(clientSites) // .from(clientSites)
.where(eq(clientSites.siteId, newt.siteId)); // .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 // Get client details for each client
for (const pair of sitesClientPairs) { // for (const pair of sitesClientPairs) {
const [client] = await db // const [client] = await db
.select() // .select()
.from(clients) // .from(clients)
.where(eq(clients.clientId, pair.clientId)); // .where(eq(clients.clientId, pair.clientId));
if (client && client.endpoint) { // if (client && client.endpoint) {
const [host, portStr] = client.endpoint.split(':'); // const [host, portStr] = client.endpoint.split(':');
if (host && portStr) { // if (host && portStr) {
destinations.push({ // destinations.push({
destinationIP: host, // destinationIP: host,
destinationPort: parseInt(portStr, 10) // destinationPort: parseInt(portStr, 10)
}); // });
} // }
} // }
} // }
// If this is a newt/site, also add other sites in the same org // If this is a newt/site, also add other sites in the same org
// if (updatedSite.orgId) { // if (updatedSite.orgId) {

View file

@ -2,10 +2,18 @@ import { z } from "zod";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; 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 { 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 { updatePeer } from "../olm/peers";
import axios from "axios";
const inputSchema = z.object({ const inputSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@ -54,7 +62,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
logger.warn("handleGetConfigMessage: Site not found"); logger.warn("handleGetConfigMessage: Site not found");
return; return;
} }
// we need to wait for hole punch success // we need to wait for hole punch success
if (!existingSite.endpoint) { if (!existingSite.endpoint) {
logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`); logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`);
@ -87,6 +95,48 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
return; 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 // Get all clients connected to this site
const clientsRes = await db const clientsRes = await db
.select() .select()
@ -107,33 +157,59 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (!client.clients.endpoint) { if (!client.clients.endpoint) {
return false; return false;
} }
if (!client.clients.online) {
return false;
}
return true; return true;
}) })
.map(async (client) => { .map(async (client) => {
// Add or update this peer on the olm if it is connected // Add or update this peer on the olm if it is connected
try { try {
if (site.endpoint && site.publicKey) { if (!site.publicKey) {
await updatePeer(client.clients.clientId, { logger.warn(
siteId: site.siteId, `Site ${site.siteId} has no public key, skipping`
endpoint: site.endpoint, );
publicKey: site.publicKey, return null;
serverIP: site.address,
serverPort: site.listenPort
});
} }
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) { } catch (error) {
logger.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 { return {
publicKey: client.clients.pubKey!, 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 endpoint: client.clientSites.isRelayed
? "" ? ""
: client.clients.endpoint! // if its relayed it should be localhost : 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 // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); 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 // Build the configuration response
const configResponse = { const configResponse = {
ipAddress: site.address, ipAddress: site.address,
peers: validPeers peers: validPeers,
targets: {
udp: udpTargets,
tcp: tcpTargets
}
}; };
logger.debug("Sending config: ", configResponse); logger.debug("Sending config: ", configResponse);
return { return {
message: { message: {
type: "newt/wg/receive-config", type: "newt/wg/receive-config",

View file

@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
export function addTargets( export function addTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string protocol: string,
port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@ -13,19 +14,32 @@ export function addTargets(
}:${target.port}`; }:${target.port}`;
}); });
const payload = { sendToClient(newtId, {
type: `newt/${protocol}/add`, type: `newt/${protocol}/add`,
data: { data: {
targets: payloadTargets 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( export function removeTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string protocol: string,
port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@ -34,11 +48,23 @@ export function removeTargets(
}:${target.port}`; }:${target.port}`;
}); });
const payload = { sendToClient(newtId, {
type: `newt/${protocol}/remove`, type: `newt/${protocol}/remove`,
data: { data: {
targets: payloadTargets 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 { MessageHandler } from "../ws";
import { import {
clients, clients,
@ -28,7 +28,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
const clientId = olm.clientId; 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) { if (!publicKey) {
logger.warn("Public key not provided"); logger.warn("Public key not provided");
return; return;
@ -58,9 +61,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sendToClient(olm.olmId, { sendToClient(olm.olmId, {
type: "olm/wg/holepunch", type: "olm/wg/holepunch",
data: { data: {
serverPubKey: exitNode.publicKey serverPubKey: exitNode.publicKey,
endpoint: exitNode.endpoint,
} }
}); });
} }
if (now - (client.lastHolePunch || 0) > 6) { if (now - (client.lastHolePunch || 0) > 6) {
@ -84,7 +89,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await db await db
.update(clientSites) .update(clientSites)
.set({ .set({
isRelayed: false isRelayed: relay == true
}) })
.where(eq(clientSites.clientId, olm.clientId)); .where(eq(clientSites.clientId, olm.clientId));
} }
@ -97,7 +102,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.where(eq(clientSites.clientId, client.clientId)); .where(eq(clientSites.clientId, client.clientId));
// Prepare an array to store site configurations // 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 // Process each site
for (const { sites: site } of sitesData) { for (const { sites: site } of sitesData) {
@ -114,12 +127,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
continue; continue;
} }
if (site.lastHolePunch && now - site.lastHolePunch > 6) { // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
logger.warn( // logger.warn(
`Site ${site.siteId} last hole punch is too old, skipping` // `Site ${site.siteId} last hole punch is too old, skipping`
); // );
continue; // continue;
} // }
// If public key changed, delete old peer from this site // If public key changed, delete old peer from this site
if (client.pubKey && client.pubKey != publicKey) { if (client.pubKey && client.pubKey != publicKey) {
@ -142,7 +155,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await addPeer(site.siteId, { await addPeer(site.siteId, {
publicKey: publicKey, publicKey: publicKey,
allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client
endpoint: client.endpoint endpoint: relay ? "" : client.endpoint
}); });
} else { } else {
logger.warn( 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 // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({
siteId: site.siteId, siteId: site.siteId,
endpoint: site.endpoint, endpoint: endpoint,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, 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 // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
if (siteConfigurations.length === 0) { // if (siteConfigurations.length === 0) {
logger.warn("No valid site configurations found"); // logger.warn("No valid site configurations found");
return; // return;
} // }
// Return connect message with all site configurations // Return connect message with all site configurations
return { return {

View file

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

View file

@ -12,6 +12,7 @@ export async function addPeer(
endpoint: string; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access
} }
) { ) {
const [olm] = await db const [olm] = await db
@ -30,7 +31,8 @@ export async function addPeer(
publicKey: peer.publicKey, publicKey: peer.publicKey,
endpoint: peer.endpoint, endpoint: peer.endpoint,
serverIP: peer.serverIP, 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; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
remoteSubnets?: string | null; // optional, comma-separated list of subnets that
} }
) { ) {
const [olm] = await db const [olm] = await db
@ -84,7 +87,8 @@ export async function updatePeer(
publicKey: peer.publicKey, publicKey: peer.publicKey,
endpoint: peer.endpoint, endpoint: peer.endpoint,
serverIP: peer.serverIP, 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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, domains, orgDomains, resources } from "@server/db";
import { import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
newts, import { eq, and, inArray, sql } from "drizzle-orm";
newtSessions,
orgs,
sites,
userActions
} from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; 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)); await trx.delete(orgs).where(eq(orgs.orgId, orgId));
}); });
@ -136,8 +168,11 @@ export async function deleteOrg(
data: {} data: {}
}; };
// Don't await this to prevent blocking the response // Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch(error => { sendToClient(newtId, payload).catch((error) => {
logger.error("Failed to send termination message to newt:", error); logger.error(
"Failed to send termination message to newt:",
error
);
}); });
} }

View file

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

View file

@ -103,7 +103,8 @@ export async function deleteResource(
removeTargets( removeTargets(
newt.newtId, newt.newtId,
targetsToBeRemoved, 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

@ -21,4 +21,5 @@ export * from "./getExchangeToken";
export * from "./createResourceRule"; export * from "./createResourceRule";
export * from "./deleteResourceRule"; export * from "./deleteResourceRule";
export * from "./listResourceRules"; export * from "./listResourceRules";
export * from "./updateResourceRule"; export * from "./updateResourceRule";
export * from "./getUserResources";

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
const updateSiteParamsSchema = z const updateSiteParamsSchema = z
.object({ .object({
@ -20,6 +21,9 @@ const updateSiteBodySchema = z
.object({ .object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(), dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z
.string()
.optional()
// subdomain: z // subdomain: z
// .string() // .string()
// .min(1) // .min(1)
@ -85,6 +89,21 @@ export async function updateSite(
const { siteId } = parsedParams.data; const { siteId } = parsedParams.data;
const updateData = parsedBody.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 const updatedSite = await db
.update(sites) .update(sites)
.set(updateData) .set(updateData)

View file

@ -173,7 +173,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .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)) .where(eq(newts.siteId, site.siteId))
.limit(1); .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)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, [updatedTarget], resource.protocol); addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
} }
} }
return response(res, { return response(res, {

View file

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

View file

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

View file

@ -24,6 +24,7 @@ import m19 from "./scriptsSqlite/1.3.0";
import m20 from "./scriptsSqlite/1.5.0"; import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0"; import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0"; import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -47,6 +48,7 @@ const migrations = [
{ version: "1.5.0", run: m20 }, { version: "1.5.0", run: m20 },
{ version: "1.6.0", run: m21 }, { version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 }, { version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] 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 UserProvider from "@app/providers/UserProvider";
import { cache } from "react"; import { cache } from "react";
import OrganizationLandingCard from "./OrganizationLandingCard"; import OrganizationLandingCard from "./OrganizationLandingCard";
import MemberResourcesPortal from "./MemberResourcesPortal";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { ListUserOrgsResponse } from "@server/routers/org"; 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 = { type OrgPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -17,6 +21,7 @@ type OrgPageProps = {
export default async function OrgPage(props: OrgPageProps) { export default async function OrgPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
const orgId = params.orgId; const orgId = params.orgId;
const env = pullEnv();
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser();
@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) {
redirect("/"); redirect("/");
} }
let redirectToSettings = false;
let overview: GetOrgOverviewResponse | undefined; let overview: GetOrgOverviewResponse | undefined;
try { try {
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>( const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) {
await authCookieHeader() await authCookieHeader()
); );
overview = res.data.data; overview = res.data.data;
if (overview.isAdmin || overview.isOwner) {
redirectToSettings = true;
}
} catch (e) {} } catch (e) {}
if (redirectToSettings) { // If user is admin or owner, redirect to settings
if (overview?.isAdmin || overview?.isOwner) {
redirect(`/${orgId}/settings`); redirect(`/${orgId}/settings`);
} }
// For non-admin users, show the member resources portal
let orgs: ListUserOrgsResponse["orgs"] = []; let orgs: ListUserOrgsResponse["orgs"] = [];
try { try {
const getOrgs = cache(async () => const getOrgs = cache(async () =>
@ -60,24 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgId={orgId} navItems={[]} orgs={orgs}> <Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && ( {overview && <MemberResourcesPortal orgId={orgId} />}
<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>
)}
</Layout> </Layout>
</UserProvider> </UserProvider>
); );

View file

@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateClientFormModal from "./CreateClientsModal";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@ -76,42 +75,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
}; };
const columns: ColumnDef<ClientRow>[] = [ 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", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -243,6 +206,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const clientRow = row.original; const clientRow = row.original;
return ( return (
<div className="flex items-center justify-end"> <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 <Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`} href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
> >
@ -259,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
return ( return (
<> <>
<CreateClientFormModal
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreate={(val) => {
setRows([val, ...rows]);
}}
orgId={orgId}
/>
{selectedClient && ( {selectedClient && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isDeleteModalOpen}
@ -309,7 +290,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
columns={columns} columns={columns}
data={rows} data={rows}
addClient={() => { 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, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type ClientInfoCardProps = {}; type ClientInfoCardProps = {};
export default function SiteInfoCard({}: ClientInfoCardProps) { export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext(); const { client, updateClient } = useClientContext();
const t = useTranslations();
return ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Client Information</AlertTitle> <AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections cols={2}> <InfoSections cols={2}>
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.online ? ( {client.online ? (
<div className="text-green-500 flex items-center space-x-2"> <div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span> <span>{t("online")}</span>
</div> </div>
) : ( ) : (
<div className="text-neutral-500 flex items-center space-x-2"> <div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span> <span>{t("offline")}</span>
</div> </div>
)} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</> </>
<InfoSection> <InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle> <InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{client.subnet.split("/")[0]} {client.subnet.split("/")[0]}
</InfoSectionContent> </InfoSectionContent>

View file

@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { useTranslations } from "next-intl";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() { export default function GeneralPage() {
const t = useTranslations();
const { client, updateClient } = useClientContext(); const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -119,18 +121,18 @@ export default function GeneralPage() {
updateClient({ name: data.name }); updateClient({ name: data.name });
toast({ toast({
title: "Client updated", title: t("clientUpdated"),
description: "The client has been updated." description: t("clientUpdatedDescription")
}); });
router.refresh(); router.refresh();
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to update client", title: t("clientUpdateFailed"),
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while updating the client." t("clientUpdateError")
) )
}); });
} finally { } finally {
@ -143,10 +145,10 @@ export default function GeneralPage() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
General Settings {t("generalSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
Configure the general settings for this client {t("generalSettingsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@ -163,15 +165,11 @@ export default function GeneralPage() {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>{t("name")}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
This is the display name of the
client.
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />
@ -181,12 +179,12 @@ export default function GeneralPage() {
name="siteIds" name="siteIds"
render={(field) => ( render={(field) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel> <FormLabel>{t("sites")}</FormLabel>
<TagInput <TagInput
{...field} {...field}
activeTagIndex={activeSitesTagIndex} activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex} setActiveTagIndex={setActiveSitesTagIndex}
placeholder="Select sites" placeholder={t("selectSites")}
size="sm" size="sm"
tags={form.getValues().siteIds} tags={form.getValues().siteIds}
setTags={(newTags) => { setTags={(newTags) => {
@ -202,9 +200,7 @@ export default function GeneralPage() {
sortTags={true} sortTags={true}
/> />
<FormDescription> <FormDescription>
The client will have connectivity to the {t("sitesDescription")}
selected sites. The sites must be configured
to accept client connections.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -222,7 +218,7 @@ export default function GeneralPage() {
loading={loading} loading={loading}
disabled={loading} disabled={loading}
> >
Save Settings {t("saveSettings")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </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 ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Clients" title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites" 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 { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react"; import { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
{t('resourceInfo')} {t("resourceInfo")}
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections cols={4}> <InfoSections cols={4}>
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t('authentication')} {t("authentication")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{authInfo.password || {authInfo.password ||
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? ( authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500"> <div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" /> <ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t('protected')}</span> <span>{t("protected")}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center space-x-2 text-yellow-500"> <div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" /> <ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span> <span>{t("notProtected")}</span>
</div> </div>
)} )}
</InfoSectionContent> </InfoSectionContent>
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('site')}</InfoSectionTitle> <InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{resource.siteName} {resource.siteName}
</InfoSectionContent> </InfoSectionContent>
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : ( ) : (
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle> <InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span> <span>
{resource.protocol.toUpperCase()} {resource.protocol.toUpperCase()}
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('port')}</InfoSectionTitle> <InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<CopyToClipboard <CopyToClipboard
text={resource.proxyPort!.toString()} text={resource.proxyPort!.toString()}
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/> />
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)}
</> </>
)} )}
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle> <InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span> <span>
{resource.enabled ? t('enabled') : t('disabled')} {resource.enabled
? t("enabled")
: t("disabled")}
</span> </span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>

View file

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

View file

@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@ -72,13 +74,14 @@ const baseResourceFormSchema = z.object({
}); });
const httpResourceFormSchema = z.object({ const httpResourceFormSchema = z.object({
domainId: z.string().optional(), domainId: z.string().nonempty(),
subdomain: z.string().optional() subdomain: z.string().optional()
}); });
const tcpUdpResourceFormSchema = z.object({ const tcpUdpResourceFormSchema = z.object({
protocol: z.string(), 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>; type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
@ -144,7 +147,8 @@ export default function Page() {
resolver: zodResolver(tcpUdpResourceFormSchema), resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: { defaultValues: {
protocol: "tcp", protocol: "tcp",
proxyPort: undefined proxyPort: undefined,
enableProxy: false
} }
}); });
@ -163,16 +167,17 @@ export default function Page() {
if (isHttp) { if (isHttp) {
const httpData = httpForm.getValues(); const httpData = httpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain,
domainId: httpData.domainId, domainId: httpData.domainId,
protocol: "tcp", protocol: "tcp"
}); });
} else { } else {
const tcpUdpData = tcpUdpForm.getValues(); const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
protocol: tcpUdpData.protocol, protocol: tcpUdpData.protocol,
proxyPort: tcpUdpData.proxyPort proxyPort: tcpUdpData.proxyPort,
enableProxy: tcpUdpData.enableProxy
}); });
} }
@ -198,8 +203,15 @@ export default function Page() {
if (isHttp) { if (isHttp) {
router.push(`/${orgId}/settings/resources/${id}`); router.push(`/${orgId}/settings/resources/${id}`);
} else { } else {
setShowSnippets(true); const tcpUdpData = tcpUdpForm.getValues();
router.refresh(); // 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) { } catch (e) {
@ -265,9 +277,9 @@ export default function Page() {
if (res?.status === 200) { if (res?.status === 200) {
const domains = res.data.data.domains; const domains = res.data.data.domains;
setBaseDomains(domains); setBaseDomains(domains);
if (domains.length) { // if (domains.length) {
httpForm.setValue("domainId", domains[0].domainId); // httpForm.setValue("domainId", domains[0].domainId);
} // }
} }
}; };
@ -603,6 +615,46 @@ export default function Page() {
</FormItem> </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>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@ -632,6 +684,8 @@ export default function Page() {
? await httpForm.trigger() ? await httpForm.trigger()
: await tcpUdpForm.trigger(); : await tcpUdpForm.trigger();
console.log(httpForm.getValues());
if (baseValid && settingsValid) { if (baseValid && settingsValid) {
onSubmit(); onSubmit();
} }

View file

@ -33,10 +33,17 @@ import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), 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>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -44,9 +51,11 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() { export default function GeneralPage() {
const { site, updateSite } = useSiteContext(); const { site, updateSite } = useSiteContext();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@ -55,7 +64,13 @@ export default function GeneralPage() {
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: site?.name, 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" mode: "onChange"
}); });
@ -66,7 +81,8 @@ export default function GeneralPage() {
await api await api
.post(`/site/${site?.siteId}`, { .post(`/site/${site?.siteId}`, {
name: data.name, name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -81,7 +97,8 @@ export default function GeneralPage() {
updateSite({ updateSite({
name: data.name, name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
}); });
toast({ toast({
@ -124,12 +141,47 @@ export default function GeneralPage() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
{t("siteNameDescription")}
</FormDescription>
</FormItem> </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" && ( {site && site.type === "newt" && (
<FormField <FormField
control={form.control} control={form.control}

View file

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

View file

@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Form, Form,
FormControl, FormControl,
@ -33,6 +34,7 @@ import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo"; import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build";
type SignupFormProps = { type SignupFormProps = {
redirect?: string; redirect?: string;
@ -44,7 +46,19 @@ const formSchema = z
.object({ .object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema, 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, { .refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"], path: ["confirmPassword"],
@ -64,13 +78,15 @@ export default function SignupForm({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: "", email: "",
password: "", password: "",
confirmPassword: "" confirmPassword: "",
agreeToTerms: false
} }
}); });
@ -85,7 +101,8 @@ export default function SignupForm({
email, email,
password, password,
inviteId, inviteId,
inviteToken inviteToken,
termsAcceptedTimestamp: termsAgreedAt
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
@ -120,14 +137,23 @@ export default function SignupForm({
return t("authCreateAccount"); 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 ( return (
<Card className="w-full max-w-md shadow-md"> <Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b"> <CardHeader className="border-b">
<div className="flex flex-row items-center justify-center"> <div className="flex flex-row items-center justify-center">
<BrandingLogo <BrandingLogo height={58} width={175} />
height={58}
width={175}
/>
</div> </div>
<div className="text-center space-y-1 pt-3"> <div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p> <p className="text-muted-foreground">{getSubtitle()}</p>
@ -180,6 +206,54 @@ export default function SignupForm({
</FormItem> </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 && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">

View file

@ -12,15 +12,24 @@ import {
KeyRound, KeyRound,
TicketCheck, TicketCheck,
User, User,
Globe, Globe, // Added from 'dev' branch
MonitorUp MonitorUp // Added from 'dev' branch
} from "lucide-react"; } from "lucide-react";
export type SidebarNavSection = { export type SidebarNavSection = { // Added from 'dev' branch
heading: string; heading: string;
items: SidebarNavItem[]; 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 = ( export const orgNavSections = (
enableClients: boolean = true enableClients: boolean = true
): SidebarNavSection[] => [ ): SidebarNavSection[] => [

View file

@ -49,6 +49,7 @@ type DomainOption = {
interface DomainPickerProps { interface DomainPickerProps {
orgId: string; orgId: string;
cols?: number;
onDomainChange?: (domainInfo: { onDomainChange?: (domainInfo: {
domainId: string; domainId: string;
domainNamespaceId?: string; domainNamespaceId?: string;
@ -61,6 +62,7 @@ interface DomainPickerProps {
export default function DomainPicker({ export default function DomainPicker({
orgId, orgId,
cols,
onDomainChange onDomainChange
}: DomainPickerProps) { }: DomainPickerProps) {
const { env } = useEnvContext(); const { env } = useEnvContext();
@ -127,9 +129,6 @@ export default function DomainPicker({
if (!userInput.trim()) return options; 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 // Add organization domain options
organizationDomains.forEach((orgDomain) => { organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") { if (orgDomain.type === "cname") {
@ -309,6 +308,7 @@ export default function DomainPicker({
<Input <Input
id="domain-input" id="domain-input"
value={userInput} value={userInput}
className="max-w-xl"
onChange={(e) => { onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods // Only allow letters, numbers, hyphens, and periods
const validInput = e.target.value.replace( const validInput = e.target.value.replace(
@ -316,6 +316,8 @@ export default function DomainPicker({
"" ""
); );
setUserInput(validInput); setUserInput(validInput);
// Clear selection when input changes
setSelectedOption(null);
}} }}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -382,7 +384,7 @@ export default function DomainPicker({
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
{t("domainPickerNoMatchingDomains", { userInput })} {t("domainPickerNoMatchingDomains")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -393,23 +395,25 @@ export default function DomainPicker({
{/* Organization Domains */} {/* Organization Domains */}
{organizationOptions.length > 0 && ( {organizationOptions.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center space-x-2"> {build !== "oss" && (
<Building2 className="h-4 w-4" /> <div className="flex items-center space-x-2">
<h4 className="text-sm font-medium"> <Building2 className="h-4 w-4" />
{t("domainPickerOrganizationDomains")} <h4 className="text-sm font-medium">
</h4> {t("domainPickerOrganizationDomains")}
</div> </h4>
<div className="grid gap-2"> </div>
)}
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{organizationOptions.map((option) => ( {organizationOptions.map((option) => (
<div <div
key={option.id} key={option.id}
className={cn( className={cn(
"transition-all p-3 rounded-lg border", "transition-all p-3 rounded-lg border",
selectedOption?.id === option.id selectedOption?.id === option.id
? "border-primary bg-primary/5" ? "border-primary bg-primary/10"
: "border-input", : "border-input hover:bg-accent",
option.verified option.verified
? "cursor-pointer hover:bg-accent" ? "cursor-pointer"
: "cursor-not-allowed opacity-60" : "cursor-not-allowed opacity-60"
)} )}
onClick={() => onClick={() =>
@ -456,10 +460,6 @@ export default function DomainPicker({
</p> </p>
)} )}
</div> </div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div> </div>
</div> </div>
))} ))}
@ -476,14 +476,14 @@ export default function DomainPicker({
{t("domainPickerProvidedDomains")} {t("domainPickerProvidedDomains")}
</div> </div>
</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) => ( {providedOptions.map((option) => (
<div <div
key={option.id} key={option.id}
className={cn( className={cn(
"transition-all p-3 rounded-lg border", "transition-all p-3 rounded-lg border",
selectedOption?.id === option.id selectedOption?.id === option.id
? "border-primary bg-primary/5" ? "border-primary bg-primary/10"
: "border-input", : "border-input",
"cursor-pointer hover:bg-accent" "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 [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [securityKeyLoading, setSecurityKeyLoading] = useState(false);
const hasIdp = idps && idps.length > 0; const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false); const [mfaRequested, setMfaRequested] = useState(false);
@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const t = useTranslations(); const t = useTranslations();
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }), email: z.string().email({ message: t("emailInvalid") }),
password: z password: z.string().min(8, { message: t("passwordRequirementsChars") })
.string()
.min(8, { message: t('passwordRequirementsChars') })
}); });
const mfaSchema = z.object({ 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>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
async function initiateSecurityKeyAuth() { async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true); setShowSecurityKeyPrompt(true);
setSecurityKeyLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Start WebAuthn authentication without email // 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) { if (!startRes) {
setError(t('securityKeyAuthError', { setError(
defaultValue: "Failed to start security key authentication" t("securityKeyAuthError", {
})); defaultValue:
"Failed to start security key authentication"
})
);
return; return;
} }
@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{ credential }, { credential },
{ {
headers: { 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) { } catch (error: any) {
if (error.name === 'NotAllowedError') { if (error.name === "NotAllowedError") {
if (error.message.includes('denied permission')) { if (error.message.includes("denied permission")) {
setError(t('securityKeyPermissionDenied', { setError(
defaultValue: "Please allow access to your security key to continue signing in." t("securityKeyPermissionDenied", {
})); defaultValue:
"Please allow access to your security key to continue signing in."
})
);
} else { } else {
setError(t('securityKeyRemovedTooQuickly', { setError(
defaultValue: "Please keep your security key connected until the sign-in process completes." t("securityKeyRemovedTooQuickly", {
})); defaultValue:
"Please keep your security key connected until the sign-in process completes."
})
);
} }
} else if (error.name === 'NotSupportedError') { } else if (error.name === "NotSupportedError") {
setError(t('securityKeyNotSupported', { setError(
defaultValue: "Your security key may not be compatible. Please try a different security key." t("securityKeyNotSupported", {
})); defaultValue:
"Your security key may not be compatible. Please try a different security key."
})
);
} else { } else {
setError(t('securityKeyUnknownError', { setError(
defaultValue: "There was a problem using your security key. Please try again." t("securityKeyUnknownError", {
})); defaultValue:
"There was a problem using your security key. Please try again."
})
);
} }
} }
} catch (e: any) { } catch (e: any) {
if (e.isAxiosError) { if (e.isAxiosError) {
setError(formatAxiosError(e, t('securityKeyAuthError', { setError(
defaultValue: "Failed to authenticate with security key" formatAxiosError(
}))); e,
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
)
);
} else { } else {
console.error(e); console.error(e);
setError(e.message || t('securityKeyAuthError', { setError(
defaultValue: "Failed to authenticate with security key" e.message ||
})); t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
} }
} finally { } finally {
setSecurityKeyLoading(false); setLoading(false);
setShowSecurityKeyPrompt(false); setShowSecurityKeyPrompt(false);
} }
} }
@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
setShowSecurityKeyPrompt(false); setShowSecurityKeyPrompt(false);
try { try {
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", { const res = await api.post<AxiosResponse<LoginResponse>>(
email, "/auth/login",
password, {
code email,
}); password,
code
}
);
const data = res.data.data; const data = res.data.data;
@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
} }
if (data?.twoFactorSetupRequired) { 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); router.push(setupUrl);
return; return;
} }
@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
} }
} catch (e: any) { } catch (e: any) {
if (e.isAxiosError) { if (e.isAxiosError) {
const errorMessage = formatAxiosError(e, t('loginError', { const errorMessage = formatAxiosError(
defaultValue: "Failed to log in" e,
})); t("loginError", {
defaultValue: "Failed to log in"
})
);
setError(errorMessage); setError(errorMessage);
return; return;
} else { } else {
console.error(e); console.error(e);
setError(e.message || t('loginError', { setError(
defaultValue: "Failed to log in" e.message ||
})); t("loginError", {
defaultValue: "Failed to log in"
})
);
return; return;
} }
} finally { } finally {
@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res); console.log(res);
if (!res) { if (!res) {
setError(t('loginError')); setError(t("loginError"));
return; return;
} }
@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
<Alert> <Alert>
<FingerprintIcon className="w-5 h-5 mr-2" /> <FingerprintIcon className="w-5 h-5 mr-2" />
<AlertDescription> <AlertDescription>
{t('securityKeyPrompt', { {t("securityKeyPrompt", {
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready." defaultValue:
"Please verify your identity using your security key. Make sure your security key is connected and ready."
})} })}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('email')}</FormLabel> <FormLabel>{t("email")}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('password')}</FormLabel> <FormLabel>
{t("password")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="password" 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}` : ""}`} href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
> >
{t('passwordForgot')} {t("passwordForgot")}
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<Button type="submit" disabled={loading}> <Button
{loading ? t('idpConnectingToProcess', { type="submit"
defaultValue: "Connecting..." disabled={loading}
}) : t('login', { loading={loading}
defaultValue: "Log in" >
})} {t("login")}
</Button> </Button>
</div> </div>
</form> </form>
@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{mfaRequested && ( {mfaRequested && (
<> <>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">{t("otpAuth")}</h3>
{t('otpAuth')}
</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('otpAuthDescription')} {t("otpAuthDescription")}
</p> </p>
</div> </div>
<Form {...mfaForm}> <Form {...mfaForm}>
@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={ pattern={
REGEXP_ONLY_DIGITS_AND_CHARS REGEXP_ONLY_DIGITS_AND_CHARS
} }
onChange={(value: string) => { onChange={(
value: string
) => {
field.onChange(value); field.onChange(value);
if (value.length === 6) { if (
mfaForm.handleSubmit(onSubmit)(); value.length === 6
) {
mfaForm.handleSubmit(
onSubmit
)();
} }
}} }}
> >
@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading} loading={loading}
disabled={loading} disabled={loading}
> >
{t('otpAuthSubmit')} {t("otpAuthSubmit")}
</Button> </Button>
)} )}
@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={initiateSecurityKeyAuth} onClick={initiateSecurityKeyAuth}
loading={securityKeyLoading} loading={loading}
disabled={securityKeyLoading || showSecurityKeyPrompt} disabled={loading || showSecurityKeyPrompt}
> >
<FingerprintIcon className="w-4 h-4 mr-2" /> <FingerprintIcon className="w-4 h-4 mr-2" />
{t('securityKeyLogin', { {t("securityKeyLogin", {
defaultValue: "Sign in with security key" defaultValue: "Sign in with security key"
})} })}
</Button> </Button>
@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground"> <span className="px-2 bg-card text-muted-foreground">
{t('idpContinue')} {t("idpContinue")}
</span> </span>
</div> </div>
</div> </div>
@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset(); mfaForm.reset();
}} }}
> >
{t('otpAuthBack')} {t("otpAuthBack")}
</Button> </Button>
)} )}
</div> </div>

View file

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

View file

@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}