diff --git a/Dockerfile b/Dockerfile index 3d0a0f68..07371f77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,8 @@ COPY ./cli/wrapper.sh /usr/local/bin/pangctl RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs COPY server/db/names.json ./dist/names.json +COPY server/db/ios_models.json ./dist/ios_models.json +COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public # OCI Image Labels diff --git a/cli/commands/clearLicenseKeys.ts b/cli/commands/clearLicenseKeys.ts new file mode 100644 index 00000000..704641d3 --- /dev/null +++ b/cli/commands/clearLicenseKeys.ts @@ -0,0 +1,36 @@ +import { CommandModule } from "yargs"; +import { db, licenseKey } from "@server/db"; +import { eq } from "drizzle-orm"; + +type ClearLicenseKeysArgs = { }; + +export const clearLicenseKeys: CommandModule< + {}, + ClearLicenseKeysArgs +> = { + command: "clear-license-keys", + describe: + "Clear all license keys from the database", + // no args + builder: (yargs) => { + return yargs; + }, + handler: async (argv: {}) => { + try { + + console.log(`Clearing all license keys from the database`); + + // Delete all license keys + const deletedCount = await db + .delete(licenseKey) + .where(eq(licenseKey.licenseKeyId, licenseKey.licenseKeyId)) .returning();; // delete all + + console.log(`Deleted ${deletedCount.length} license key(s) from the database`); + + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index f44f41ba..328520aa 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -6,6 +6,7 @@ import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; import { clearExitNodes } from "./commands/clearExitNodes"; import { rotateServerSecret } from "./commands/rotateServerSecret"; +import { clearLicenseKeys } from "./commands/clearLicenseKeys"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -13,5 +14,6 @@ yargs(hideBin(process.argv)) .command(resetUserSecurityKeys) .command(clearExitNodes) .command(rotateServerSecret) + .command(clearLicenseKeys) .demandCommand() .help().argv; diff --git a/install/containers.go b/install/containers.go index 464186c2..333fd890 100644 --- a/install/containers.go +++ b/install/containers.go @@ -210,6 +210,47 @@ func isDockerRunning() bool { return true } +func isPodmanRunning() bool { + cmd := exec.Command("podman", "info") + if err := cmd.Run(); err != nil { + return false + } + return true +} + +// detectContainerType detects whether the system is currently using Docker or Podman +// by checking which container runtime is running and has containers +func detectContainerType() SupportedContainer { + // Check if we have running containers with podman + if isPodmanRunning() { + cmd := exec.Command("podman", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Podman + } + } + + // Check if we have running containers with docker + if isDockerRunning() { + cmd := exec.Command("docker", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Docker + } + } + + // If no containers are running, check which one is installed and running + if isPodmanRunning() && isPodmanInstalled() { + return Podman + } + + if isDockerRunning() && isDockerInstalled() { + return Docker + } + + return Undefined +} + // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied func executeDockerComposeCommandWithArgs(args ...string) error { var cmd *exec.Cmd diff --git a/install/crowdsec.go b/install/crowdsec.go index 2e388e92..401ef215 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -93,7 +93,7 @@ func installCrowdsec(config Config) error { if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") { fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:") - fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer") + fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType) } return nil @@ -117,7 +117,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { } // Execute the command to get the API key - cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") + cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") var out bytes.Buffer cmd.Stdout = &out diff --git a/install/main.go b/install/main.go index a231da2d..3ea6af22 100644 --- a/install/main.go +++ b/install/main.go @@ -229,7 +229,16 @@ func main() { } } - config.InstallationContainerType = podmanOrDocker(reader) + // Try to detect container type from existing installation + detectedType := detectContainerType() + if detectedType == Undefined { + // If detection fails, prompt the user + fmt.Println("Unable to detect container type from existing installation.") + config.InstallationContainerType = podmanOrDocker(reader) + } else { + config.InstallationContainerType = detectedType + fmt.Printf("Detected container type: %s\n", config.InstallationContainerType) + } config.DoCrowdsecInstall = true err := installCrowdsec(config) @@ -286,10 +295,10 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { os.Exit(1) } - if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { + if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") - approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true) if approved { if os.Geteuid() != 0 { fmt.Println("You need to run the installer as root for such a configuration.") @@ -300,7 +309,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { // container low-range ports as unprivileged ports. // Linux only. - if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil { fmt.Printf("Error configuring unprivileged ports: %v\n", err) os.Exit(1) } diff --git a/messages/en-US.json b/messages/en-US.json index 3f1f8174..23078d05 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1581,7 +1581,7 @@ "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Time is in seconds", "requireDeviceApproval": "Require Device Approvals", - "requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources", + "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", @@ -2483,5 +2483,31 @@ "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", "signupOrgLink": "Sign in or sign up with your organization instead", "verifyEmailLogInWithDifferentAccount": "Use a Different Account", - "logIn": "Log In" + "logIn": "Log In", + "deviceInformation": "Device Information", + "deviceInformationDescription": "Information about the device and agent", + "platform": "Platform", + "macosVersion": "macOS Version", + "windowsVersion": "Windows Version", + "iosVersion": "iOS Version", + "androidVersion": "Android Version", + "osVersion": "OS Version", + "kernelVersion": "Kernel Version", + "deviceModel": "Device Model", + "serialNumber": "Serial Number", + "hostname": "Hostname", + "firstSeen": "First Seen", + "lastSeen": "Last Seen", + "deviceSettingsDescription": "View device information and settings", + "devicePendingApprovalDescription": "This device is waiting for approval", + "deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.", + "unblockClient": "Unblock Client", + "unblockClientDescription": "The device has been unblocked", + "unarchiveClient": "Unarchive Client", + "unarchiveClientDescription": "The device has been unarchived", + "block": "Block", + "unblock": "Unblock", + "deviceActions": "Device Actions", + "deviceActionsDescription": "Manage device status and access", + "devicePendingApprovalBannerDescription": "This device is pending approval. It won't be able to connect to resources until approved." } diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 0b4a131a..b2ea08a3 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -19,6 +19,7 @@ import logger from "@server/logger"; import { sendTerminateClient } from "@server/routers/client/terminate"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; +import { OlmErrorCodes } from "@server/routers/olm/error"; export async function calculateUserClientsForOrgs( userId: string, @@ -305,6 +306,7 @@ async function cleanupOrphanedClients( if (deletedClient.olmId) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, deletedClient.olmId ); } diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts index 5478c690..b2f9e151 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -24,6 +24,8 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { disconnectClient, sendToClient } from "#private/routers/ws"; +import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; const reGenerateSecretParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -117,12 +119,12 @@ export async function reGenerateClientSecret( // Only disconnect if explicitly requested if (disconnect) { - const payload = { - type: `olm/terminate`, - data: {} - }; // Don't await this to prevent blocking the response - sendToClient(existingOlms[0].olmId, payload).catch((error) => { + sendTerminateClient( + clientId, + OlmErrorCodes.TERMINATED_REKEYED, + existingOlms[0].olmId + ).catch((error) => { logger.error( "Failed to send termination message to olm:", error diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index e1fe3f54..342dba58 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -493,7 +493,7 @@ const sendToClientLocal = async ( } // Handle config version - let configVersion = await getClientConfigVersion(clientId); + const configVersion = await getClientConfigVersion(clientId); // Add config version to message const messageWithVersion = { diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts index 330f6ed8..621a0acf 100644 --- a/server/routers/client/archiveClient.ts +++ b/server/routers/client/archiveClient.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const archiveClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -79,11 +80,6 @@ export async function archiveClient( // Rebuild associations to clean up related data await rebuildClientAssociationsFromClient(client, trx); - - // Send terminate signal if there's an associated OLM - if (client.olmId) { - await sendTerminateClient(client.clientId, client.olmId); - } }); return response(res, { diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts index 68ae64f8..bd760b3d 100644 --- a/server/routers/client/blockClient.ts +++ b/server/routers/client/blockClient.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const blockClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -78,7 +79,7 @@ export async function blockClient( // Send terminate signal if there's an associated OLM and it's connected if (client.olmId && client.online) { - await sendTerminateClient(client.clientId, client.olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); } }); diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index a16a2996..276bfde9 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const deleteClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -91,7 +92,7 @@ export async function deleteClient( await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { - await sendTerminateClient(deletedClient.clientId, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion + await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion } }); diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 709eb1a5..7917e037 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -49,6 +49,20 @@ export type GetClientResponse = NonNullable< Awaited> >["clients"] & { olmId: string | null; + agent: string | null; + olmVersion: string | null; + fingerprint: { + username: string | null; + hostname: string | null; + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + firstSeen: number | null; + lastSeen: number | null; + } | null; }; registry.registerPath({ @@ -115,10 +129,29 @@ export async function getClient( clientName = getUserDeviceName(model, client.clients.name); } + // Build fingerprint data if available + const fingerprintData = client.fingerprints + ? { + username: client.fingerprints.username || null, + hostname: client.fingerprints.hostname || null, + platform: client.fingerprints.platform || null, + osVersion: client.fingerprints.osVersion || null, + kernelVersion: client.fingerprints.kernelVersion || null, + arch: client.fingerprints.arch || null, + deviceModel: client.fingerprints.deviceModel || null, + serialNumber: client.fingerprints.serialNumber || null, + firstSeen: client.fingerprints.firstSeen || null, + lastSeen: client.fingerprints.lastSeen || null + } + : null; + const data: GetClientResponse = { ...client.clients, name: clientName, - olmId: client.olms ? client.olms.olmId : null + olmId: client.olms ? client.olms.olmId : null, + agent: client.olms?.agent || null, + olmVersion: client.olms?.version || null, + fingerprint: fingerprintData }; return response(res, { diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 99857261..31f75d68 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -143,7 +143,14 @@ function queryClients( olmArchived: olms.archived, archived: clients.archived, blocked: clients.blocked, - deviceModel: fingerprints.deviceModel + deviceModel: fingerprints.deviceModel, + fingerprintPlatform: fingerprints.platform, + fingerprintOsVersion: fingerprints.osVersion, + fingerprintKernelVersion: fingerprints.kernelVersion, + fingerprintArch: fingerprints.arch, + fingerprintSerialNumber: fingerprints.serialNumber, + fingerprintUsername: fingerprints.username, + fingerprintHostname: fingerprints.hostname }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) diff --git a/server/routers/client/terminate.ts b/server/routers/client/terminate.ts index 1cfdc709..db9cfcb0 100644 --- a/server/routers/client/terminate.ts +++ b/server/routers/client/terminate.ts @@ -1,9 +1,11 @@ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms } from "@server/db"; import { eq } from "drizzle-orm"; +import { OlmErrorCodes } from "../olm/error"; export async function sendTerminateClient( clientId: number, + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId?: string | null ) { if (!olmId) { @@ -20,6 +22,9 @@ export async function sendTerminateClient( await sendToClient(olmId, { type: `olm/terminate`, - data: {} + data: { + code: error.code, + message: error.message + } }); } diff --git a/server/routers/external.ts b/server/routers/external.ts index 3ea60983..2287ee26 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -18,6 +18,7 @@ import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; import * as newt from "./newt"; import * as olm from "./olm"; +import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -712,6 +713,8 @@ authenticated.get( authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); +authenticated.get(`/server-info`, serverInfo.getServerInfo); + authenticated.post( `/supporter-key/validate`, supporterKey.validateSupporterKey diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts index 46abd1a1..b1a7bb4d 100644 --- a/server/routers/olm/archiveUserOlm.ts +++ b/server/routers/olm/archiveUserOlm.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ @@ -52,7 +53,7 @@ export async function archiveUserOlm( .where(eq(clients.clientId, client.clientId)); await rebuildClientAssociationsFromClient(client, trx); - await sendTerminateClient(client.clientId, olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId); } // Archive the OLM (set archived to true) diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 83a3d16f..2c281489 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -11,6 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ @@ -76,6 +77,7 @@ export async function deleteUserOlm( if (olm) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, olm.olmId ); // the olmId needs to be provided because it cant look it up after deletion } diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts new file mode 100644 index 00000000..6ea209ce --- /dev/null +++ b/server/routers/olm/error.ts @@ -0,0 +1,104 @@ +import { sendToClient } from "#dynamic/routers/ws"; +// Error codes for registration failures +export const OlmErrorCodes = { + OLM_NOT_FOUND: { + code: "OLM_NOT_FOUND", + message: "The specified device could not be found." + }, + CLIENT_ID_NOT_FOUND: { + code: "CLIENT_ID_NOT_FOUND", + message: "No client ID was provided in the request." + }, + CLIENT_NOT_FOUND: { + code: "CLIENT_NOT_FOUND", + message: "The specified client does not exist." + }, + CLIENT_BLOCKED: { + code: "CLIENT_BLOCKED", + message: + "This client has been blocked in this organization and cannot connect. Please contact your administrator." + }, + CLIENT_PENDING: { + code: "CLIENT_PENDING", + message: + "This client is pending approval and cannot connect yet. Please contact your administrator." + }, + ORG_NOT_FOUND: { + code: "ORG_NOT_FOUND", + message: + "The organization could not be found. Please select a valid organization." + }, + USER_ID_NOT_FOUND: { + code: "USER_ID_NOT_FOUND", + message: "No user ID was provided in the request." + }, + INVALID_USER_SESSION: { + code: "INVALID_USER_SESSION", + message: + "Your user session is invalid or has expired. Please log in again." + }, + USER_ID_MISMATCH: { + code: "USER_ID_MISMATCH", + message: "The provided user ID does not match the session." + }, + ORG_ACCESS_POLICY_DENIED: { + code: "ORG_ACCESS_POLICY_DENIED", + message: + "Access to this organization has been denied by policy. Please contact your administrator." + }, + ORG_ACCESS_POLICY_PASSWORD_EXPIRED: { + code: "ORG_ACCESS_POLICY_PASSWORD_EXPIRED", + message: + "Access to this organization has been denied because your password has expired. Please visit this organization's dashboard to update your password." + }, + ORG_ACCESS_POLICY_SESSION_EXPIRED: { + code: "ORG_ACCESS_POLICY_SESSION_EXPIRED", + message: + "Access to this organization has been denied because your session has expired. Please log in again to refresh the session." + }, + ORG_ACCESS_POLICY_2FA_REQUIRED: { + code: "ORG_ACCESS_POLICY_2FA_REQUIRED", + message: + "Access to this organization requires two-factor authentication. Please visit this organization's dashboard to enable two-factor authentication." + }, + TERMINATED_REKEYED: { + code: "TERMINATED_REKEYED", + message: + "This session was terminated because encryption keys were regenerated." + }, + TERMINATED_ORG_DELETED: { + code: "TERMINATED_ORG_DELETED", + message: + "This session was terminated because the organization was deleted." + }, + TERMINATED_INACTIVITY: { + code: "TERMINATED_INACTIVITY", + message: "This session was terminated due to inactivity." + }, + TERMINATED_DELETED: { + code: "TERMINATED_DELETED", + message: "This session was terminated because it was deleted." + }, + TERMINATED_ARCHIVED: { + code: "TERMINATED_ARCHIVED", + message: "This session was terminated because it was archived." + }, + TERMINATED_BLOCKED: { + code: "TERMINATED_BLOCKED", + message: "This session was terminated because access was blocked." + } +} as const; + +// Helper function to send registration error +export async function sendOlmError( + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], + olmId: string +) { + sendToClient(olmId, { + type: "olm/error", + data: { + code: error.code, + message: error.message + } + }); +} diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts index dc0bfde3..578438f8 100644 --- a/server/routers/olm/getUserOlm.ts +++ b/server/routers/olm/getUserOlm.ts @@ -8,8 +8,8 @@ import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; +// import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ @@ -101,7 +101,7 @@ export async function getUserOlm( const model = result.fingerprints?.deviceModel || null; const newName = getUserDeviceName(model, olm.name); - const responseData = blocked !== undefined + const responseData = blocked !== undefined ? { ...olm, name: newName, blocked } : { ...olm, name: newName }; diff --git a/server/routers/olm/handleOlmDisconnectingMessage.ts b/server/routers/olm/handleOlmDisconnectingMessage.ts new file mode 100644 index 00000000..2ddd5e51 --- /dev/null +++ b/server/routers/olm/handleOlmDisconnectingMessage.ts @@ -0,0 +1,34 @@ +import { MessageHandler } from "@server/routers/ws"; +import { clients, db, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +/** + * Handles disconnecting messages from clients to show disconnected in the ui + */ +export const handleOlmDisconnecingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + online: false + }) + .where(eq(clients.clientId, olm.clientId)); + } catch (error) { + logger.error("Error handling disconnecting message", { error }); + } +}; diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index bfcb7f33..f0999f4f 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -10,6 +10,7 @@ import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { sendOlmSyncMessage } from "./sync"; +import { OlmErrorCodes } from "./error"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; @@ -64,6 +65,7 @@ export const startOlmOfflineChecker = (): void => { try { await sendTerminateClient( offlineClient.clientId, + OlmErrorCodes.TERMINATED_INACTIVITY, offlineClient.olmId ); // terminate first // wait a moment to ensure the message is sent @@ -176,7 +178,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { logger.debug(`handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})`); if (configVersion == null || configVersion === undefined) { - logger.debug(`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`) + logger.debug(`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`); } if (message.configVersion != null && configVersion != null && configVersion != message.configVersion) { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 46241603..f21705dd 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,32 +1,20 @@ -import { - Client, - clientPostureSnapshots, - clientSiteResourcesAssociationsCache, - db, - fingerprints, - orgs, - siteResources -} from "@server/db"; +import { clientPostureSnapshots, db, fingerprints, orgs } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, olms, sites } from "@server/db"; -import { and, count, eq, inArray, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; +import { count, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { generateAliasConfig } from "@server/lib/ip"; -import { generateRemoteSubnets } from "@server/lib/ip"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; +import { OlmErrorCodes, sendOlmError } from "./error"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); @@ -53,78 +41,88 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!olm.clientId) { logger.warn("Olm client ID not found"); + sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); return; } - const [client] = await db - .select() - .from(clients) - .where(eq(clients.clientId, olm.clientId)) - .limit(1); + if (fingerprint) { + const [existingFingerprint] = await db + .select() + .from(fingerprints) + .where(eq(fingerprints.olmId, olm.olmId)) + .limit(1); - if (!client) { - logger.warn("Client ID not found"); - return; - } + if (!existingFingerprint) { + await db.insert(fingerprints).values({ + olmId: olm.olmId, + firstSeen: now, + lastSeen: now, - if (client.blocked) { - logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); - return; - } + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint + }); + } else { + const hasChanges = + existingFingerprint.username !== fingerprint.username || + existingFingerprint.hostname !== fingerprint.hostname || + existingFingerprint.platform !== fingerprint.platform || + existingFingerprint.osVersion !== fingerprint.osVersion || + existingFingerprint.kernelVersion !== + fingerprint.kernelVersion || + existingFingerprint.arch !== fingerprint.arch || + existingFingerprint.deviceModel !== fingerprint.deviceModel || + existingFingerprint.serialNumber !== fingerprint.serialNumber || + existingFingerprint.platformFingerprint !== + fingerprint.platformFingerprint; - const [org] = await db - .select() - .from(orgs) - .where(eq(orgs.orgId, client.orgId)) - .limit(1); - - if (!org) { - logger.warn("Org not found"); - return; - } - - if (orgId) { - if (!olm.userId) { - logger.warn("Olm has no user ID"); - return; + if (hasChanges) { + await db + .update(fingerprints) + .set({ + lastSeen: now, + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint + }) + .where(eq(fingerprints.olmId, olm.olmId)); + } } + } - const { session: userSession, user } = - await validateSessionToken(userToken); - if (!userSession || !user) { - logger.warn("Invalid user session for olm register"); - return; // by returning here we just ignore the ping and the setInterval will force it to disconnect - } - if (user.userId !== olm.userId) { - logger.warn("User ID mismatch for olm register"); - return; - } + if (postures) { + await db.insert(clientPostureSnapshots).values({ + clientId: olm.clientId, - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(userToken)) - ); + biometricsEnabled: postures?.biometricsEnabled, + diskEncrypted: postures?.diskEncrypted, + firewallEnabled: postures?.firewallEnabled, + autoUpdatesEnabled: postures?.autoUpdatesEnabled, + tpmAvailable: postures?.tpmAvailable, - const policyCheck = await checkOrgAccessPolicy({ - orgId: orgId, - userId: olm.userId, - sessionId // this is the user token passed in the message + windowsDefenderEnabled: postures?.windowsDefenderEnabled, + + macosSipEnabled: postures?.macosSipEnabled, + macosGatekeeperEnabled: postures?.macosGatekeeperEnabled, + macosFirewallStealthMode: postures?.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures?.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures?.linuxSELinuxEnabled, + + collectedAt: now }); - - if (!policyCheck.allowed) { - logger.warn( - `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` - ); - return; - } - } - - logger.debug( - `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` - ); - - if (!publicKey) { - logger.warn("Public key not provided"); - return; } if ( @@ -142,6 +140,133 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .where(eq(olms.olmId, olm.olmId)); } + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, olm.clientId)) + .limit(1); + + if (!client) { + logger.warn("Client ID not found"); + sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId); + return; + } + + if (client.blocked) { + logger.debug( + `Client ${client.clientId} is blocked. Ignoring register.` + ); + sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); + return; + } + + if (client.approvalState == "pending") { + logger.debug( + `Client ${client.clientId} approval is pending. Ignoring register.` + ); + sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); + return; + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, client.orgId)) + .limit(1); + + if (!org) { + logger.warn("Org not found"); + sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); + return; + } + + if (orgId) { + if (!olm.userId) { + logger.warn("Olm has no user ID"); + sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); + return; + } + + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + logger.warn("Invalid user session for olm register"); + sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); + return; + } + if (user.userId !== olm.userId) { + logger.warn("User ID mismatch for olm register"); + sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); + return; + } + + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(userToken)) + ); + + const policyCheck = await checkOrgAccessPolicy({ + orgId: orgId, + userId: olm.userId, + sessionId // this is the user token passed in the message + }); + + logger.debug("Policy check result:", policyCheck); + + if (policyCheck?.error) { + logger.error( + `Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` + ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); + return; + } + + if (!policyCheck.policies?.passwordAge?.compliant === false) { + logger.warn( + `Olm user ${olm.userId} has non-compliant password age for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, + olm.olmId + ); + return; + } else if ( + !policyCheck.policies?.maxSessionLength?.compliant === false + ) { + logger.warn( + `Olm user ${olm.userId} has non-compliant session length for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, + olm.olmId + ); + return; + } else if (policyCheck.policies?.requiredTwoFactor === false) { + logger.warn( + `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, + olm.olmId + ); + return; + } else if (!policyCheck.allowed) { + logger.warn( + `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` + ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); + return; + } + } + + logger.debug( + `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` + ); + + if (!publicKey) { + logger.warn("Public key not provided"); + return; + } + if (client.pubKey !== publicKey || client.archived) { logger.info( "Public key mismatch. Updating public key and clearing session info..." @@ -151,7 +276,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .update(clients) .set({ pubKey: publicKey, - archived: false, + archived: false }) .where(eq(clients.clientId, client.clientId)); @@ -198,72 +323,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { relay ); - if (fingerprint) { - const [existingFingerprint] = await db - .select() - .from(fingerprints) - .where(eq(fingerprints.olmId, olm.olmId)) - .limit(1); - - if (!existingFingerprint) { - await db.insert(fingerprints).values({ - olmId: olm.olmId, - firstSeen: now, - lastSeen: now, - - username: fingerprint.username, - hostname: fingerprint.hostname, - platform: fingerprint.platform, - osVersion: fingerprint.osVersion, - kernelVersion: fingerprint.kernelVersion, - arch: fingerprint.arch, - deviceModel: fingerprint.deviceModel, - serialNumber: fingerprint.serialNumber, - platformFingerprint: fingerprint.platformFingerprint - }); - } else { - await db - .update(fingerprints) - .set({ - lastSeen: now, - - username: fingerprint.username, - hostname: fingerprint.hostname, - platform: fingerprint.platform, - osVersion: fingerprint.osVersion, - kernelVersion: fingerprint.kernelVersion, - arch: fingerprint.arch, - deviceModel: fingerprint.deviceModel, - serialNumber: fingerprint.serialNumber, - platformFingerprint: fingerprint.platformFingerprint - }) - .where(eq(fingerprints.olmId, olm.olmId)); - } - } - - if (postures && olm.clientId) { - await db.insert(clientPostureSnapshots).values({ - clientId: olm.clientId, - - biometricsEnabled: postures?.biometricsEnabled, - diskEncrypted: postures?.diskEncrypted, - firewallEnabled: postures?.firewallEnabled, - autoUpdatesEnabled: postures?.autoUpdatesEnabled, - tpmAvailable: postures?.tpmAvailable, - - windowsDefenderEnabled: postures?.windowsDefenderEnabled, - - macosSipEnabled: postures?.macosSipEnabled, - macosGatekeeperEnabled: postures?.macosGatekeeperEnabled, - macosFirewallStealthMode: postures?.macosFirewallStealthMode, - - linuxAppArmorEnabled: postures?.linuxAppArmorEnabled, - linuxSELinuxEnabled: postures?.linuxSELinuxEnabled, - - collectedAt: now - }); - } - // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES // if (siteConfigurations.length === 0) { // logger.warn("No valid site configurations found"); diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index c9017911..f04ba0be 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -10,3 +10,4 @@ export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; export * from "./recoverOlmWithFingerprint"; +export * from "./handleOlmDisconnectingMessage"; diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts index 6147919c..d4ecd22c 100644 --- a/server/routers/olm/sync.ts +++ b/server/routers/olm/sync.ts @@ -66,7 +66,7 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) { }); } - logger.debug("sendOlmSyncMessage: sending sync message") + logger.debug("sendOlmSyncMessage: sending sync message"); await sendToClient(olm.olmId, { type: "olm/sync", diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 35dc7503..48d3102d 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -21,6 +21,8 @@ import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; +import { OlmErrorCodes } from "../olm/error"; +import { sendTerminateClient } from "../client/terminate"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -206,10 +208,11 @@ export async function deleteOrg( } for (const olmId of olmsToTerminate) { - sendToClient(olmId, { - type: "olm/terminate", - data: {} - }).catch((error) => { + sendTerminateClient( + 0, // clientId not needed since we're passing olmId + OlmErrorCodes.TERMINATED_REKEYED, + olmId + ).catch((error) => { logger.error( "Failed to send termination message to olm:", error diff --git a/server/routers/serverInfo/getServerInfo.ts b/server/routers/serverInfo/getServerInfo.ts new file mode 100644 index 00000000..fa71cedc --- /dev/null +++ b/server/routers/serverInfo/getServerInfo.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import config from "@server/lib/config"; +import { build } from "@server/build"; +import { APP_VERSION } from "@server/lib/consts"; +import license from "#dynamic/license/license"; + +export type GetServerInfoResponse = { + version: string; + supporterStatusValid: boolean; + build: "oss" | "enterprise" | "saas"; + enterpriseLicenseValid: boolean; + enterpriseLicenseType: string | null; +}; + +export async function getServerInfo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const supporterData = config.getSupporterData(); + const supporterStatusValid = supporterData?.valid || false; + + let enterpriseLicenseValid = false; + let enterpriseLicenseType: string | null = null; + + if (build === "enterprise") { + try { + const licenseStatus = await license.check(); + enterpriseLicenseValid = licenseStatus.isLicenseValid; + enterpriseLicenseType = licenseStatus.tier || null; + } catch (error) { + logger.warn("Failed to check enterprise license status:", error); + } + } + + return sendResponse(res, { + data: { + version: APP_VERSION, + supporterStatusValid, + build, + enterpriseLicenseValid, + enterpriseLicenseType + }, + success: true, + error: false, + message: "Server info retrieved", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/serverInfo/index.ts b/server/routers/serverInfo/index.ts new file mode 100644 index 00000000..1bbfbdba --- /dev/null +++ b/server/routers/serverInfo/index.ts @@ -0,0 +1 @@ +export * from "./getServerInfo"; diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 55423c01..94d9d920 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -11,6 +11,7 @@ import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -63,23 +64,37 @@ export async function deleteSite( let deletedNewtId: string | null = null; await db.transaction(async (trx) => { - if (site.pubKey) { - if (site.type == "wireguard") { + if (site.type == "wireguard") { + if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, siteId)) - .returning(); - if (deletedNewt) { - deletedNewtId = deletedNewt.newtId; + } + } else if (site.type == "newt") { + // delete all of the site resources on this site + const siteResourcesOnSite = trx + .delete(siteResources) + .where(eq(siteResources.siteId, siteId)) + .returning(); - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where(eq(newtSessions.newtId, deletedNewt.newtId)); - } + // loop through them + for (const removedSiteResource of await siteResourcesOnSite) { + await rebuildClientAssociationsFromSiteResource( + removedSiteResource, + trx + ); + } + + // get the newt on the site by querying the newt table for siteId + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, siteId)) + .returning(); + if (deletedNewt) { + deletedNewtId = deletedNewt.newtId; + + // delete all of the sessions for the newt + await trx + .delete(newtSessions) + .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index e1902477..b9a1abc9 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -23,15 +23,10 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ email: z + .string() .email() .toLowerCase() - .optional() - .refine((data) => { - if (data) { - return z.email().safeParse(data).success; - } - return true; - }), + .optional(), username: z.string().nonempty().toLowerCase(), name: z.string().optional(), type: z.enum(["internal", "oidc"]).optional(), diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index bcf0b4dc..45c62e6c 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -14,7 +14,8 @@ import { handleOlmPingMessage, startOlmOfflineChecker, handleOlmServerPeerAddMessage, - handleOlmUnRelayMessage + handleOlmUnRelayMessage, + handleOlmDisconnecingMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { MessageHandler } from "./types"; @@ -25,6 +26,7 @@ export const messageHandlers: Record = { "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, "olm/ping": handleOlmPingMessage, + "olm/disconnecting": handleOlmDisconnecingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, "newt/wg/get-config": handleGetConfigMessage, diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index df8d7344..c4b49495 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -305,7 +305,7 @@ export default async function migration() { const subnets = site.remoteSubnets.split(","); for (const subnet of subnets) { // Generate a unique niceId for each new site resource - let niceId = generateName(); + const niceId = generateName(); insertCidrResource.run( site.siteId, subnet.trim(), diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx index 0fef0d78..de62c189 100644 --- a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx +++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx @@ -1,4 +1,5 @@ import { ApprovalFeed } from "@app/components/ApprovalFeed"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -42,6 +43,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { title={t("accessApprovalsManage")} description={t("accessApprovalsDescription")} /> + + +
diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 3199d817..0e55ffeb 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -361,7 +361,7 @@ export default function Page() { const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, @@ -403,7 +403,7 @@ export default function Page() { const res = await api .put(`/org/${orgId}/user`, { username: values.username, - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index c2ef26e4..0e3d9b09 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -29,9 +29,11 @@ import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import ActionBanner from "@app/components/ActionBanner"; +import { Shield, ShieldOff } from "lucide-react"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -45,7 +47,9 @@ export default function GeneralPage() { const { client, updateClient } = useClientContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); + const [, startTransition] = useTransition(); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -109,8 +113,54 @@ export default function GeneralPage() { } } + const handleUnblock = async () => { + if (!client?.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + return ( + {/* Blocked Device Banner */} + {client?.blocked && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} diff --git a/src/app/[orgId]/settings/clients/machine/create/page.tsx b/src/app/[orgId]/settings/clients/machine/create/page.tsx index 5fb12b46..d52e5364 100644 --- a/src/app/[orgId]/settings/clients/machine/create/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/create/page.tsx @@ -73,9 +73,10 @@ type CommandItem = string | { title: string; command: string }; type Commands = { unix: Record; windows: Record; + docker: Record; }; -const platforms = ["unix", "windows"] as const; +const platforms = ["unix", "docker", "windows"] as const; type Platform = (typeof platforms)[number]; @@ -156,6 +157,27 @@ export default function Page() { command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` } ] + }, + docker: { + "Docker Compose": [ + `services: + olm: + image: fosrl/olm + container_name: olm + restart: unless-stopped + network_mode: host + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - PANGOLIN_ENDPOINT=${endpoint} + - OLM_ID=${id} + - OLM_SECRET=${secret}` + ], + "Docker Run": [ + `docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] } }; setCommands(commands); @@ -167,6 +189,8 @@ export default function Page() { return ["All"]; case "windows": return ["x64"]; + case "docker": + return ["Docker Compose", "Docker Run"]; default: return ["x64"]; } diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx new file mode 100644 index 00000000..daa668e6 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -0,0 +1,553 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import ActionBanner from "@app/components/ActionBanner"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { useState, useEffect, useTransition } from "react"; +import { Check, Ban, Shield, ShieldOff, Clock } from "lucide-react"; +import { useParams } from "next/navigation"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { SiAndroid } from "react-icons/si"; + +function formatTimestamp(timestamp: number | null | undefined): string { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleString(); +} + +function formatPlatform(platform: string | null | undefined): string { + if (!platform) return "-"; + const platformMap: Record = { + macos: "macOS", + windows: "Windows", + linux: "Linux", + ios: "iOS", + android: "Android", + unknown: "Unknown" + }; + return platformMap[platform.toLowerCase()] || platform; +} + +function getPlatformIcon(platform: string | null | undefined) { + if (!platform) return null; + const normalizedPlatform = platform.toLowerCase(); + switch (normalizedPlatform) { + case "macos": + case "ios": + return ; + case "windows": + return ; + case "linux": + return ; + case "android": + return ; + default: + return null; + } +} + +type FieldConfig = { + show: boolean; + labelKey: string; +}; + +function getPlatformFieldConfig( + platform: string | null | undefined +): Record { + const normalizedPlatform = platform?.toLowerCase() || "unknown"; + + const configs: Record> = { + macos: { + osVersion: { show: true, labelKey: "macosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + windows: { + osVersion: { show: true, labelKey: "windowsVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + linux: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + ios: { + osVersion: { show: true, labelKey: "iosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + android: { + osVersion: { show: true, labelKey: "androidVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + unknown: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + } + }; + + return configs[normalizedPlatform] || configs.unknown; +} + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const { isPaidUser } = usePaidStatus(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const params = useParams(); + const orgId = params.orgId as string; + const [approvalId, setApprovalId] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [, startTransition] = useTransition(); + + const showApprovalFeatures = build !== "oss" && isPaidUser; + + // Fetch approval ID for this client if pending + useEffect(() => { + if ( + showApprovalFeatures && + client.approvalState === "pending" && + client.clientId + ) { + api.get(`/org/${orgId}/approvals?approvalState=pending`) + .then((res) => { + const approval = res.data.data.approvals.find( + (a: any) => a.clientId === client.clientId + ); + if (approval) { + setApprovalId(approval.approvalId); + } + }) + .catch(() => { + // Silently fail - approval might not exist + }); + } + }, [ + showApprovalFeatures, + client.approvalState, + client.clientId, + orgId, + api + ]); + + const handleApprove = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "approved" + }); + // Optimistically update the client context + updateClient({ approvalState: "approved" }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalApprovedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleDeny = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "denied" + }); + // Optimistically update the client context + updateClient({ approvalState: "denied", blocked: true }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalDeniedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleBlock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/block`); + // Optimistically update the client context + updateClient({ blocked: true, approvalState: "denied" }); + toast({ + title: t("blockClient"), + description: t("blockClientMessage") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleUnblock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + return ( + + {/* Pending Approval Banner */} + {showApprovalFeatures && client.approvalState === "pending" && ( + } + description={t("devicePendingApprovalBannerDescription")} + actions={ + <> + + + + } + /> + )} + + {/* Blocked Device Banner */} + {client.blocked && client.approvalState !== "pending" && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} + + {/* Device Information Section */} + {(client.fingerprint || (client.agent && client.olmVersion)) && ( + + + + {t("deviceInformation")} + + + {t("deviceInformationDescription")} + + + + + {client.agent && client.olmVersion && ( +
+ + + {t("agent")} + + + + {client.agent + + " v" + + client.olmVersion} + + + +
+ )} + + {client.fingerprint && + (() => { + const platform = client.fingerprint.platform; + const fieldConfig = + getPlatformFieldConfig(platform); + + return ( + + {platform && ( + + + {t("platform")} + + +
+ {getPlatformIcon( + platform + )} + + {formatPlatform( + platform + )} + +
+
+
+ )} + + {client.fingerprint.osVersion && + fieldConfig.osVersion.show && ( + + + {t( + fieldConfig + .osVersion + .labelKey + )} + + + { + client.fingerprint + .osVersion + } + + + )} + + {client.fingerprint.kernelVersion && + fieldConfig.kernelVersion.show && ( + + + {t("kernelVersion")} + + + { + client.fingerprint + .kernelVersion + } + + + )} + + {client.fingerprint.arch && + fieldConfig.arch.show && ( + + + {t("architecture")} + + + { + client.fingerprint + .arch + } + + + )} + + {client.fingerprint.deviceModel && + fieldConfig.deviceModel.show && ( + + + {t("deviceModel")} + + + { + client.fingerprint + .deviceModel + } + + + )} + + {client.fingerprint.serialNumber && + fieldConfig.serialNumber.show && ( + + + {t("serialNumber")} + + + { + client.fingerprint + .serialNumber + } + + + )} + + {client.fingerprint.username && + fieldConfig.username.show && ( + + + {t("username")} + + + { + client.fingerprint + .username + } + + + )} + + {client.fingerprint.hostname && + fieldConfig.hostname.show && ( + + + {t("hostname")} + + + { + client.fingerprint + .hostname + } + + + )} + + {client.fingerprint.firstSeen && ( + + + {t("firstSeen")} + + + {formatTimestamp( + client.fingerprint + .firstSeen + )} + + + )} + + {client.fingerprint.lastSeen && ( + + + {t("lastSeen")} + + + {formatTimestamp( + client.fingerprint + .lastSeen + )} + + + )} +
+ ); + })()} +
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx new file mode 100644 index 00000000..7d8059aa --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -0,0 +1,57 @@ +import ClientInfoCard from "@app/components/ClientInfoCard"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import ClientProvider from "@app/providers/ClientProvider"; +import { GetClientResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: number | string; orgId: string }>; +}; + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.niceId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + redirect(`/${params.orgId}/settings/clients/user`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("general"), + href: `/${params.orgId}/settings/clients/user/${params.niceId}/general` + } + ]; + + return ( + <> + + + +
+ + {children} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx new file mode 100644 index 00000000..9ad97186 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; niceId: number | string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/clients/user/${params.niceId}/general` + ); +} diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index dee24532..35a2b2e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) { const mapClientToRow = ( client: ListClientsResponse["clients"][0] ): ClientRow => { + // Build fingerprint object if any fingerprint data exists + const hasFingerprintData = + (client as any).fingerprintPlatform || + (client as any).fingerprintOsVersion || + (client as any).fingerprintKernelVersion || + (client as any).fingerprintArch || + (client as any).fingerprintSerialNumber || + (client as any).fingerprintUsername || + (client as any).fingerprintHostname || + (client as any).deviceModel; + + const fingerprint = hasFingerprintData + ? { + platform: (client as any).fingerprintPlatform || null, + osVersion: (client as any).fingerprintOsVersion || null, + kernelVersion: + (client as any).fingerprintKernelVersion || null, + arch: (client as any).fingerprintArch || null, + deviceModel: (client as any).deviceModel || null, + serialNumber: + (client as any).fingerprintSerialNumber || null, + username: (client as any).fingerprintUsername || null, + hostname: (client as any).fingerprintHostname || null + } + : null; + return { name: client.name, id: client.clientId, @@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) { agent: client.agent, archived: client.archived || false, blocked: client.blocked || false, - approvalState: client.approvalState + approvalState: client.approvalState, + fingerprint }; }; diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 3ed9f0b2..53d03918 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -51,6 +51,10 @@ export default async function GeneralSettingsPage({ title: t("general"), href: `/{orgId}/settings/general`, exact: true + }, + { + title: t("security"), + href: `/{orgId}/settings/general/security` } ]; if (build !== "oss") { diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 3e78adc3..30285ff8 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,19 +1,12 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; import { useState, - useRef, useTransition, - useActionState, - type ComponentRef + useActionState } from "react"; import { Form, @@ -25,13 +18,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -55,79 +41,19 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import type { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; import type { OrgContextType } from "@app/contexts/orgContext"; -// Session length options in hours -const SESSION_LENGTH_OPTIONS = [ - { value: null, labelKey: "unenforced" }, - { value: 1, labelKey: "1Hour" }, - { value: 3, labelKey: "3Hours" }, - { value: 6, labelKey: "6Hours" }, - { value: 12, labelKey: "12Hours" }, - { value: 24, labelKey: "1DaySession" }, - { value: 72, labelKey: "3Days" }, - { value: 168, labelKey: "7Days" }, - { value: 336, labelKey: "14Days" }, - { value: 720, labelKey: "30DaysSession" }, - { value: 2160, labelKey: "90DaysSession" }, - { value: 4320, labelKey: "180DaysSession" } -]; - -// Password expiry options in days - will be translated in component -const PASSWORD_EXPIRY_OPTIONS = [ - { value: null, labelKey: "neverExpire" }, - { value: 1, labelKey: "1Day" }, - { value: 30, labelKey: "30Days" }, - { value: 60, labelKey: "60Days" }, - { value: 90, labelKey: "90Days" }, - { value: 180, labelKey: "180Days" }, - { value: 365, labelKey: "1Year" } -]; - // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional(), - requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional(), - passwordExpiryDays: z.number().nullable().optional(), - settingsLogRetentionDaysRequest: z.number(), - settingsLogRetentionDaysAccess: z.number(), - settingsLogRetentionDaysAction: z.number() + subnet: z.string().optional() }); -type GeneralFormValues = z.infer; - -const LOG_RETENTION_OPTIONS = [ - { label: "logRetentionDisabled", value: 0 }, - { label: "logRetention3Days", value: 3 }, - { label: "logRetention7Days", value: 7 }, - { label: "logRetention14Days", value: 14 }, - { label: "logRetention30Days", value: 30 }, - { label: "logRetention90Days", value: 90 }, - ...(build != "saas" - ? [ - { label: "logRetentionForever", value: -1 }, - { label: "logRetentionEndOfFollowingYear", value: 9001 } - ] - : []) -]; - export default function GeneralPage() { const { org } = useOrgContext(); return ( - - - - {build !== "oss" && } {build !== "saas" && } ); @@ -340,637 +266,3 @@ function GeneralSectionForm({ org }: SectionFormProps) { ); } -function LogRetentionSectionForm({ org }: SectionFormProps) { - const form = useForm({ - resolver: zodResolver( - GeneralFormSchema.pick({ - settingsLogRetentionDaysRequest: true, - settingsLogRetentionDaysAccess: true, - settingsLogRetentionDaysAction: true - }) - ), - defaultValues: { - settingsLogRetentionDaysRequest: - org.settingsLogRetentionDaysRequest ?? 15, - settingsLogRetentionDaysAccess: - org.settingsLogRetentionDaysAccess ?? 15, - settingsLogRetentionDaysAction: - org.settingsLogRetentionDaysAction ?? 15 - }, - mode: "onChange" - }); - - const router = useRouter(); - const t = useTranslations(); - const { isPaidUser, hasSaasSubscription } = usePaidStatus(); - - const [, formAction, loadingSave] = useActionState(performSave, null); - const api = createApiClient(useEnvContext()); - - async function performSave() { - const isValid = await form.trigger(); - if (!isValid) return; - - const data = form.getValues(); - - try { - const reqData = { - settingsLogRetentionDaysRequest: - data.settingsLogRetentionDaysRequest, - settingsLogRetentionDaysAccess: - data.settingsLogRetentionDaysAccess, - settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction - } as any; - - // Update organization - await api.post(`/org/${org.orgId}`, reqData); - - toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) - }); - } - } - - return ( - - - {t("logRetention")} - - {t("logRetentionDescription")} - - - - -
- - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build !== "oss" && ( - <> - - - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - -
-
- -
- -
-
- ); -} - -function SecuritySettingsSectionForm({ org }: SectionFormProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver( - GeneralFormSchema.pick({ - requireTwoFactor: true, - maxSessionLengthHours: true, - passwordExpiryDays: true - }) - ), - defaultValues: { - requireTwoFactor: org.requireTwoFactor || false, - maxSessionLengthHours: org.maxSessionLengthHours || null, - passwordExpiryDays: org.passwordExpiryDays || null - }, - mode: "onChange" - }); - const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); - - // Track initial security policy values - const initialSecurityValues = { - requireTwoFactor: org.requireTwoFactor || false, - maxSessionLengthHours: org.maxSessionLengthHours || null, - passwordExpiryDays: org.passwordExpiryDays || null - }; - - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = - useState(false); - - // Check if security policies have changed - const hasSecurityPolicyChanged = () => { - const currentValues = form.getValues(); - return ( - currentValues.requireTwoFactor !== - initialSecurityValues.requireTwoFactor || - currentValues.maxSessionLengthHours !== - initialSecurityValues.maxSessionLengthHours || - currentValues.passwordExpiryDays !== - initialSecurityValues.passwordExpiryDays - ); - }; - - const [, formAction, loadingSave] = useActionState(onSubmit, null); - const api = createApiClient(useEnvContext()); - - const formRef = useRef>(null); - - async function onSubmit() { - // Check if security policies have changed - if (hasSecurityPolicyChanged()) { - setIsSecurityPolicyConfirmOpen(true); - return; - } - - await performSave(); - } - - async function performSave() { - const isValid = await form.trigger(); - if (!isValid) return; - - const data = form.getValues(); - - try { - const reqData = { - requireTwoFactor: data.requireTwoFactor || false, - maxSessionLengthHours: data.maxSessionLengthHours, - passwordExpiryDays: data.passwordExpiryDays - } as any; - - // Update organization - await api.post(`/org/${org.orgId}`, reqData); - - toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) - }); - } - } - - return ( - <> - -

{t("securityPolicyChangeDescription")}

-
- } - buttonText={t("saveSettings")} - onConfirm={performSave} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - -
- - - { - const isDisabled = !isPaidUser; - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("passwordExpiryDays")} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> - - -
-
- -
- -
-
- - ); -} diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx new file mode 100644 index 00000000..716e35d6 --- /dev/null +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -0,0 +1,751 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { + useState, + useRef, + useActionState, + type ComponentRef +} from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import type { OrgContextType } from "@app/contexts/orgContext"; + +// Session length options in hours +const SESSION_LENGTH_OPTIONS = [ + { value: null, labelKey: "unenforced" }, + { value: 1, labelKey: "1Hour" }, + { value: 3, labelKey: "3Hours" }, + { value: 6, labelKey: "6Hours" }, + { value: 12, labelKey: "12Hours" }, + { value: 24, labelKey: "1DaySession" }, + { value: 72, labelKey: "3Days" }, + { value: 168, labelKey: "7Days" }, + { value: 336, labelKey: "14Days" }, + { value: 720, labelKey: "30DaysSession" }, + { value: 2160, labelKey: "90DaysSession" }, + { value: 4320, labelKey: "180DaysSession" } +]; + +// Password expiry options in days - will be translated in component +const PASSWORD_EXPIRY_OPTIONS = [ + { value: null, labelKey: "neverExpire" }, + { value: 1, labelKey: "1Day" }, + { value: 30, labelKey: "30Days" }, + { value: 60, labelKey: "60Days" }, + { value: 90, labelKey: "90Days" }, + { value: 180, labelKey: "180Days" }, + { value: 365, labelKey: "1Year" } +]; + +// Schema for security organization settings +const SecurityFormSchema = z.object({ + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number() +}); + +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" + ? [ + { label: "logRetentionForever", value: -1 }, + { label: "logRetentionEndOfFollowingYear", value: 9001 } + ] + : []) +]; + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +export default function SecurityPage() { + const { org } = useOrgContext(); + return ( + + + {build !== "oss" && } + + ); +} + +function LogRetentionSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + settingsLogRetentionDaysRequest: true, + settingsLogRetentionDaysAccess: true, + settingsLogRetentionDaysAction: true + }) + ), + defaultValues: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.settingsLogRetentionDaysAction ?? 15 + }, + mode: "onChange" + }); + + const router = useRouter(); + const t = useTranslations(); + const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("logRetention")} + + {t("logRetentionDescription")} + + + + +
+ + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {build !== "oss" && ( + <> + + + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + +
+
+ +
+ +
+
+ ); +} + +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), + defaultValues: { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }, + mode: "onChange" + }); + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; + + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + + const [, formAction, loadingSave] = useActionState(onSubmit, null); + const api = createApiClient(useEnvContext()); + + const formRef = useRef>(null); + + async function onSubmit() { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(); + } + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + requireTwoFactor: data.requireTwoFactor || false, + maxSessionLengthHours: data.maxSessionLengthHours, + passwordExpiryDays: data.passwordExpiryDays + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + <> + +

{t("securityPolicyChangeDescription")}

+ + } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + { + const isDisabled = !isPaidUser; + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + +
+
+ +
+ +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index c236d338..133a94f2 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -728,26 +728,28 @@ WantedBy=default.target` )} /> -
- -
+ {form.watch("method") === "newt" && ( +
+ +
+ )} {form.watch("method") === "newt" && showAdvancedSettings && ( { // Detect if we're on iOS or Android @@ -82,4 +82,4 @@ export default function DeviceAuthSuccessPage() {

); -} \ No newline at end of file +} diff --git a/src/components/ActionBanner.tsx b/src/components/ActionBanner.tsx new file mode 100644 index 00000000..6e98b978 --- /dev/null +++ b/src/components/ActionBanner.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { type ReactNode } from "react"; +import { Card, CardContent } from "@app/components/ui/card"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { cva, type VariantProps } from "class-variance-authority"; + +const actionBannerVariants = cva( + "mb-6 relative overflow-hidden", + { + variants: { + variant: { + warning: "border-yellow-500/30 bg-gradient-to-br from-yellow-500/10 via-background to-background", + info: "border-blue-500/30 bg-gradient-to-br from-blue-500/10 via-background to-background", + success: "border-green-500/30 bg-gradient-to-br from-green-500/10 via-background to-background", + destructive: "border-red-500/30 bg-gradient-to-br from-red-500/10 via-background to-background", + default: "border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +const titleVariants = "text-lg font-semibold flex items-center gap-2"; + +const iconVariants = cva( + "w-5 h-5", + { + variants: { + variant: { + warning: "text-yellow-600 dark:text-yellow-500", + info: "text-blue-600 dark:text-blue-500", + success: "text-green-600 dark:text-green-500", + destructive: "text-red-600 dark:text-red-500", + default: "text-primary" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +type ActionBannerProps = { + title: string; + titleIcon?: ReactNode; + description: string; + actions?: ReactNode; + className?: string; +} & VariantProps; + +export function ActionBanner({ + title, + titleIcon, + description, + actions, + variant = "default", + className +}: ActionBannerProps) { + return ( + + +
+
+

+ {titleIcon && ( + + {titleIcon} + + )} + {title} +

+

+ {description} +

+
+ {actions && ( +
+ {actions} +
+ )} +
+
+
+ ); +} + +export default ActionBanner; diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index 8e7fa5e7..a50b6039 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -19,7 +19,11 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - + + + {t("name")} + {client.name} + {t("identifier")} {client.niceId} diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 8108461d..ba9863b5 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -161,7 +161,7 @@ export default function CreateRoleForm({ )} /> {build !== "oss" && ( -
+
{ return ( diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 914b43b0..cadeb230 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -195,8 +195,8 @@ export default function DeviceLoginForm({ ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; function onCancel() { setMetadata(null); diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index 7990ab92..46db3967 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -169,7 +169,7 @@ export default function EditRoleForm({ )} /> {build !== "oss" && ( -
+
diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 71117be6..52617631 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -59,7 +59,6 @@ export default function MachineClientsTable({ const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -152,8 +151,6 @@ export default function MachineClientsTable({ .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -421,8 +418,7 @@ export default function MachineClientsTable({ if (clientRow.blocked) { unblockClient(clientRow.id); } else { - setSelectedClient(clientRow); - setIsBlockModalOpen(true); + blockClient(clientRow.id); } }} > @@ -482,28 +478,6 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - {selectedClient && ( - { - setIsBlockModalOpen(val); - if (!val) { - setSelectedClient(null); - } - }} - dialog={ -
-

{t("blockClientQuestion")}

-

{t("blockClientMessage")}

-
- } - buttonText={t("blockClientConfirm")} - onConfirm={async () => blockClient(selectedClient!.id)} - string={selectedClient.name} - title={t("blockClient")} - /> - )} - ) => { e.preventDefault(); diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx index f20856b4..9cb93757 100644 --- a/src/components/SignupForm.tsx +++ b/src/components/SignupForm.tsx @@ -201,8 +201,8 @@ export default function SignupForm({ ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp); const orgBannerHref = redirect diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index b2a5fd8b..102014e3 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -27,7 +27,7 @@ import ClientDownloadBanner from "./ClientDownloadBanner"; import { Badge } from "./ui/badge"; import { build } from "@server/build"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type ClientRow = { id: number; @@ -48,6 +48,16 @@ export type ClientRow = { approvalState: "approved" | "pending" | "denied" | null; archived?: boolean; blocked?: boolean; + fingerprint?: { + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + username: string | null; + hostname: string | null; + } | null; }; type ClientTableProps = { @@ -55,12 +65,57 @@ type ClientTableProps = { orgId: string; }; +function formatPlatform(platform: string | null | undefined): string { + if (!platform) return "-"; + const platformMap: Record = { + macos: "macOS", + windows: "Windows", + linux: "Linux", + ios: "iOS", + android: "Android", + unknown: "Unknown" + }; + return platformMap[platform.toLowerCase()] || platform; +} + export default function UserDevicesTable({ userClients }: ClientTableProps) { const router = useRouter(); const t = useTranslations(); + const formatFingerprintInfo = ( + fingerprint: ClientRow["fingerprint"] + ): string => { + if (!fingerprint) return ""; + const parts: string[] = []; + + if (fingerprint.platform) { + parts.push( + `${t("platform")}: ${formatPlatform(fingerprint.platform)}` + ); + } + if (fingerprint.deviceModel) { + parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`); + } + if (fingerprint.osVersion) { + parts.push(`${t("osVersion")}: ${fingerprint.osVersion}`); + } + if (fingerprint.arch) { + parts.push(`${t("architecture")}: ${fingerprint.arch}`); + } + if (fingerprint.hostname) { + parts.push(`${t("hostname")}: ${fingerprint.hostname}`); + } + if (fingerprint.username) { + parts.push(`${t("username")}: ${fingerprint.username}`); + } + if (fingerprint.serialNumber) { + parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`); + } + + return parts.join("\n"); + }; + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -152,8 +207,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -185,7 +238,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "name", enableHiding: false, - friendlyName: "Name", + friendlyName: t("name"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const r = row.original; + const fingerprintInfo = r.fingerprint + ? formatFingerprintInfo(r.fingerprint) + : null; return (
{r.name} + {fingerprintInfo && ( + +
+
+ {t("deviceInformation")} +
+
+ {fingerprintInfo} +
+
+
+ )} {r.archived && ( {t("archived")} @@ -253,7 +321,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "userEmail", - friendlyName: "User", + friendlyName: t("users"), header: ({ column }) => { return ( ); @@ -287,7 +355,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: "Connectivity", + friendlyName: t("online"), header: ({ column }) => { return ( ); @@ -309,14 +377,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return (
- Connected + {t("online")}
); } else { return (
- Disconnected + {t("offline")}
); } @@ -324,7 +392,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "mbIn", - friendlyName: "Data In", + friendlyName: t("dataIn"), header: ({ column }) => { return ( ); @@ -343,7 +411,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "mbOut", - friendlyName: "Data Out", + friendlyName: t("dataOut"), header: ({ column }) => { return ( ); @@ -402,7 +470,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "subnet", - friendlyName: "Address", + friendlyName: t("address"), header: ({ column }) => { return ( ); @@ -448,8 +516,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { > {clientRow.archived - ? "Unarchive" - : "Archive"} + ? t("actionUnarchiveClient") + : t("actionArchiveClient")} {clientRow.blocked - ? "Unblock" - : "Block"} + ? t("actionUnblockClient") + : t("actionBlockClient")} {!clientRow.userId && ( @@ -477,17 +544,17 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }} > - Delete + {t("delete")} )} @@ -499,6 +566,49 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return baseColumns; }, [hasRowsWithoutUserId, t]); + const statusFilterOptions = useMemo(() => { + const allOptions = [ + { + id: "active", + label: t("active"), + value: "active" + }, + { + id: "pending", + label: t("pendingApproval"), + value: "pending" + }, + { + id: "denied", + label: t("deniedApproval"), + value: "denied" + }, + { + id: "archived", + label: t("archived"), + value: "archived" + }, + { + id: "blocked", + label: t("blocked"), + value: "blocked" + } + ]; + + if (build === "oss") { + return allOptions.filter((option) => option.value !== "pending"); + } + + return allOptions; + }, [t]); + + const statusFilterDefaultValues = useMemo(() => { + if (build === "oss") { + return ["active"]; + } + return ["active", "pending"]; + }, []); + return ( <> {selectedClient && !selectedClient.userId && ( @@ -514,34 +624,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {

{t("clientMessageRemove")}

} - buttonText="Confirm Delete Client" + buttonText={t("actionDeleteClient")} onConfirm={async () => deleteClient(selectedClient!.id)} string={selectedClient.name} - title="Delete Client" + title={t("actionDeleteClient")} /> )} - {selectedClient && ( - { - setIsBlockModalOpen(val); - if (!val) { - setSelectedClient(null); - } - }} - dialog={ -
-

{t("blockClientQuestion")}

-

{t("blockClientMessage")}

-
- } - buttonText={t("blockClientConfirm")} - onConfirm={async () => blockClient(selectedClient!.id)} - string={selectedClient.name} - title={t("blockClient")} - /> - )} - diff --git a/src/components/private/IdpLoginButtons.tsx b/src/components/private/IdpLoginButtons.tsx index 7649bfde..50d84981 100644 --- a/src/components/private/IdpLoginButtons.tsx +++ b/src/components/private/IdpLoginButtons.tsx @@ -133,6 +133,7 @@ export default function IdpLoginButtons({ loginWithIdp(idp.idpId); }} disabled={loading} + loading={loading} > {effectiveType === "google" && ( svg]:text-destructive", + "border-destructive/50 border bg-destructive/8 dark:text-red-200 text-red-900 dark:border-destructive/50 [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", info: "border-blue-500/50 border bg-blue-500/10 text-blue-800 dark:text-blue-400 dark:border-blue-400 [&>svg]:text-blue-500", diff --git a/src/components/ui/info-popup.tsx b/src/components/ui/info-popup.tsx index cff1cce4..b7c0f55e 100644 --- a/src/components/ui/info-popup.tsx +++ b/src/components/ui/info-popup.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState, useRef, useEffect } from "react"; import { Info } from "lucide-react"; import { Popover, @@ -17,25 +17,61 @@ interface InfoPopupProps { } export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) { + const [open, setOpen] = useState(false); + const timeoutRef = useRef(null); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setOpen(true); + }; + + const handleMouseLeave = () => { + // Add a small delay to prevent flickering when moving between trigger and content + timeoutRef.current = setTimeout(() => { + setOpen(false); + }, 100); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + const defaultTrigger = ( ); + const triggerElement = trigger ?? defaultTrigger; + return (
{text && {text}} - - - {trigger ?? defaultTrigger} + + + {triggerElement} - + {children || (info && (

diff --git a/src/providers/ClientProvider.tsx b/src/providers/ClientProvider.tsx index 5e89acd8..3bcedca5 100644 --- a/src/providers/ClientProvider.tsx +++ b/src/providers/ClientProvider.tsx @@ -2,7 +2,7 @@ import ClientContext from "@app/contexts/clientContext"; import { GetClientResponse } from "@server/routers/client/getClient"; -import { useState } from "react"; +import { useState, useEffect } from "react"; interface ClientProviderProps { children: React.ReactNode; @@ -15,6 +15,11 @@ export function ClientProvider({ }: ClientProviderProps) { const [client, setClient] = useState(serverClient); + // Sync client state when server client changes (e.g., after router.refresh()) + useEffect(() => { + setClient(serverClient); + }, [serverClient]); + const updateClient = (updatedClient: Partial) => { if (!client) { throw new Error("No client to update");