mirror of
https://github.com/fosrl/pangolin.git
synced 2025-09-01 07:20:06 +02:00
commit
1a4d34a802
59 changed files with 3077 additions and 897 deletions
|
@ -42,10 +42,6 @@ _Pangolin tunnels your services to the internet so you can access anything from
|
|||
</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks' target="_blank"><img src='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks/upvote_embed.svg' alt='Launch YC: Pangolin – Open-source secure gateway to private networks'/ ></a>
|
||||
</p>
|
||||
|
||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||
|
||||
<img src="public/screenshots/hero.png" alt="Preview"/>
|
||||
|
|
|
@ -31,6 +31,7 @@ services:
|
|||
- SYS_MODULE
|
||||
ports:
|
||||
- 51820:51820/udp
|
||||
- 21820:21820/udp
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
|
|
|
@ -22,10 +22,6 @@ gerbil:
|
|||
start_port: 51820
|
||||
base_endpoint: "{{.DashboardDomain}}"
|
||||
|
||||
orgs:
|
||||
block_size: 24
|
||||
subnet_group: 100.89.138.0/20
|
||||
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: "{{.EmailSMTPHost}}"
|
||||
|
|
|
@ -31,6 +31,7 @@ services:
|
|||
- SYS_MODULE
|
||||
ports:
|
||||
- 51820:51820/udp
|
||||
- 21820:21820/udp
|
||||
- 443:443 # Port for traefik because of the network_mode
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
{{end}}
|
||||
|
|
|
@ -60,8 +60,23 @@ const (
|
|||
)
|
||||
|
||||
func main() {
|
||||
|
||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||
|
||||
fmt.Println("Welcome to the Pangolin installer!")
|
||||
fmt.Println("This installer will help you set up Pangolin on your server.")
|
||||
fmt.Println("")
|
||||
fmt.Println("Please make sure you have the following prerequisites:")
|
||||
fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.")
|
||||
fmt.Println("- Point your domain to the VPS IP with A records.")
|
||||
fmt.Println("")
|
||||
fmt.Println("http://docs.fossorial.io/Getting%20Started/dns-networking")
|
||||
fmt.Println("")
|
||||
fmt.Println("Lets get started!")
|
||||
fmt.Println("")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
inputContainer := readString(reader, "Would you like to run Pangolin as docker or podman container?", "docker")
|
||||
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||
|
||||
chosenContainer := Docker
|
||||
if strings.EqualFold(inputContainer, "docker") {
|
||||
|
|
|
@ -59,7 +59,6 @@
|
|||
"siteErrorCreate": "Error creating site",
|
||||
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
|
||||
"siteErrorCreateDefaults": "Site defaults not found",
|
||||
"siteNameDescription": "This is the display name for the site.",
|
||||
"method": "Method",
|
||||
"siteMethodDescription": "This is how you will expose connections.",
|
||||
"siteLearnNewt": "Learn how to install Newt on your system",
|
||||
|
@ -1094,7 +1093,7 @@
|
|||
"sidebarAllUsers": "All Users",
|
||||
"sidebarIdentityProviders": "Identity Providers",
|
||||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
|
@ -1196,7 +1195,7 @@
|
|||
"sidebarExpand": "Expand",
|
||||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Enter your domain",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
|
@ -1206,7 +1205,7 @@
|
|||
"domainPickerSortAsc": "A-Z",
|
||||
"domainPickerSortDesc": "Z-A",
|
||||
"domainPickerCheckingAvailability": "Checking availability...",
|
||||
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
|
||||
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
|
||||
"domainPickerOrganizationDomains": "Organization Domains",
|
||||
"domainPickerProvidedDomains": "Provided Domains",
|
||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||
|
@ -1274,5 +1273,50 @@
|
|||
"createDomainDnsPropagation": "DNS Propagation",
|
||||
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
||||
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources"
|
||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "I agree to the",
|
||||
"termsOfService": "terms of service",
|
||||
"and": "and",
|
||||
"privacyPolicy": "privacy policy"
|
||||
},
|
||||
"siteRequired": "Site is required.",
|
||||
"olmTunnel": "Olm Tunnel",
|
||||
"olmTunnelDescription": "Use Olm for client connectivity",
|
||||
"errorCreatingClient": "Error creating client",
|
||||
"clientDefaultsNotFound": "Client defaults not found",
|
||||
"createClient": "Create Client",
|
||||
"createClientDescription": "Create a new client for connecting to your sites",
|
||||
"seeAllClients": "See All Clients",
|
||||
"clientInformation": "Client Information",
|
||||
"clientNamePlaceholder": "Client name",
|
||||
"address": "Address",
|
||||
"subnetPlaceholder": "Subnet",
|
||||
"addressDescription": "The address that this client will use for connectivity",
|
||||
"selectSites": "Select sites",
|
||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||
"clientInstallOlm": "Install Olm",
|
||||
"clientInstallOlmDescription": "Get Olm running on your system",
|
||||
"clientOlmCredentials": "Olm Credentials",
|
||||
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
|
||||
"olmEndpoint": "Olm Endpoint",
|
||||
"olmId": "Olm ID",
|
||||
"olmSecretKey": "Olm Secret Key",
|
||||
"clientCredentialsSave": "Save Your Credentials",
|
||||
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||
"generalSettingsDescription": "Configure the general settings for this client",
|
||||
"clientUpdated": "Client updated",
|
||||
"clientUpdatedDescription": "The client has been updated.",
|
||||
"clientUpdateFailed": "Failed to update client",
|
||||
"clientUpdateError": "An error occurred while updating the client.",
|
||||
"sitesFetchFailed": "Failed to fetch sites",
|
||||
"sitesFetchError": "An error occurred while fetching sites.",
|
||||
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
|
||||
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
|
||||
"remoteSubnets": "Remote Subnets",
|
||||
"enterCidrRange": "Enter CIDR range",
|
||||
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
|
||||
"resourceEnableProxy": "Enable Public Proxy",
|
||||
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||
"externalProxyEnabled": "External Proxy Enabled"
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import {
|
|||
boolean,
|
||||
integer,
|
||||
bigint,
|
||||
real
|
||||
real,
|
||||
text
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
|
@ -58,7 +59,8 @@ export const sites = pgTable("sites", {
|
|||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
|
@ -92,7 +94,8 @@ export const resources = pgTable("resources", {
|
|||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
setHostHeader: varchar("setHostHeader")
|
||||
setHostHeader: varchar("setHostHeader"),
|
||||
enableProxy: boolean("enableProxy").default(true),
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
@ -135,6 +138,8 @@ export const users = pgTable("user", {
|
|||
twoFactorSecret: varchar("twoFactorSecret"),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||
termsVersion: varchar("termsVersion"),
|
||||
serverAdmin: boolean("serverAdmin").notNull().default(false)
|
||||
});
|
||||
|
||||
|
@ -504,8 +509,8 @@ export const clients = pgTable("clients", {
|
|||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
lastPing: varchar("lastPing"),
|
||||
type: varchar("type").notNull(), // "olm"
|
||||
|
@ -539,7 +544,7 @@ export const olmSessions = pgTable("clientSession", {
|
|||
olmId: varchar("olmId")
|
||||
.notNull()
|
||||
.references(() => olms.olmId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const userClients = pgTable("userClients", {
|
||||
|
@ -562,9 +567,11 @@ export const roleClients = pgTable("roleClients", {
|
|||
|
||||
export const securityKeys = pgTable("webauthnCredentials", {
|
||||
credentialId: varchar("credentialId").primaryKey(),
|
||||
userId: varchar("userId").notNull().references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: varchar("publicKey").notNull(),
|
||||
signCount: integer("signCount").notNull(),
|
||||
transports: varchar("transports"),
|
||||
|
|
|
@ -65,7 +65,8 @@ export const sites = sqliteTable("sites", {
|
|||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
.default(true),
|
||||
remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
|
@ -105,7 +106,8 @@ export const resources = sqliteTable("resources", {
|
|||
.notNull()
|
||||
.default(false),
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader")
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
@ -154,6 +156,8 @@ export const users = sqliteTable("user", {
|
|||
.notNull()
|
||||
.default(false),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
||||
termsVersion: text("termsVersion"),
|
||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
|||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.7.3";
|
||||
export const APP_VERSION = "1.8.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
|
|
@ -229,9 +229,22 @@ export const configSchema = z
|
|||
disable_local_sites: z.boolean().optional(),
|
||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
enable_clients: z.boolean().optional()
|
||||
enable_clients: z.boolean().optional().default(true),
|
||||
})
|
||||
.optional(),
|
||||
dns: z
|
||||
.object({
|
||||
nameservers: z
|
||||
.array(z.string().optional().optional())
|
||||
.optional()
|
||||
.default(["ns1.fossorial.io", "ns2.fossorial.io"]),
|
||||
cname_extension: z.string().optional().default("fossorial.io")
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"],
|
||||
cname_extension: "fossorial.io"
|
||||
})
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
|
|
@ -106,21 +106,21 @@ export async function login(
|
|||
);
|
||||
}
|
||||
|
||||
// Check if user has security keys registered
|
||||
const userSecurityKeys = await db
|
||||
.select()
|
||||
.from(securityKeys)
|
||||
.where(eq(securityKeys.userId, existingUser.userId));
|
||||
|
||||
if (userSecurityKeys.length > 0) {
|
||||
return response<LoginResponse>(res, {
|
||||
data: { useSecurityKey: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Security key authentication required",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
// // Check if user has security keys registered
|
||||
// const userSecurityKeys = await db
|
||||
// .select()
|
||||
// .from(securityKeys)
|
||||
// .where(eq(securityKeys.userId, existingUser.userId));
|
||||
//
|
||||
// if (userSecurityKeys.length > 0) {
|
||||
// return response<LoginResponse>(res, {
|
||||
// data: { useSecurityKey: true },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "Security key authentication required",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
if (
|
||||
existingUser.twoFactorSetupRequested &&
|
||||
|
|
|
@ -21,15 +21,14 @@ import { hashPassword } from "@server/auth/password";
|
|||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export const signupBodySchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.email(),
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: passwordSchema,
|
||||
inviteToken: z.string().optional(),
|
||||
inviteId: z.string().optional()
|
||||
inviteId: z.string().optional(),
|
||||
termsAcceptedTimestamp: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
||||
|
@ -54,7 +53,8 @@ export async function signup(
|
|||
);
|
||||
}
|
||||
|
||||
const { email, password, inviteToken, inviteId } = parsedBody.data;
|
||||
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } =
|
||||
parsedBody.data;
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userId = generateId(15);
|
||||
|
@ -161,13 +161,24 @@ export async function signup(
|
|||
}
|
||||
}
|
||||
|
||||
if (build === "saas" && !termsAcceptedTimestamp) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"You must accept the terms of service and privacy policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
type: UserType.Internal,
|
||||
username: email,
|
||||
email: email,
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString()
|
||||
dateCreated: moment().toISOString(),
|
||||
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
||||
termsVersion: "1"
|
||||
});
|
||||
|
||||
// give the user their default permissions:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, exitNodes, sites } from "@server/db";
|
||||
import { clients, clientSites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -17,6 +17,7 @@ import {
|
|||
addPeer as olmAddPeer,
|
||||
deletePeer as olmDeletePeer
|
||||
} from "../olm/peers";
|
||||
import axios from "axios";
|
||||
|
||||
const updateClientParamsSchema = z
|
||||
.object({
|
||||
|
@ -53,6 +54,11 @@ registry.registerPath({
|
|||
responses: {}
|
||||
});
|
||||
|
||||
interface PeerDestination {
|
||||
destinationIP: string;
|
||||
destinationPort: number;
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
@ -124,15 +130,22 @@ export async function updateClient(
|
|||
);
|
||||
for (const siteId of sitesAdded) {
|
||||
if (!client.subnet || !client.pubKey || !client.endpoint) {
|
||||
logger.debug("Client subnet, pubKey or endpoint is not set");
|
||||
logger.debug(
|
||||
"Client subnet, pubKey or endpoint is not set"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: WE NEED TO HANDLE THIS BETTER. RIGHT NOW WE ARE JUST GUESSING BASED ON THE OTHER SITES
|
||||
// BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS
|
||||
const isRelayed = true;
|
||||
|
||||
const site = await newtAddPeer(siteId, {
|
||||
publicKey: client.pubKey,
|
||||
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
|
||||
endpoint: client.endpoint
|
||||
endpoint: isRelayed ? "" : client.endpoint
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
logger.debug("Failed to add peer to newt - missing site");
|
||||
continue;
|
||||
|
@ -142,12 +155,49 @@ export async function updateClient(
|
|||
logger.debug("Site endpoint or publicKey is not set");
|
||||
continue;
|
||||
}
|
||||
|
||||
let endpoint;
|
||||
|
||||
if (isRelayed) {
|
||||
if (!site.exitNodeId) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// get the exit node for the site
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
.limit(1);
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn(
|
||||
`Exit node not found for site ${site.siteId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
endpoint = `${exitNode.endpoint}:21820`;
|
||||
} else {
|
||||
if (!endpoint) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no endpoint, skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
endpoint = site.endpoint;
|
||||
}
|
||||
|
||||
await olmAddPeer(client.clientId, {
|
||||
siteId: siteId,
|
||||
endpoint: site.endpoint,
|
||||
siteId: site.siteId,
|
||||
endpoint: endpoint,
|
||||
publicKey: site.publicKey,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort
|
||||
serverPort: site.listenPort,
|
||||
remoteSubnets: site.remoteSubnets
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -170,7 +220,11 @@ export async function updateClient(
|
|||
logger.debug("Site endpoint or publicKey is not set");
|
||||
continue;
|
||||
}
|
||||
await olmDeletePeer(client.clientId, site.siteId, site.publicKey);
|
||||
await olmDeletePeer(
|
||||
client.clientId,
|
||||
site.siteId,
|
||||
site.publicKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -201,6 +255,101 @@ export async function updateClient(
|
|||
}
|
||||
}
|
||||
|
||||
if (client.endpoint) {
|
||||
// get all sites for this client and join with exit nodes with site.exitNodeId
|
||||
const sitesData = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(
|
||||
clientSites,
|
||||
eq(sites.siteId, clientSites.siteId)
|
||||
)
|
||||
.leftJoin(
|
||||
exitNodes,
|
||||
eq(sites.exitNodeId, exitNodes.exitNodeId)
|
||||
)
|
||||
.where(eq(clientSites.clientId, client.clientId));
|
||||
|
||||
let exitNodeDestinations: {
|
||||
reachableAt: string;
|
||||
destinations: PeerDestination[];
|
||||
}[] = [];
|
||||
|
||||
for (const site of sitesData) {
|
||||
if (!site.sites.subnet) {
|
||||
logger.warn(
|
||||
`Site ${site.sites.siteId} has no subnet, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// find the destinations in the array
|
||||
let destinations = exitNodeDestinations.find(
|
||||
(d) => d.reachableAt === site.exitNodes?.reachableAt
|
||||
);
|
||||
|
||||
if (!destinations) {
|
||||
destinations = {
|
||||
reachableAt: site.exitNodes?.reachableAt || "",
|
||||
destinations: [
|
||||
{
|
||||
destinationIP:
|
||||
site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 0
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
// add to the existing destinations
|
||||
destinations.destinations.push({
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 0
|
||||
});
|
||||
}
|
||||
|
||||
// update it in the array
|
||||
exitNodeDestinations = exitNodeDestinations.filter(
|
||||
(d) => d.reachableAt !== site.exitNodes?.reachableAt
|
||||
);
|
||||
exitNodeDestinations.push(destinations);
|
||||
}
|
||||
|
||||
for (const destination of exitNodeDestinations) {
|
||||
try {
|
||||
logger.info(
|
||||
`Updating destinations for exit node at ${destination.reachableAt}`
|
||||
);
|
||||
const payload = {
|
||||
sourceIp: client.endpoint?.split(":")[0] || "",
|
||||
sourcePort: parseInt(client.endpoint?.split(":")[1]) || 0,
|
||||
destinations: destination.destinations
|
||||
};
|
||||
logger.info(
|
||||
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
|
||||
);
|
||||
const response = await axios.post(
|
||||
`${destination.reachableAt}/update-destinations`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Destinations updated:", {
|
||||
peer: response.data.status
|
||||
});
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the updated client
|
||||
const [updatedClient] = await trx
|
||||
.select()
|
||||
|
|
|
@ -11,6 +11,7 @@ import { generateId } from "@server/auth/sessions/app";
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { isValidDomain } from "@server/lib/validators";
|
||||
import { build } from "@server/build";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
|
@ -228,15 +229,15 @@ export async function createOrgDomain(
|
|||
|
||||
// TODO: This needs to be cross region and not hardcoded
|
||||
if (type === "ns") {
|
||||
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
|
||||
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
||||
} else if (type === "cname") {
|
||||
cnameRecords = [
|
||||
{
|
||||
value: `${domainId}.cname.fossorial.io`,
|
||||
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: baseDomain
|
||||
},
|
||||
{
|
||||
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
|
||||
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||
baseDomain: `_acme-challenge.${baseDomain}`
|
||||
}
|
||||
];
|
||||
|
|
|
@ -233,6 +233,12 @@ authenticated.get(
|
|||
resource.listResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-resources",
|
||||
verifyOrgAccess,
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
|
|
|
@ -8,7 +8,7 @@ export async function addPeer(exitNodeId: number, peer: {
|
|||
publicKey: string;
|
||||
allowedIps: string[];
|
||||
}) {
|
||||
|
||||
logger.info(`Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}`);
|
||||
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
||||
if (!exitNode) {
|
||||
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
||||
|
@ -35,6 +35,7 @@ export async function addPeer(exitNodeId: number, peer: {
|
|||
}
|
||||
|
||||
export async function deletePeer(exitNodeId: number, publicKey: string) {
|
||||
logger.info(`Deleting peer with public key ${publicKey} from exit node ${exitNodeId}`);
|
||||
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
||||
if (!exitNode) {
|
||||
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { clients, newts, olms, Site, sites, clientSites } from "@server/db";
|
||||
import { clients, newts, olms, Site, sites, clientSites, exitNodes } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -9,6 +9,7 @@ import logger from "@server/logger";
|
|||
import { fromError } from "zod-validation-error";
|
||||
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
||||
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
||||
import axios from "axios";
|
||||
|
||||
// Define Zod schema for request validation
|
||||
const updateHolePunchSchema = z.object({
|
||||
|
@ -17,7 +18,8 @@ const updateHolePunchSchema = z.object({
|
|||
token: z.string(),
|
||||
ip: z.string(),
|
||||
port: z.number(),
|
||||
timestamp: z.number()
|
||||
timestamp: z.number(),
|
||||
reachableAt: z.string().optional()
|
||||
});
|
||||
|
||||
// New response type with multi-peer destination support
|
||||
|
@ -43,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 destinations: PeerDestination[] = [];
|
||||
|
||||
|
@ -95,37 +96,129 @@ export async function updateHolePunch(
|
|||
);
|
||||
}
|
||||
|
||||
// Get all sites that this client is connected to
|
||||
const clientSitePairs = await db
|
||||
.select()
|
||||
.from(clientSites)
|
||||
.where(eq(clientSites.clientId, client.clientId));
|
||||
// // Get all sites that this client is connected to
|
||||
// const clientSitePairs = await db
|
||||
// .select()
|
||||
// .from(clientSites)
|
||||
// .where(eq(clientSites.clientId, client.clientId));
|
||||
|
||||
if (clientSitePairs.length === 0) {
|
||||
logger.warn(`No sites found for client: ${client.clientId}`);
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
|
||||
);
|
||||
}
|
||||
// if (clientSitePairs.length === 0) {
|
||||
// logger.warn(`No sites found for client: ${client.clientId}`);
|
||||
// return next(
|
||||
// createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
|
||||
// );
|
||||
// }
|
||||
|
||||
// Get all sites details
|
||||
const siteIds = clientSitePairs.map(pair => pair.siteId);
|
||||
// // Get all sites details
|
||||
// const siteIds = clientSitePairs.map(pair => pair.siteId);
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId));
|
||||
// for (const siteId of siteIds) {
|
||||
// const [site] = await db
|
||||
// .select()
|
||||
// .from(sites)
|
||||
// .where(eq(sites.siteId, siteId));
|
||||
|
||||
if (site && site.subnet && site.listenPort) {
|
||||
destinations.push({
|
||||
destinationIP: site.subnet.split("/")[0],
|
||||
destinationPort: site.listenPort
|
||||
// if (site && site.subnet && site.listenPort) {
|
||||
// destinations.push({
|
||||
// destinationIP: site.subnet.split("/")[0],
|
||||
// destinationPort: site.listenPort
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// get all sites for this client and join with exit nodes with site.exitNodeId
|
||||
const sitesData = await db
|
||||
.select()
|
||||
.from(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) {
|
||||
logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`);
|
||||
|
||||
const { session, newt: newtSession } =
|
||||
await validateNewtSessionToken(token);
|
||||
|
||||
|
@ -174,28 +267,29 @@ export async function updateHolePunch(
|
|||
}
|
||||
|
||||
// Find all clients that connect to this site
|
||||
const sitesClientPairs = await db
|
||||
.select()
|
||||
.from(clientSites)
|
||||
.where(eq(clientSites.siteId, newt.siteId));
|
||||
// const sitesClientPairs = await db
|
||||
// .select()
|
||||
// .from(clientSites)
|
||||
// .where(eq(clientSites.siteId, newt.siteId));
|
||||
|
||||
// THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING
|
||||
// Get client details for each client
|
||||
for (const pair of sitesClientPairs) {
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, pair.clientId));
|
||||
// for (const pair of sitesClientPairs) {
|
||||
// const [client] = await db
|
||||
// .select()
|
||||
// .from(clients)
|
||||
// .where(eq(clients.clientId, pair.clientId));
|
||||
|
||||
if (client && client.endpoint) {
|
||||
const [host, portStr] = client.endpoint.split(':');
|
||||
if (host && portStr) {
|
||||
destinations.push({
|
||||
destinationIP: host,
|
||||
destinationPort: parseInt(portStr, 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (client && client.endpoint) {
|
||||
// const [host, portStr] = client.endpoint.split(':');
|
||||
// if (host && portStr) {
|
||||
// destinations.push({
|
||||
// destinationIP: host,
|
||||
// destinationPort: parseInt(portStr, 10)
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// If this is a newt/site, also add other sites in the same org
|
||||
// if (updatedSite.orgId) {
|
||||
|
|
|
@ -2,10 +2,18 @@ import { z } from "zod";
|
|||
import { MessageHandler } from "../ws";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
ExitNode,
|
||||
exitNodes,
|
||||
resources,
|
||||
Target,
|
||||
targets
|
||||
} from "@server/db";
|
||||
import { clients, clientSites, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { updatePeer } from "../olm/peers";
|
||||
import axios from "axios";
|
||||
|
||||
const inputSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
|
@ -54,7 +62,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
logger.warn("handleGetConfigMessage: Site not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// we need to wait for hole punch success
|
||||
if (!existingSite.endpoint) {
|
||||
logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`);
|
||||
|
@ -87,6 +95,48 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
return;
|
||||
}
|
||||
|
||||
let exitNode: ExitNode | undefined;
|
||||
if (site.exitNodeId) {
|
||||
[exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
.limit(1);
|
||||
if (exitNode.reachableAt) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${exitNode.reachableAt}/update-proxy-mapping`,
|
||||
{
|
||||
oldDestination: {
|
||||
destinationIP: existingSite.subnet?.split("/")[0],
|
||||
destinationPort: existingSite.listenPort
|
||||
},
|
||||
newDestination: {
|
||||
destinationIP: site.subnet?.split("/")[0],
|
||||
destinationPort: site.listenPort
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Destinations updated:", {
|
||||
peer: response.data.status
|
||||
});
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all clients connected to this site
|
||||
const clientsRes = await db
|
||||
.select()
|
||||
|
@ -107,33 +157,59 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
if (!client.clients.endpoint) {
|
||||
return false;
|
||||
}
|
||||
if (!client.clients.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(async (client) => {
|
||||
// Add or update this peer on the olm if it is connected
|
||||
try {
|
||||
if (site.endpoint && site.publicKey) {
|
||||
await updatePeer(client.clients.clientId, {
|
||||
siteId: site.siteId,
|
||||
endpoint: site.endpoint,
|
||||
publicKey: site.publicKey,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort
|
||||
});
|
||||
if (!site.publicKey) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no public key, skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let endpoint = site.endpoint;
|
||||
if (client.clientSites.isRelayed) {
|
||||
if (!site.exitNodeId) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn(
|
||||
`Exit node not found for site ${site.siteId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
endpoint = `${exitNode.endpoint}:21820`;
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} has no endpoint, skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
await updatePeer(client.clients.clientId, {
|
||||
siteId: site.siteId,
|
||||
endpoint: endpoint,
|
||||
publicKey: site.publicKey,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort,
|
||||
remoteSubnets: site.remoteSubnets
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to add/update peer ${client.clients.pubKey} to newt ${newt.newtId}: ${error}`
|
||||
`Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: client.clients.pubKey!,
|
||||
allowedIps: [`${client.clients.subnet.split('/')[0]}/32`], // we want to only allow from that client
|
||||
allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client
|
||||
endpoint: client.clientSites.isRelayed
|
||||
? ""
|
||||
: client.clients.endpoint! // if its relayed it should be localhost
|
||||
|
@ -144,14 +220,96 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
// Filter out any null values from peers that didn't have an olm
|
||||
const validPeers = peers.filter((peer) => peer !== null);
|
||||
|
||||
// Improved version
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First get all resources for the site
|
||||
const resourcesList = await tx
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(resources)
|
||||
.where(and(eq(resources.siteId, siteId), eq(resources.http, false)));
|
||||
|
||||
// Get all enabled targets for these resources in a single query
|
||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled,
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
// Combine the data in JS instead of using SQL for the JSON
|
||||
return resourcesList.map((resource) => ({
|
||||
...resource,
|
||||
targets: allTargets.filter(
|
||||
(target) => target.resourceId === resource.resourceId
|
||||
)
|
||||
}));
|
||||
});
|
||||
|
||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||
(acc, resource) => {
|
||||
// Skip resources with no targets
|
||||
if (!resource.targets?.length) return acc;
|
||||
|
||||
// Format valid targets into strings
|
||||
const formattedTargets = resource.targets
|
||||
.filter(
|
||||
(target: Target) =>
|
||||
resource.proxyPort && target?.ip && target?.port
|
||||
)
|
||||
.map(
|
||||
(target: Target) =>
|
||||
`${resource.proxyPort}:${target.ip}:${target.port}`
|
||||
);
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (resource.protocol === "tcp") {
|
||||
acc.tcpTargets.push(...formattedTargets);
|
||||
} else {
|
||||
acc.udpTargets.push(...formattedTargets);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||
);
|
||||
|
||||
// Build the configuration response
|
||||
const configResponse = {
|
||||
ipAddress: site.address,
|
||||
peers: validPeers
|
||||
peers: validPeers,
|
||||
targets: {
|
||||
udp: udpTargets,
|
||||
tcp: tcpTargets
|
||||
}
|
||||
};
|
||||
|
||||
logger.debug("Sending config: ", configResponse);
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "newt/wg/receive-config",
|
||||
|
|
|
@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
|
|||
export function addTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string
|
||||
protocol: string,
|
||||
port: number | null = null
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
|
@ -13,19 +14,32 @@ export function addTargets(
|
|||
}:${target.port}`;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sendToClient(newtId, {
|
||||
type: `newt/${protocol}/add`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
};
|
||||
sendToClient(newtId, payload);
|
||||
});
|
||||
|
||||
const payloadTargetsResources = targets.map((target) => {
|
||||
return `${port ? port + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/add`,
|
||||
data: {
|
||||
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function removeTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string
|
||||
protocol: string,
|
||||
port: number | null = null
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
|
@ -34,11 +48,23 @@ export function removeTargets(
|
|||
}:${target.port}`;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sendToClient(newtId, {
|
||||
type: `newt/${protocol}/remove`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
};
|
||||
sendToClient(newtId, payload);
|
||||
});
|
||||
|
||||
const payloadTargetsResources = targets.map((target) => {
|
||||
return `${port ? port + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/remove`,
|
||||
data: {
|
||||
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, ExitNode } from "@server/db";
|
||||
import { MessageHandler } from "../ws";
|
||||
import {
|
||||
clients,
|
||||
|
@ -28,7 +28,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
return;
|
||||
}
|
||||
const clientId = olm.clientId;
|
||||
const { publicKey } = message.data;
|
||||
const { publicKey, relay } = message.data;
|
||||
|
||||
logger.debug(`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`);
|
||||
|
||||
if (!publicKey) {
|
||||
logger.warn("Public key not provided");
|
||||
return;
|
||||
|
@ -58,9 +61,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
sendToClient(olm.olmId, {
|
||||
type: "olm/wg/holepunch",
|
||||
data: {
|
||||
serverPubKey: exitNode.publicKey
|
||||
serverPubKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint,
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
if (now - (client.lastHolePunch || 0) > 6) {
|
||||
|
@ -84,7 +89,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
await db
|
||||
.update(clientSites)
|
||||
.set({
|
||||
isRelayed: false
|
||||
isRelayed: relay == true
|
||||
})
|
||||
.where(eq(clientSites.clientId, olm.clientId));
|
||||
}
|
||||
|
@ -97,7 +102,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
.where(eq(clientSites.clientId, client.clientId));
|
||||
|
||||
// Prepare an array to store site configurations
|
||||
const siteConfigurations = [];
|
||||
let siteConfigurations = [];
|
||||
logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`);
|
||||
|
||||
if (sitesData.length === 0) {
|
||||
sendToClient(olm.olmId, {
|
||||
type: "olm/register/no-sites",
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Process each site
|
||||
for (const { sites: site } of sitesData) {
|
||||
|
@ -114,12 +127,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (site.lastHolePunch && now - site.lastHolePunch > 6) {
|
||||
logger.warn(
|
||||
`Site ${site.siteId} last hole punch is too old, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||
// logger.warn(
|
||||
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||
// );
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// If public key changed, delete old peer from this site
|
||||
if (client.pubKey && client.pubKey != publicKey) {
|
||||
|
@ -142,7 +155,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
await addPeer(site.siteId, {
|
||||
publicKey: publicKey,
|
||||
allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client
|
||||
endpoint: client.endpoint
|
||||
endpoint: relay ? "" : client.endpoint
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
|
@ -150,21 +163,36 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
);
|
||||
}
|
||||
|
||||
let endpoint = site.endpoint;
|
||||
if (relay) {
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
.limit(1);
|
||||
if (!exitNode) {
|
||||
logger.warn(`Exit node not found for site ${site.siteId}`);
|
||||
continue;
|
||||
}
|
||||
endpoint = `${exitNode.endpoint}:21820`;
|
||||
}
|
||||
|
||||
// Add site configuration to the array
|
||||
siteConfigurations.push({
|
||||
siteId: site.siteId,
|
||||
endpoint: site.endpoint,
|
||||
endpoint: endpoint,
|
||||
publicKey: site.publicKey,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort
|
||||
serverPort: site.listenPort,
|
||||
remoteSubnets: site.remoteSubnets
|
||||
});
|
||||
}
|
||||
|
||||
// If we have no valid site configurations, don't send a connect message
|
||||
if (siteConfigurations.length === 0) {
|
||||
logger.warn("No valid site configurations found");
|
||||
return;
|
||||
}
|
||||
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
||||
// if (siteConfigurations.length === 0) {
|
||||
// logger.warn("No valid site configurations found");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Return connect message with all site configurations
|
||||
return {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, exitNodes, sites } from "@server/db";
|
||||
import { MessageHandler } from "../ws";
|
||||
import { clients, clientSites, Olm } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { updatePeer } from "../newt/peers";
|
||||
import logger from "@server/logger";
|
||||
|
||||
|
@ -30,29 +30,67 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
logger.warn("Site not found or does not have exit node");
|
||||
logger.warn("Client not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
|
||||
if (!client.pubKey) {
|
||||
logger.warn("Site or client has no endpoint or listen port");
|
||||
logger.warn("Client has no endpoint or listen port");
|
||||
return;
|
||||
}
|
||||
|
||||
const { siteId } = message.data;
|
||||
|
||||
// Get the site
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site || !site.exitNodeId) {
|
||||
logger.warn("Site not found or has no exit node");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the site's exit node
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
.limit(1);
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn("Exit node not found for site");
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(clientSites)
|
||||
.set({
|
||||
isRelayed: true
|
||||
})
|
||||
.where(eq(clientSites.clientId, olm.clientId));
|
||||
.where(
|
||||
and(
|
||||
eq(clientSites.clientId, olm.clientId),
|
||||
eq(clientSites.siteId, siteId)
|
||||
)
|
||||
);
|
||||
|
||||
// update the peer on the exit node
|
||||
await updatePeer(siteId, client.pubKey, {
|
||||
endpoint: "" // this removes the endpoint
|
||||
});
|
||||
|
||||
sendToClient(olm.olmId, {
|
||||
type: "olm/wg/peer/relay",
|
||||
data: {
|
||||
siteId: siteId,
|
||||
endpoint: exitNode.endpoint,
|
||||
publicKey: exitNode.publicKey
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ export async function addPeer(
|
|||
endpoint: string;
|
||||
serverIP: string | null;
|
||||
serverPort: number | null;
|
||||
remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access
|
||||
}
|
||||
) {
|
||||
const [olm] = await db
|
||||
|
@ -30,7 +31,8 @@ export async function addPeer(
|
|||
publicKey: peer.publicKey,
|
||||
endpoint: peer.endpoint,
|
||||
serverIP: peer.serverIP,
|
||||
serverPort: peer.serverPort
|
||||
serverPort: peer.serverPort,
|
||||
remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -66,6 +68,7 @@ export async function updatePeer(
|
|||
endpoint: string;
|
||||
serverIP: string | null;
|
||||
serverPort: number | null;
|
||||
remoteSubnets?: string | null; // optional, comma-separated list of subnets that
|
||||
}
|
||||
) {
|
||||
const [olm] = await db
|
||||
|
@ -84,7 +87,8 @@ export async function updatePeer(
|
|||
publicKey: peer.publicKey,
|
||||
endpoint: peer.endpoint,
|
||||
serverIP: peer.serverIP,
|
||||
serverPort: peer.serverPort
|
||||
serverPort: peer.serverPort,
|
||||
remoteSubnets: peer.remoteSubnets
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
newts,
|
||||
newtSessions,
|
||||
orgs,
|
||||
sites,
|
||||
userActions
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, domains, orgDomains, resources } from "@server/db";
|
||||
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
|
@ -126,6 +120,44 @@ export async function deleteOrg(
|
|||
}
|
||||
}
|
||||
|
||||
const allOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(domains.configManaged, false)
|
||||
)
|
||||
);
|
||||
|
||||
// For each domain, check if it belongs to multiple organizations
|
||||
const domainIdsToDelete: string[] = [];
|
||||
for (const orgDomain of allOrgDomains) {
|
||||
const domainId = orgDomain.domains.domainId;
|
||||
|
||||
// Count how many organizations this domain belongs to
|
||||
const orgCount = await trx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.domainId, domainId));
|
||||
|
||||
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
|
||||
if (orgCount[0].count === 1) {
|
||||
domainIdsToDelete.push(domainId);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete domains that belong exclusively to this organization
|
||||
if (domainIdsToDelete.length > 0) {
|
||||
await trx
|
||||
.delete(domains)
|
||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||
}
|
||||
|
||||
// Delete resources
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
|
||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||
});
|
||||
|
||||
|
@ -136,8 +168,11 @@ export async function deleteOrg(
|
|||
data: {}
|
||||
};
|
||||
// Don't await this to prevent blocking the response
|
||||
sendToClient(newtId, payload).catch(error => {
|
||||
logger.error("Failed to send termination message to newt:", error);
|
||||
sendToClient(newtId, payload).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to newt:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -33,10 +33,7 @@ const createResourceParamsSchema = z
|
|||
const createHttpResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
subdomain: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional(),
|
||||
subdomain: z.string().nullable().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
|
@ -51,7 +48,7 @@ const createHttpResourceSchema = z
|
|||
return true;
|
||||
},
|
||||
{ message: "Invalid subdomain" }
|
||||
)
|
||||
);
|
||||
|
||||
const createRawResourceSchema = z
|
||||
.object({
|
||||
|
@ -59,7 +56,8 @@ const createRawResourceSchema = z
|
|||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.number().int().min(1).max(65535)
|
||||
proxyPort: z.number().int().min(1).max(65535),
|
||||
enableProxy: z.boolean().default(true)
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
|
@ -88,12 +86,7 @@ registry.registerPath({
|
|||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema:
|
||||
build == "oss"
|
||||
? createHttpResourceSchema.or(
|
||||
createRawResourceSchema
|
||||
)
|
||||
: createHttpResourceSchema
|
||||
schema: createHttpResourceSchema.or(createRawResourceSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -156,7 +149,10 @@ export async function createResource(
|
|||
{ siteId, orgId }
|
||||
);
|
||||
} else {
|
||||
if (!config.getRawConfig().flags?.allow_raw_resources && build == "oss") {
|
||||
if (
|
||||
!config.getRawConfig().flags?.allow_raw_resources &&
|
||||
build == "oss"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
@ -378,7 +374,7 @@ async function createRawResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||
const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data;
|
||||
|
||||
// if http is false check to see if there is already a resource with the same port and protocol
|
||||
const existingResource = await db
|
||||
|
@ -411,7 +407,8 @@ async function createRawResource(
|
|||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
proxyPort,
|
||||
enableProxy
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
|
@ -103,7 +103,8 @@ export async function deleteResource(
|
|||
removeTargets(
|
||||
newt.newtId,
|
||||
targetsToBeRemoved,
|
||||
deletedResource.protocol
|
||||
deletedResource.protocol,
|
||||
deletedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
168
server/routers/resource/getUserResources.ts
Normal file
168
server/routers/resource/getUserResources.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
};
|
|
@ -21,4 +21,5 @@ export * from "./getExchangeToken";
|
|||
export * from "./createResourceRule";
|
||||
export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
|
@ -168,7 +168,8 @@ export async function transferResource(
|
|||
removeTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol
|
||||
updatedResource.protocol,
|
||||
updatedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -190,7 +191,8 @@ export async function transferResource(
|
|||
addTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol
|
||||
updatedResource.protocol,
|
||||
updatedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,9 +34,7 @@ const updateResourceParamsSchema = z
|
|||
const updateHttpResourceBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
subdomain: subdomainSchema
|
||||
.nullable()
|
||||
.optional(),
|
||||
subdomain: subdomainSchema.nullable().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
sso: z.boolean().optional(),
|
||||
blockAccess: z.boolean().optional(),
|
||||
|
@ -93,7 +91,8 @@ const updateRawResourceBodySchema = z
|
|||
name: z.string().min(1).max(255).optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
stickySession: z.boolean().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
enabled: z.boolean().optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
@ -121,12 +120,9 @@ registry.registerPath({
|
|||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema:
|
||||
build == "oss"
|
||||
? updateHttpResourceBodySchema.and(
|
||||
updateRawResourceBodySchema
|
||||
)
|
||||
: updateHttpResourceBodySchema
|
||||
schema: updateHttpResourceBodySchema.and(
|
||||
updateRawResourceBodySchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -288,7 +284,9 @@ async function updateHttpResource(
|
|||
} else if (domainRes.domains.type == "wildcard") {
|
||||
if (updateData.subdomain !== undefined) {
|
||||
// the subdomain cant have a dot in it
|
||||
const parsedSubdomain = subdomainSchema.safeParse(updateData.subdomain);
|
||||
const parsedSubdomain = subdomainSchema.safeParse(
|
||||
updateData.subdomain
|
||||
);
|
||||
if (!parsedSubdomain.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -341,7 +339,7 @@ async function updateHttpResource(
|
|||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set({...updateData, })
|
||||
.set({ ...updateData })
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import createHttpError from "http-errors";
|
|||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isValidCIDR } from "@server/lib/validators";
|
||||
|
||||
const updateSiteParamsSchema = z
|
||||
.object({
|
||||
|
@ -20,6 +21,9 @@ const updateSiteBodySchema = z
|
|||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
remoteSubnets: z
|
||||
.string()
|
||||
.optional()
|
||||
// subdomain: z
|
||||
// .string()
|
||||
// .min(1)
|
||||
|
@ -85,6 +89,21 @@ export async function updateSite(
|
|||
const { siteId } = parsedParams.data;
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
// if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
|
||||
if (updateData.remoteSubnets) {
|
||||
const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim());
|
||||
for (const subnet of subnets) {
|
||||
if (!isValidCIDR(subnet)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Invalid CIDR format: ${subnet}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSite = await db
|
||||
.update(sites)
|
||||
.set(updateData)
|
||||
|
|
|
@ -173,7 +173,7 @@ export async function createTarget(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(newt.newtId, newTarget, resource.protocol);
|
||||
addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -105,7 +105,7 @@ export async function deleteTarget(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
|
||||
removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -157,7 +157,7 @@ export async function updateTarget(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(newt.newtId, [updatedTarget], resource.protocol);
|
||||
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
|
||||
}
|
||||
}
|
||||
return response(res, {
|
||||
|
|
|
@ -66,7 +66,8 @@ export async function traefikConfigProvider(
|
|||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy
|
||||
})
|
||||
.from(resources)
|
||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
|
@ -365,6 +366,10 @@ export async function traefikConfigProvider(
|
|||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const port = resource.proxyPort;
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
|||
import path from "path";
|
||||
import m1 from "./scriptsPg/1.6.0";
|
||||
import m2 from "./scriptsPg/1.7.0";
|
||||
import m3 from "./scriptsPg/1.8.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -14,7 +15,8 @@ import m2 from "./scriptsPg/1.7.0";
|
|||
// Define the migration list with versions and their corresponding functions
|
||||
const migrations = [
|
||||
{ version: "1.6.0", run: m1 },
|
||||
{ version: "1.7.0", run: m2 }
|
||||
{ version: "1.7.0", run: m2 },
|
||||
{ version: "1.8.0", run: m3 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
|
|
@ -24,6 +24,7 @@ import m19 from "./scriptsSqlite/1.3.0";
|
|||
import m20 from "./scriptsSqlite/1.5.0";
|
||||
import m21 from "./scriptsSqlite/1.6.0";
|
||||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -47,6 +48,7 @@ const migrations = [
|
|||
{ version: "1.5.0", run: m20 },
|
||||
{ version: "1.6.0", run: m21 },
|
||||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 },
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
32
server/setup/scriptsPg/1.8.0.ts
Normal file
32
server/setup/scriptsPg/1.8.0.ts
Normal 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`);
|
||||
}
|
30
server/setup/scriptsSqlite/1.8.0.ts
Normal file
30
server/setup/scriptsSqlite/1.8.0.ts
Normal 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`);
|
||||
}
|
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
|||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { cache } from "react";
|
||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
||||
import MemberResourcesPortal from "./MemberResourcesPortal";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||
import { redirect } from "next/navigation";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { orgLangingNavItems } from "@app/app/navigation";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -17,6 +21,7 @@ type OrgPageProps = {
|
|||
export default async function OrgPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
const env = pullEnv();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
let redirectToSettings = false;
|
||||
let overview: GetOrgOverviewResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
||||
|
@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||
await authCookieHeader()
|
||||
);
|
||||
overview = res.data.data;
|
||||
|
||||
if (overview.isAdmin || overview.isOwner) {
|
||||
redirectToSettings = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (redirectToSettings) {
|
||||
// If user is admin or owner, redirect to settings
|
||||
if (overview?.isAdmin || overview?.isOwner) {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
// For non-admin users, show the member resources portal
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
|
@ -60,24 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
overview={{
|
||||
orgId: overview.orgId,
|
||||
orgName: overview.orgName,
|
||||
stats: {
|
||||
users: overview.numUsers,
|
||||
sites: overview.numSites,
|
||||
resources: overview.numResources
|
||||
},
|
||||
isAdmin: overview.isAdmin,
|
||||
isOwner: overview.isOwner,
|
||||
userRole: overview.userRoleName
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
);
|
||||
|
|
|
@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast";
|
|||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CreateClientFormModal from "./CreateClientsModal";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
|
@ -76,42 +75,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
};
|
||||
|
||||
const columns: ColumnDef<ClientRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
|
@ -243,6 +206,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
const clientRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
|
@ -259,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CreateClientFormModal
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreate={(val) => {
|
||||
setRows([val, ...rows]);
|
||||
}}
|
||||
orgId={orgId}
|
||||
/>
|
||||
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
|
@ -309,7 +290,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
|||
columns={columns}
|
||||
data={rows}
|
||||
addClient={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/clients/create`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -9,38 +9,40 @@ import {
|
|||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Client Information</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
|
|
|
@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
|
|||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
|
@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
|
|||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { client, updateClient } = useClientContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -119,18 +121,18 @@ export default function GeneralPage() {
|
|||
updateClient({ name: data.name });
|
||||
|
||||
toast({
|
||||
title: "Client updated",
|
||||
description: "The client has been updated."
|
||||
title: t("clientUpdated"),
|
||||
description: t("clientUpdatedDescription")
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update client",
|
||||
title: t("clientUpdateFailed"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the client."
|
||||
t("clientUpdateError")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -143,10 +145,10 @@ export default function GeneralPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("generalSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this client
|
||||
{t("generalSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -163,15 +165,11 @@ export default function GeneralPage() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
client.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
@ -181,12 +179,12 @@ export default function GeneralPage() {
|
|||
name="siteIds"
|
||||
render={(field) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Sites</FormLabel>
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeSitesTagIndex}
|
||||
setActiveTagIndex={setActiveSitesTagIndex}
|
||||
placeholder="Select sites"
|
||||
placeholder={t("selectSites")}
|
||||
size="sm"
|
||||
tags={form.getValues().siteIds}
|
||||
setTags={(newTags) => {
|
||||
|
@ -202,9 +200,7 @@ export default function GeneralPage() {
|
|||
sortTags={true}
|
||||
/>
|
||||
<FormDescription>
|
||||
The client will have connectivity to the
|
||||
selected sites. The sites must be configured
|
||||
to accept client connections.
|
||||
{t("sitesDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -222,7 +218,7 @@ export default function GeneralPage() {
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Settings
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal file
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Clients"
|
||||
title="Manage Clients (beta)"
|
||||
description="Clients are devices that can connect to your sites"
|
||||
/>
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
|
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('resourceInfo')}
|
||||
{t("resourceInfo")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
|
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t('authentication')}
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
|
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>{t('protected')}</span>
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>{t('notProtected')}</span>
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
|
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
|
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>
|
||||
{t("protocol")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
|
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
|
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{build == "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("externalProxyEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enableProxy
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled ? t('enabled') : t('disabled')}
|
||||
{resource.enabled
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
|
|
@ -66,6 +66,7 @@ import {
|
|||
} from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
|
@ -78,6 +79,7 @@ import {
|
|||
} from "@app/components/Credenza";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
|
@ -118,25 +120,31 @@ export default function GeneralForm() {
|
|||
fullDomain: string;
|
||||
} | null>(null);
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional()
|
||||
}).refine((data) => {
|
||||
// For non-HTTP resources, proxyPort should be defined
|
||||
if (!resource.http) {
|
||||
return data.proxyPort !== undefined;
|
||||
}
|
||||
// For HTTP resources, proxyPort should be undefined
|
||||
return data.proxyPort === undefined;
|
||||
}, {
|
||||
message: !resource.http
|
||||
? "Port number is required for non-HTTP resources"
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
});
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// For non-HTTP resources, proxyPort should be defined
|
||||
if (!resource.http) {
|
||||
return data.proxyPort !== undefined;
|
||||
}
|
||||
// For HTTP resources, proxyPort should be undefined
|
||||
return data.proxyPort === undefined;
|
||||
},
|
||||
{
|
||||
message: !resource.http
|
||||
? "Port number is required for non-HTTP resources"
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
|
@ -147,7 +155,8 @@ export default function GeneralForm() {
|
|||
name: resource.name,
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
domainId: resource.domainId || undefined,
|
||||
proxyPort: resource.proxyPort || undefined
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
enableProxy: resource.enableProxy || false
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
@ -211,7 +220,10 @@ export default function GeneralForm() {
|
|||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
|
@ -238,7 +250,10 @@ export default function GeneralForm() {
|
|||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
fullDomain: resource.fullDomain,
|
||||
proxyPort: data.proxyPort
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
}),
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
@ -357,16 +372,29 @@ export default function GeneralForm() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePortNumber")}
|
||||
{t(
|
||||
"resourcePortNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(
|
||||
e
|
||||
) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
e
|
||||
.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
@ -374,11 +402,49 @@ export default function GeneralForm() {
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourcePortNumberDescription")}
|
||||
{t(
|
||||
"resourcePortNumberDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableProxy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
variant={
|
||||
"outlinePrimarySquare"
|
||||
}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"resourceEnableProxy"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceEnableProxyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
@ -574,6 +640,7 @@ export default function GeneralForm() {
|
|||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
orgId={orgId as string}
|
||||
cols={1}
|
||||
onDomainChange={(res) => {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
|
|
|
@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
|
@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
|
|||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
|
@ -72,13 +74,14 @@ const baseResourceFormSchema = z.object({
|
|||
});
|
||||
|
||||
const httpResourceFormSchema = z.object({
|
||||
domainId: z.string().optional(),
|
||||
domainId: z.string().nonempty(),
|
||||
subdomain: z.string().optional()
|
||||
});
|
||||
|
||||
const tcpUdpResourceFormSchema = z.object({
|
||||
protocol: z.string(),
|
||||
proxyPort: z.number().int().min(1).max(65535)
|
||||
proxyPort: z.number().int().min(1).max(65535),
|
||||
enableProxy: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||
|
@ -144,7 +147,8 @@ export default function Page() {
|
|||
resolver: zodResolver(tcpUdpResourceFormSchema),
|
||||
defaultValues: {
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined
|
||||
proxyPort: undefined,
|
||||
enableProxy: false
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -163,16 +167,17 @@ export default function Page() {
|
|||
|
||||
if (isHttp) {
|
||||
const httpData = httpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp",
|
||||
});
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp"
|
||||
});
|
||||
} else {
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
protocol: tcpUdpData.protocol,
|
||||
proxyPort: tcpUdpData.proxyPort
|
||||
proxyPort: tcpUdpData.proxyPort,
|
||||
enableProxy: tcpUdpData.enableProxy
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -198,8 +203,15 @@ export default function Page() {
|
|||
if (isHttp) {
|
||||
router.push(`/${orgId}/settings/resources/${id}`);
|
||||
} else {
|
||||
setShowSnippets(true);
|
||||
router.refresh();
|
||||
const tcpUdpData = tcpUdpForm.getValues();
|
||||
// Only show config snippets if enableProxy is explicitly true
|
||||
if (tcpUdpData.enableProxy === true) {
|
||||
setShowSnippets(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
// If enableProxy is false or undefined, go directly to resource page
|
||||
router.push(`/${orgId}/settings/resources/${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -265,9 +277,9 @@ export default function Page() {
|
|||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
setBaseDomains(domains);
|
||||
if (domains.length) {
|
||||
httpForm.setValue("domainId", domains[0].domainId);
|
||||
}
|
||||
// if (domains.length) {
|
||||
// httpForm.setValue("domainId", domains[0].domainId);
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -603,6 +615,46 @@ export default function Page() {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
<FormField
|
||||
control={
|
||||
tcpUdpForm.control
|
||||
}
|
||||
name="enableProxy"
|
||||
render={({
|
||||
field
|
||||
}) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
variant={
|
||||
"outlinePrimarySquare"
|
||||
}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"resourceEnableProxy"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceEnableProxyDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
@ -632,6 +684,8 @@ export default function Page() {
|
|||
? await httpForm.trigger()
|
||||
: await tcpUdpForm.trigger();
|
||||
|
||||
console.log(httpForm.getValues());
|
||||
|
||||
if (baseValid && settingsValid) {
|
||||
onSubmit();
|
||||
}
|
||||
|
|
|
@ -33,10 +33,17 @@ import { useState } from "react";
|
|||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
dockerSocketEnabled: z.boolean().optional()
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
remoteSubnets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
@ -44,9 +51,11 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
|||
export default function GeneralPage() {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
@ -55,7 +64,13 @@ export default function GeneralPage() {
|
|||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: site?.name,
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||
remoteSubnets: site?.remoteSubnets
|
||||
? site.remoteSubnets.split(',').map((subnet, index) => ({
|
||||
id: subnet.trim(),
|
||||
text: subnet.trim()
|
||||
}))
|
||||
: []
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
@ -66,7 +81,8 @@ export default function GeneralPage() {
|
|||
await api
|
||||
.post(`/site/${site?.siteId}`, {
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
|
@ -81,7 +97,8 @@ export default function GeneralPage() {
|
|||
|
||||
updateSite({
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
});
|
||||
|
||||
toast({
|
||||
|
@ -124,12 +141,47 @@ export default function GeneralPage() {
|
|||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("siteNameDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteSubnets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeCidrTagIndex}
|
||||
setActiveTagIndex={setActiveCidrTagIndex}
|
||||
placeholder={t("enterCidrRange")}
|
||||
size="sm"
|
||||
tags={form.getValues().remoteSubnets || []}
|
||||
setTags={(newSubnets) => {
|
||||
form.setValue(
|
||||
"remoteSubnets",
|
||||
newSubnets as Tag[]
|
||||
);
|
||||
}}
|
||||
validateTag={(tag) => {
|
||||
// Basic CIDR validation regex
|
||||
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(tag);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("remoteSubnetsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { SiNixos } from "react-icons/si";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
|
@ -74,6 +75,7 @@ type Commands = {
|
|||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
podman: Record<string, string[]>;
|
||||
nixos: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const platforms = [
|
||||
|
@ -82,7 +84,8 @@ const platforms = [
|
|||
"podman",
|
||||
"mac",
|
||||
"windows",
|
||||
"freebsd"
|
||||
"freebsd",
|
||||
"nixos"
|
||||
] as const;
|
||||
|
||||
type Platform = (typeof platforms)[number];
|
||||
|
@ -285,6 +288,14 @@ WantedBy=default.target`
|
|||
"Podman Run": [
|
||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
nixos: {
|
||||
x86_64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
aarch64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
|
@ -304,6 +315,8 @@ WantedBy=default.target`
|
|||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "freebsd":
|
||||
return ["amd64", "arm64"];
|
||||
case "nixos":
|
||||
return ["x86_64", "aarch64"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
|
@ -321,6 +334,8 @@ WantedBy=default.target`
|
|||
return "Podman";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
case "nixos":
|
||||
return "NixOS";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
|
@ -365,6 +380,8 @@ WantedBy=default.target`
|
|||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
|
@ -587,11 +604,6 @@ WantedBy=default.target`
|
|||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
@ -33,6 +34,7 @@ import Image from "next/image";
|
|||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -44,7 +46,19 @@ const formSchema = z
|
|||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
confirmPassword: passwordSchema,
|
||||
agreeToTerms: z.boolean().refine(
|
||||
(val) => {
|
||||
if (build === "saas") {
|
||||
val === true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"You must agree to the terms of service and privacy policy"
|
||||
}
|
||||
)
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
|
@ -64,13 +78,15 @@ export default function SignupForm({
|
|||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -85,7 +101,8 @@ export default function SignupForm({
|
|||
email,
|
||||
password,
|
||||
inviteId,
|
||||
inviteToken
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
|
@ -120,14 +137,23 @@ export default function SignupForm({
|
|||
return t("authCreateAccount");
|
||||
}
|
||||
|
||||
const handleTermsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const isoNow = new Date().toISOString();
|
||||
console.log("Terms agreed at:", isoNow);
|
||||
setTermsAgreedAt(isoNow);
|
||||
form.setValue("agreeToTerms", true);
|
||||
} else {
|
||||
form.setValue("agreeToTerms", false);
|
||||
setTermsAgreedAt(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo
|
||||
height={58}
|
||||
width={175}
|
||||
/>
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
|
@ -180,6 +206,54 @@ export default function SignupForm({
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpTerms.IAgreeToThe")}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}
|
||||
</a>
|
||||
{t("signUpTerms.and")}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
|
|
|
@ -12,15 +12,24 @@ import {
|
|||
KeyRound,
|
||||
TicketCheck,
|
||||
User,
|
||||
Globe,
|
||||
MonitorUp
|
||||
Globe, // Added from 'dev' branch
|
||||
MonitorUp // Added from 'dev' branch
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
export type SidebarNavSection = { // Added from 'dev' branch
|
||||
heading: string;
|
||||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
// Merged from 'user-management-and-resources' branch
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <User className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
export const orgNavSections = (
|
||||
enableClients: boolean = true
|
||||
): SidebarNavSection[] => [
|
||||
|
|
|
@ -49,6 +49,7 @@ type DomainOption = {
|
|||
|
||||
interface DomainPickerProps {
|
||||
orgId: string;
|
||||
cols?: number;
|
||||
onDomainChange?: (domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
|
@ -61,6 +62,7 @@ interface DomainPickerProps {
|
|||
|
||||
export default function DomainPicker({
|
||||
orgId,
|
||||
cols,
|
||||
onDomainChange
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
|
@ -127,9 +129,6 @@ export default function DomainPicker({
|
|||
|
||||
if (!userInput.trim()) return options;
|
||||
|
||||
// Check if input is more than one level deep (contains multiple dots)
|
||||
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
||||
|
||||
// Add organization domain options
|
||||
organizationDomains.forEach((orgDomain) => {
|
||||
if (orgDomain.type === "cname") {
|
||||
|
@ -309,6 +308,7 @@ export default function DomainPicker({
|
|||
<Input
|
||||
id="domain-input"
|
||||
value={userInput}
|
||||
className="max-w-xl"
|
||||
onChange={(e) => {
|
||||
// Only allow letters, numbers, hyphens, and periods
|
||||
const validInput = e.target.value.replace(
|
||||
|
@ -316,6 +316,8 @@ export default function DomainPicker({
|
|||
""
|
||||
);
|
||||
setUserInput(validInput);
|
||||
// Clear selection when input changes
|
||||
setSelectedOption(null);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
@ -382,7 +384,7 @@ export default function DomainPicker({
|
|||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("domainPickerNoMatchingDomains", { userInput })}
|
||||
{t("domainPickerNoMatchingDomains")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
@ -393,23 +395,25 @@ export default function DomainPicker({
|
|||
{/* Organization Domains */}
|
||||
{organizationOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{build !== "oss" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("domainPickerOrganizationDomains")}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||
{organizationOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-input",
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-accent",
|
||||
option.verified
|
||||
? "cursor-pointer hover:bg-accent"
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={() =>
|
||||
|
@ -456,10 +460,6 @@ export default function DomainPicker({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption?.id ===
|
||||
option.id && (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
@ -476,14 +476,14 @@ export default function DomainPicker({
|
|||
{t("domainPickerProvidedDomains")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||
{providedOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"transition-all p-3 rounded-lg border",
|
||||
selectedOption?.id === option.id
|
||||
? "border-primary bg-primary/5"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input",
|
||||
"cursor-pointer hover:bg-accent"
|
||||
)}
|
||||
|
|
|
@ -63,7 +63,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [securityKeyLoading, setSecurityKeyLoading] = useState(false);
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
|
||||
const [mfaRequested, setMfaRequested] = useState(false);
|
||||
|
@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: t('passwordRequirementsChars') })
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||
});
|
||||
|
||||
const mfaSchema = z.object({
|
||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
|
@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
|
||||
async function initiateSecurityKeyAuth() {
|
||||
setShowSecurityKeyPrompt(true);
|
||||
setSecurityKeyLoading(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Start WebAuthn authentication without email
|
||||
const startRes = await api.post("/auth/security-key/authenticate/start", {});
|
||||
const startRes = await api.post(
|
||||
"/auth/security-key/authenticate/start",
|
||||
{}
|
||||
);
|
||||
|
||||
if (!startRes) {
|
||||
setError(t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to start security key authentication"
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to start security key authentication"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
{ credential },
|
||||
{
|
||||
headers: {
|
||||
'X-Temp-Session-Id': tempSessionId
|
||||
"X-Temp-Session-Id": tempSessionId
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -136,39 +139,61 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if (error.message.includes('denied permission')) {
|
||||
setError(t('securityKeyPermissionDenied', {
|
||||
defaultValue: "Please allow access to your security key to continue signing in."
|
||||
}));
|
||||
if (error.name === "NotAllowedError") {
|
||||
if (error.message.includes("denied permission")) {
|
||||
setError(
|
||||
t("securityKeyPermissionDenied", {
|
||||
defaultValue:
|
||||
"Please allow access to your security key to continue signing in."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setError(t('securityKeyRemovedTooQuickly', {
|
||||
defaultValue: "Please keep your security key connected until the sign-in process completes."
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyRemovedTooQuickly", {
|
||||
defaultValue:
|
||||
"Please keep your security key connected until the sign-in process completes."
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
setError(t('securityKeyNotSupported', {
|
||||
defaultValue: "Your security key may not be compatible. Please try a different security key."
|
||||
}));
|
||||
} else if (error.name === "NotSupportedError") {
|
||||
setError(
|
||||
t("securityKeyNotSupported", {
|
||||
defaultValue:
|
||||
"Your security key may not be compatible. Please try a different security key."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setError(t('securityKeyUnknownError', {
|
||||
defaultValue: "There was a problem using your security key. Please try again."
|
||||
}));
|
||||
setError(
|
||||
t("securityKeyUnknownError", {
|
||||
defaultValue:
|
||||
"There was a problem using your security key. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to authenticate with security key"
|
||||
})));
|
||||
setError(
|
||||
formatAxiosError(
|
||||
e,
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(e.message || t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to authenticate with security key"
|
||||
}));
|
||||
setError(
|
||||
e.message ||
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSecurityKeyLoading(false);
|
||||
setLoading(false);
|
||||
setShowSecurityKeyPrompt(false);
|
||||
}
|
||||
}
|
||||
|
@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
setShowSecurityKeyPrompt(false);
|
||||
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||
email,
|
||||
password,
|
||||
code
|
||||
});
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>(
|
||||
"/auth/login",
|
||||
{
|
||||
email,
|
||||
password,
|
||||
code
|
||||
}
|
||||
);
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
|
@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
}
|
||||
|
||||
if (data?.twoFactorSetupRequired) {
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||
router.push(setupUrl);
|
||||
return;
|
||||
}
|
||||
|
@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
const errorMessage = formatAxiosError(e, t('loginError', {
|
||||
defaultValue: "Failed to log in"
|
||||
}));
|
||||
const errorMessage = formatAxiosError(
|
||||
e,
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
setError(errorMessage);
|
||||
return;
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(e.message || t('loginError', {
|
||||
defaultValue: "Failed to log in"
|
||||
}));
|
||||
setError(
|
||||
e.message ||
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
|
@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
console.log(res);
|
||||
|
||||
if (!res) {
|
||||
setError(t('loginError'));
|
||||
setError(t("loginError"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
<Alert>
|
||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||
<AlertDescription>
|
||||
{t('securityKeyPrompt', {
|
||||
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
{t("securityKeyPrompt", {
|
||||
defaultValue:
|
||||
"Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
|
@ -320,18 +357,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t('passwordForgot')}
|
||||
{t("passwordForgot")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? t('idpConnectingToProcess', {
|
||||
defaultValue: "Connecting..."
|
||||
}) : t('login', {
|
||||
defaultValue: "Log in"
|
||||
})}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
{mfaRequested && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t('otpAuth')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('otpAuthDescription')}
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
|
@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
mfaForm.handleSubmit(onSubmit)();
|
||||
if (
|
||||
value.length === 6
|
||||
) {
|
||||
mfaForm.handleSubmit(
|
||||
onSubmit
|
||||
)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('otpAuthSubmit')}
|
||||
{t("otpAuthSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={initiateSecurityKeyAuth}
|
||||
loading={securityKeyLoading}
|
||||
disabled={securityKeyLoading || showSecurityKeyPrompt}
|
||||
loading={loading}
|
||||
disabled={loading || showSecurityKeyPrompt}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t('securityKeyLogin', {
|
||||
{t("securityKeyLogin", {
|
||||
defaultValue: "Sign in with security key"
|
||||
})}
|
||||
</Button>
|
||||
|
@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
{t('idpContinue')}
|
||||
{t("idpContinue")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
|||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
{t('otpAuthBack')}
|
||||
{t("otpAuthBack")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
|
|||
|
||||
interface InfoPopupProps {
|
||||
text?: string;
|
||||
info: string;
|
||||
info?: string;
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
|||
{trigger ?? defaultTrigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
{children ||
|
||||
(info && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info}
|
||||
</p>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
|
@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
|||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
|
||||
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:top-0 md:bottom-auto md:left-1/2 md:-translate-x-1/2 md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue