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