Merge branch 'dev' into feat/show-newt-install-command

This commit is contained in:
Fred KISSIE
2026-01-20 03:36:38 +01:00
63 changed files with 2461 additions and 1069 deletions

View File

@@ -77,6 +77,8 @@ COPY ./cli/wrapper.sh /usr/local/bin/pangctl
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
COPY server/db/names.json ./dist/names.json 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 COPY public ./public
# OCI Image Labels # OCI Image Labels

View File

@@ -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);
}
}
};

View File

@@ -6,6 +6,7 @@ import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
import { clearExitNodes } from "./commands/clearExitNodes"; import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret"; import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -13,5 +14,6 @@ yargs(hideBin(process.argv))
.command(resetUserSecurityKeys) .command(resetUserSecurityKeys)
.command(clearExitNodes) .command(clearExitNodes)
.command(rotateServerSecret) .command(rotateServerSecret)
.command(clearLicenseKeys)
.demandCommand() .demandCommand()
.help().argv; .help().argv;

View File

@@ -210,6 +210,47 @@ func isDockerRunning() bool {
return true 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 // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
func executeDockerComposeCommandWithArgs(args ...string) error { func executeDockerComposeCommandWithArgs(args ...string) error {
var cmd *exec.Cmd var cmd *exec.Cmd

View File

@@ -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") { 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("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 return nil
@@ -117,7 +117,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) {
} }
// Execute the command to get the API key // 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 var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out

View File

@@ -229,7 +229,16 @@ func main() {
} }
} }
// 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) config.InstallationContainerType = podmanOrDocker(reader)
} else {
config.InstallationContainerType = detectedType
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
}
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
err := installCrowdsec(config) err := installCrowdsec(config)
@@ -286,10 +295,10 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
os.Exit(1) 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("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.") 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 approved {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Println("You need to run the installer as root for such a configuration.") 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. // container low-range ports as unprivileged ports.
// Linux only. // 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) fmt.Printf("Error configuring unprivileged ports: %v\n", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -1581,7 +1581,7 @@
"timeoutSeconds": "Timeout (sec)", "timeoutSeconds": "Timeout (sec)",
"timeIsInSeconds": "Time is in seconds", "timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals", "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", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "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?", "signupOrgTip": "Are you trying to sign in through your organization's identity provider?",
"signupOrgLink": "Sign in or sign up with your organization instead", "signupOrgLink": "Sign in or sign up with your organization instead",
"verifyEmailLogInWithDifferentAccount": "Use a Different Account", "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."
} }

View File

@@ -19,6 +19,7 @@ import logger from "@server/logger";
import { sendTerminateClient } from "@server/routers/client/terminate"; import { sendTerminateClient } from "@server/routers/client/terminate";
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
import { OlmErrorCodes } from "@server/routers/olm/error";
export async function calculateUserClientsForOrgs( export async function calculateUserClientsForOrgs(
userId: string, userId: string,
@@ -305,6 +306,7 @@ async function cleanupOrphanedClients(
if (deletedClient.olmId) { if (deletedClient.olmId) {
await sendTerminateClient( await sendTerminateClient(
deletedClient.clientId, deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
deletedClient.olmId deletedClient.olmId
); );
} }

View File

@@ -24,6 +24,8 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { disconnectClient, sendToClient } from "#private/routers/ws"; 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({ const reGenerateSecretParamsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -117,12 +119,12 @@ export async function reGenerateClientSecret(
// Only disconnect if explicitly requested // Only disconnect if explicitly requested
if (disconnect) { if (disconnect) {
const payload = {
type: `olm/terminate`,
data: {}
};
// Don't await this to prevent blocking the response // 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( logger.error(
"Failed to send termination message to olm:", "Failed to send termination message to olm:",
error error

View File

@@ -493,7 +493,7 @@ const sendToClientLocal = async (
} }
// Handle config version // Handle config version
let configVersion = await getClientConfigVersion(clientId); const configVersion = await getClientConfigVersion(clientId);
// Add config version to message // Add config version to message
const messageWithVersion = { const messageWithVersion = {

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({ const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -79,11 +80,6 @@ export async function archiveClient(
// Rebuild associations to clean up related data // Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx); 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, { return response(res, {

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const blockClientSchema = z.strictObject({ const blockClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) 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 // Send terminate signal if there's an associated OLM and it's connected
if (client.olmId && client.online) { if (client.olmId && client.online) {
await sendTerminateClient(client.clientId, client.olmId); await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId);
} }
}); });

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const deleteClientSchema = z.strictObject({ const deleteClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -91,7 +92,7 @@ export async function deleteClient(
await rebuildClientAssociationsFromClient(deletedClient, trx); await rebuildClientAssociationsFromClient(deletedClient, trx);
if (olm) { 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
} }
}); });

View File

@@ -49,6 +49,20 @@ export type GetClientResponse = NonNullable<
Awaited<ReturnType<typeof query>> Awaited<ReturnType<typeof query>>
>["clients"] & { >["clients"] & {
olmId: string | null; 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({ registry.registerPath({
@@ -115,10 +129,29 @@ export async function getClient(
clientName = getUserDeviceName(model, client.clients.name); 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 = { const data: GetClientResponse = {
...client.clients, ...client.clients,
name: clientName, 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<GetClientResponse>(res, { return response<GetClientResponse>(res, {

View File

@@ -143,7 +143,14 @@ function queryClients(
olmArchived: olms.archived, olmArchived: olms.archived,
archived: clients.archived, archived: clients.archived,
blocked: clients.blocked, 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) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))

View File

@@ -1,9 +1,11 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { db, olms } from "@server/db"; import { db, olms } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { OlmErrorCodes } from "../olm/error";
export async function sendTerminateClient( export async function sendTerminateClient(
clientId: number, clientId: number,
error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes],
olmId?: string | null olmId?: string | null
) { ) {
if (!olmId) { if (!olmId) {
@@ -20,6 +22,9 @@ export async function sendTerminateClient(
await sendToClient(olmId, { await sendToClient(olmId, {
type: `olm/terminate`, type: `olm/terminate`,
data: {} data: {
code: error.code,
message: error.message
}
}); });
} }

View File

@@ -18,6 +18,7 @@ import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs"; import * as logs from "./auditLogs";
import * as newt from "./newt"; import * as newt from "./newt";
import * as olm from "./olm"; import * as olm from "./olm";
import * as serverInfo from "./serverInfo";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
verifyAccessTokenAccess, verifyAccessTokenAccess,
@@ -712,6 +713,8 @@ authenticated.get(
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
authenticated.get(`/server-info`, serverInfo.getServerInfo);
authenticated.post( authenticated.post(
`/supporter-key/validate`, `/supporter-key/validate`,
supporterKey.validateSupporterKey supporterKey.validateSupporterKey

View File

@@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate"; import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -52,7 +53,7 @@ export async function archiveUserOlm(
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx); await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, olmId); await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
} }
// Archive the OLM (set archived to true) // Archive the OLM (set archived to true)

View File

@@ -11,6 +11,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate"; import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -76,6 +77,7 @@ export async function deleteUserOlm(
if (olm) { if (olm) {
await sendTerminateClient( await sendTerminateClient(
deletedClient.clientId, deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
olm.olmId olm.olmId
); // the olmId needs to be provided because it cant look it up after deletion ); // the olmId needs to be provided because it cant look it up after deletion
} }

104
server/routers/olm/error.ts Normal file
View File

@@ -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
}
});
}

View File

@@ -8,8 +8,8 @@ import response from "@server/lib/response";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
// import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z const paramsSchema = z
.object({ .object({

View File

@@ -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 });
}
};

View File

@@ -10,6 +10,7 @@ import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { sendOlmSyncMessage } from "./sync"; import { sendOlmSyncMessage } from "./sync";
import { OlmErrorCodes } from "./error";
// Track if the offline checker interval is running // Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null; let offlineCheckerInterval: NodeJS.Timeout | null = null;
@@ -64,6 +65,7 @@ export const startOlmOfflineChecker = (): void => {
try { try {
await sendTerminateClient( await sendTerminateClient(
offlineClient.clientId, offlineClient.clientId,
OlmErrorCodes.TERMINATED_INACTIVITY,
offlineClient.olmId offlineClient.olmId
); // terminate first ); // terminate first
// wait a moment to ensure the message is sent // 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})`); logger.debug(`handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})`);
if (configVersion == null || configVersion === undefined) { 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) { if (message.configVersion != null && configVersion != null && configVersion != message.configVersion) {

View File

@@ -1,32 +1,20 @@
import { import { clientPostureSnapshots, db, fingerprints, orgs } from "@server/db";
Client,
clientPostureSnapshots,
clientSiteResourcesAssociationsCache,
db,
fingerprints,
orgs,
siteResources
} from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
exitNodes,
Olm, Olm,
olms, olms,
sites sites
} from "@server/db"; } from "@server/db";
import { and, count, eq, inArray, isNull } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { generateAliasConfig } from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); logger.info("Handling register olm message!");
@@ -53,78 +41,88 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("Olm client ID not found"); logger.warn("Olm client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
return; return;
} }
const [client] = await db if (fingerprint) {
const [existingFingerprint] = await db
.select() .select()
.from(clients) .from(fingerprints)
.where(eq(clients.clientId, olm.clientId)) .where(eq(fingerprints.olmId, olm.olmId))
.limit(1); .limit(1);
if (!client) { if (!existingFingerprint) {
logger.warn("Client ID not found"); await db.insert(fingerprints).values({
return; olmId: olm.olmId,
} firstSeen: now,
lastSeen: now,
if (client.blocked) { username: fingerprint.username,
logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); hostname: fingerprint.hostname,
return; platform: fingerprint.platform,
} osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
const [org] = await db arch: fingerprint.arch,
.select() deviceModel: fingerprint.deviceModel,
.from(orgs) serialNumber: fingerprint.serialNumber,
.where(eq(orgs.orgId, client.orgId)) platformFingerprint: fingerprint.platformFingerprint
.limit(1);
if (!org) {
logger.warn("Org not found");
return;
}
if (orgId) {
if (!olm.userId) {
logger.warn("Olm has no user ID");
return;
}
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;
}
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
}); });
} 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;
if (!policyCheck.allowed) { if (hasChanges) {
logger.warn( await db
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` .update(fingerprints)
); .set({
return; 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));
}
} }
} }
logger.debug( if (postures) {
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` await db.insert(clientPostureSnapshots).values({
); clientId: olm.clientId,
if (!publicKey) { biometricsEnabled: postures?.biometricsEnabled,
logger.warn("Public key not provided"); diskEncrypted: postures?.diskEncrypted,
return; 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
});
} }
if ( if (
@@ -142,6 +140,133 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.where(eq(olms.olmId, olm.olmId)); .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) { if (client.pubKey !== publicKey || client.archived) {
logger.info( logger.info(
"Public key mismatch. Updating public key and clearing session info..." "Public key mismatch. Updating public key and clearing session info..."
@@ -151,7 +276,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.update(clients) .update(clients)
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
archived: false, archived: false
}) })
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
@@ -198,72 +323,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
relay 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 // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
// if (siteConfigurations.length === 0) { // if (siteConfigurations.length === 0) {
// logger.warn("No valid site configurations found"); // logger.warn("No valid site configurations found");

View File

@@ -10,3 +10,4 @@ export * from "./getUserOlm";
export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmServerPeerAddMessage";
export * from "./handleOlmUnRelayMessage"; export * from "./handleOlmUnRelayMessage";
export * from "./recoverOlmWithFingerprint"; export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage";

View File

@@ -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, { await sendToClient(olm.olmId, {
type: "olm/sync", type: "olm/sync",

View File

@@ -21,6 +21,8 @@ import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers"; import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { OlmErrorCodes } from "../olm/error";
import { sendTerminateClient } from "../client/terminate";
const deleteOrgSchema = z.strictObject({ const deleteOrgSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -206,10 +208,11 @@ export async function deleteOrg(
} }
for (const olmId of olmsToTerminate) { for (const olmId of olmsToTerminate) {
sendToClient(olmId, { sendTerminateClient(
type: "olm/terminate", 0, // clientId not needed since we're passing olmId
data: {} OlmErrorCodes.TERMINATED_REKEYED,
}).catch((error) => { olmId
).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to olm:", "Failed to send termination message to olm:",
error error

View File

@@ -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<any> {
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<GetServerInfoResponse>(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")
);
}
}

View File

@@ -0,0 +1 @@
export * from "./getServerInfo";

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -11,6 +11,7 @@ import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
const deleteSiteSchema = z.strictObject({ const deleteSiteSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive()) siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -63,10 +64,25 @@ export async function deleteSite(
let deletedNewtId: string | null = null; let deletedNewtId: string | null = null;
await db.transaction(async (trx) => { 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); await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") { } 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();
// 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 // get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx const [deletedNewt] = await trx
.delete(newts) .delete(newts)
@@ -81,7 +97,6 @@ export async function deleteSite(
.where(eq(newtSessions.newtId, deletedNewt.newtId)); .where(eq(newtSessions.newtId, deletedNewt.newtId));
} }
} }
}
await trx.delete(sites).where(eq(sites.siteId, siteId)); await trx.delete(sites).where(eq(sites.siteId, siteId));
}); });

View File

@@ -23,15 +23,10 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
email: z email: z
.string()
.email() .email()
.toLowerCase() .toLowerCase()
.optional() .optional(),
.refine((data) => {
if (data) {
return z.email().safeParse(data).success;
}
return true;
}),
username: z.string().nonempty().toLowerCase(), username: z.string().nonempty().toLowerCase(),
name: z.string().optional(), name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(), type: z.enum(["internal", "oidc"]).optional(),

View File

@@ -14,7 +14,8 @@ import {
handleOlmPingMessage, handleOlmPingMessage,
startOlmOfflineChecker, startOlmOfflineChecker,
handleOlmServerPeerAddMessage, handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage handleOlmUnRelayMessage,
handleOlmDisconnecingMessage
} from "../olm"; } from "../olm";
import { handleHealthcheckStatusMessage } from "../target"; import { handleHealthcheckStatusMessage } from "../target";
import { MessageHandler } from "./types"; import { MessageHandler } from "./types";
@@ -25,6 +26,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/relay": handleOlmRelayMessage, "olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"olm/disconnecting": handleOlmDisconnecingMessage,
"newt/ping": handleNewtPingMessage, "newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage, "newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage, "newt/wg/get-config": handleGetConfigMessage,

View File

@@ -305,7 +305,7 @@ export default async function migration() {
const subnets = site.remoteSubnets.split(","); const subnets = site.remoteSubnets.split(",");
for (const subnet of subnets) { for (const subnet of subnets) {
// Generate a unique niceId for each new site resource // Generate a unique niceId for each new site resource
let niceId = generateName(); const niceId = generateName();
insertCidrResource.run( insertCidrResource.run(
site.siteId, site.siteId,
subnet.trim(), subnet.trim(),

View File

@@ -1,4 +1,5 @@
import { ApprovalFeed } from "@app/components/ApprovalFeed"; import { ApprovalFeed } from "@app/components/ApprovalFeed";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
@@ -42,6 +43,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
title={t("accessApprovalsManage")} title={t("accessApprovalsManage")}
description={t("accessApprovalsDescription")} description={t("accessApprovalsDescription")}
/> />
<PaidFeaturesAlert />
<OrgProvider org={org}> <OrgProvider org={org}>
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<ApprovalFeed orgId={params.orgId} /> <ApprovalFeed orgId={params.orgId} />

View File

@@ -361,7 +361,7 @@ export default function Page() {
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure username: values.email, // Use email as username for Google/Azure
email: values.email, email: values.email || undefined,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
idpId: selectedUserOption.idpId, idpId: selectedUserOption.idpId,
@@ -403,7 +403,7 @@ export default function Page() {
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.username, username: values.username,
email: values.email, email: values.email || undefined,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
idpId: selectedUserOption.idpId, idpId: selectedUserOption.idpId,

View File

@@ -29,9 +29,11 @@ import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState, useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import ActionBanner from "@app/components/ActionBanner";
import { Shield, ShieldOff } from "lucide-react";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
@@ -45,7 +47,9 @@ export default function GeneralPage() {
const { client, updateClient } = useClientContext(); const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition();
const form = useForm({ const form = useForm({
resolver: zodResolver(GeneralFormSchema), 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 ( return (
<SettingsContainer> <SettingsContainer>
{/* Blocked Device Banner */}
{client?.blocked && (
<ActionBanner
variant="destructive"
title={t("blocked")}
titleIcon={<Shield className="w-5 h-5" />}
description={t("deviceBlockedDescription")}
actions={
<Button
onClick={handleUnblock}
disabled={isRefreshing}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<ShieldOff className="size-4" />
{t("unblock")}
</Button>
}
/>
)}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>

View File

@@ -73,9 +73,10 @@ type CommandItem = string | { title: string; command: string };
type Commands = { type Commands = {
unix: Record<string, CommandItem[]>; unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>; windows: Record<string, CommandItem[]>;
docker: Record<string, CommandItem[]>;
}; };
const platforms = ["unix", "windows"] as const; const platforms = ["unix", "docker", "windows"] as const;
type Platform = (typeof platforms)[number]; type Platform = (typeof platforms)[number];
@@ -156,6 +157,27 @@ export default function Page() {
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` 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); setCommands(commands);
@@ -167,6 +189,8 @@ export default function Page() {
return ["All"]; return ["All"];
case "windows": case "windows":
return ["x64"]; return ["x64"];
case "docker":
return ["Docker Compose", "Docker Run"];
default: default:
return ["x64"]; return ["x64"];
} }

View File

@@ -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<string, string> = {
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 <FaApple className="h-4 w-4" />;
case "windows":
return <FaWindows className="h-4 w-4" />;
case "linux":
return <FaLinux className="h-4 w-4" />;
case "android":
return <SiAndroid className="h-4 w-4" />;
default:
return null;
}
}
type FieldConfig = {
show: boolean;
labelKey: string;
};
function getPlatformFieldConfig(
platform: string | null | undefined
): Record<string, FieldConfig> {
const normalizedPlatform = platform?.toLowerCase() || "unknown";
const configs: Record<string, Record<string, FieldConfig>> = {
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<number | null>(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 (
<SettingsContainer>
{/* Pending Approval Banner */}
{showApprovalFeatures && client.approvalState === "pending" && (
<ActionBanner
variant="warning"
title={t("pendingApproval")}
titleIcon={<Clock className="w-5 h-5" />}
description={t("devicePendingApprovalBannerDescription")}
actions={
<>
<Button
onClick={handleApprove}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Check className="size-4" />
{t("approve")}
</Button>
<Button
onClick={handleDeny}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<Ban className="size-4" />
{t("deny")}
</Button>
</>
}
/>
)}
{/* Blocked Device Banner */}
{client.blocked && client.approvalState !== "pending" && (
<ActionBanner
variant="destructive"
title={t("blocked")}
titleIcon={<Shield className="w-5 h-5" />}
description={t("deviceBlockedDescription")}
actions={
<Button
onClick={handleUnblock}
disabled={isRefreshing}
loading={isRefreshing}
variant="outline"
className="gap-2"
>
<ShieldOff className="size-4" />
{t("unblock")}
</Button>
}
/>
)}
{/* Device Information Section */}
{(client.fingerprint || (client.agent && client.olmVersion)) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceInformation")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceInformationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{client.agent && client.olmVersion && (
<div className="mb-6">
<InfoSection>
<InfoSectionTitle>
{t("agent")}
</InfoSectionTitle>
<InfoSectionContent>
<Badge variant="secondary">
{client.agent +
" v" +
client.olmVersion}
</Badge>
</InfoSectionContent>
</InfoSection>
</div>
)}
{client.fingerprint &&
(() => {
const platform = client.fingerprint.platform;
const fieldConfig =
getPlatformFieldConfig(platform);
return (
<InfoSections cols={3}>
{platform && (
<InfoSection>
<InfoSectionTitle>
{t("platform")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center gap-2">
{getPlatformIcon(
platform
)}
<span>
{formatPlatform(
platform
)}
</span>
</div>
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.osVersion &&
fieldConfig.osVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t(
fieldConfig
.osVersion
.labelKey
)}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.osVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.kernelVersion &&
fieldConfig.kernelVersion.show && (
<InfoSection>
<InfoSectionTitle>
{t("kernelVersion")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.kernelVersion
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.arch &&
fieldConfig.arch.show && (
<InfoSection>
<InfoSectionTitle>
{t("architecture")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.arch
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.deviceModel &&
fieldConfig.deviceModel.show && (
<InfoSection>
<InfoSectionTitle>
{t("deviceModel")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.deviceModel
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.serialNumber &&
fieldConfig.serialNumber.show && (
<InfoSection>
<InfoSectionTitle>
{t("serialNumber")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.serialNumber
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.username &&
fieldConfig.username.show && (
<InfoSection>
<InfoSectionTitle>
{t("username")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.username
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.hostname &&
fieldConfig.hostname.show && (
<InfoSection>
<InfoSectionTitle>
{t("hostname")}
</InfoSectionTitle>
<InfoSectionContent>
{
client.fingerprint
.hostname
}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.firstSeen && (
<InfoSection>
<InfoSectionTitle>
{t("firstSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint
.firstSeen
)}
</InfoSectionContent>
</InfoSection>
)}
{client.fingerprint.lastSeen && (
<InfoSection>
<InfoSectionTitle>
{t("lastSeen")}
</InfoSectionTitle>
<InfoSectionContent>
{formatTimestamp(
client.fingerprint
.lastSeen
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
);
})()}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -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<AxiosResponse<GetClientResponse>>(
`/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 (
<>
<SettingsSectionTitle
title={`${client?.name} Settings`}
description={t("deviceSettingsDescription")}
/>
<ClientProvider client={client}>
<div className="space-y-6">
<ClientInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</ClientProvider>
</>
);
}

View File

@@ -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`
);
}

View File

@@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) {
const mapClientToRow = ( const mapClientToRow = (
client: ListClientsResponse["clients"][0] client: ListClientsResponse["clients"][0]
): ClientRow => { ): 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 { return {
name: client.name, name: client.name,
id: client.clientId, id: client.clientId,
@@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent, agent: client.agent,
archived: client.archived || false, archived: client.archived || false,
blocked: client.blocked || false, blocked: client.blocked || false,
approvalState: client.approvalState approvalState: client.approvalState,
fingerprint
}; };
}; };

View File

@@ -51,6 +51,10 @@ export default async function GeneralSettingsPage({
title: t("general"), title: t("general"),
href: `/{orgId}/settings/general`, href: `/{orgId}/settings/general`,
exact: true exact: true
},
{
title: t("security"),
href: `/{orgId}/settings/general/security`
} }
]; ];
if (build !== "oss") { if (build !== "oss") {

View File

@@ -1,19 +1,12 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import {
useState, useState,
useRef,
useTransition, useTransition,
useActionState, useActionState
type ComponentRef
} from "react"; } from "react";
import { import {
Form, Form,
@@ -25,13 +18,6 @@ import {
FormMessage FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -55,79 +41,19 @@ import {
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; 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"; 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 // Schema for general organization settings
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string(), name: z.string(),
subnet: z.string().optional(), 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()
}); });
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
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() { export default function GeneralPage() {
const { org } = useOrgContext(); const { org } = useOrgContext();
return ( return (
<SettingsContainer> <SettingsContainer>
<GeneralSectionForm org={org.org} /> <GeneralSectionForm org={org.org} />
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
{build !== "saas" && <DeleteForm org={org.org} />} {build !== "saas" && <DeleteForm org={org.org} />}
</SettingsContainer> </SettingsContainer>
); );
@@ -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 (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<FormField
control={form.control}
name="settingsLogRetentionDaysRequest"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logRetentionRequestLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) =>
field.onChange(
parseInt(value, 10)
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
hasSaasSubscription &&
option.value >
30
) {
return false;
}
return true;
}
).map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{build !== "oss" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAccessLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionActionLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
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<ComponentRef<"form">>(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 (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<div className="flex items-center gap-2">
<FormControl>
<SwitchInput
id="require-two-factor"
defaultChecked={
field.value ||
false
}
label={t(
"requireTwoFactorForAllUsers"
)}
disabled={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -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 (
<SettingsContainer>
<LogRetentionSectionForm org={org.org} />
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
</SettingsContainer>
);
}
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 (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("logRetention")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("logRetentionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="grid gap-4"
id="org-log-retention-settings-form"
>
<FormField
control={form.control}
name="settingsLogRetentionDaysRequest"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("logRetentionRequestLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) =>
field.onChange(
parseInt(value, 10)
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
hasSaasSubscription &&
option.value >
30
) {
return false;
}
return true;
}
).map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{build !== "oss" && (
<>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAccessLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t(
"logRetentionActionLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="org-log-retention-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
);
}
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<ComponentRef<"form">>(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 (
<>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={performSave}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
ref={formRef}
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<div className="flex items-center gap-2">
<FormControl>
<SwitchInput
id="require-two-factor"
defaultChecked={
field.value ||
false
}
label={t(
"requireTwoFactorForAllUsers"
)}
disabled={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-4">
<Button
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveSettings")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -728,6 +728,7 @@ WantedBy=default.target`
</FormItem> </FormItem>
)} )}
/> />
{form.watch("method") === "newt" && (
<div className="flex items-center justify-end md:col-start-2"> <div className="flex items-center justify-end md:col-start-2">
<Button <Button
type="button" type="button"
@@ -748,6 +749,7 @@ WantedBy=default.target`
{t("advancedSettings")} {t("advancedSettings")}
</Button> </Button>
</div> </div>
)}
{form.watch("method") === "newt" && {form.watch("method") === "newt" &&
showAdvancedSettings && ( showAdvancedSettings && (
<FormField <FormField

View File

@@ -18,8 +18,8 @@ export default function DeviceAuthSuccessPage() {
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
useEffect(() => { useEffect(() => {
// Detect if we're on iOS or Android // Detect if we're on iOS or Android

View File

@@ -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<typeof actionBannerVariants>;
export function ActionBanner({
title,
titleIcon,
description,
actions,
variant = "default",
className
}: ActionBannerProps) {
return (
<Card className={cn(actionBannerVariants({ variant }), className)}>
<CardContent className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1 space-y-2 min-w-0">
<h3 className={titleVariants}>
{titleIcon && (
<span className={cn(iconVariants({ variant }))}>
{titleIcon}
</span>
)}
{title}
</h3>
<p className="text-sm text-muted-foreground max-w-4xl">
{description}
</p>
</div>
{actions && (
<div className="flex flex-wrap gap-3 lg:shrink-0 lg:justify-end">
{actions}
</div>
)}
</div>
</CardContent>
</Card>
);
}
export default ActionBanner;

View File

@@ -19,7 +19,11 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={3}> <InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle> <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{client.niceId}</InfoSectionContent> <InfoSectionContent>{client.niceId}</InfoSectionContent>

View File

@@ -161,7 +161,7 @@ export default function CreateRoleForm({
)} )}
/> />
{build !== "oss" && ( {build !== "oss" && (
<div className="pt-3"> <div>
<PaidFeaturesAlert /> <PaidFeaturesAlert />
<FormField <FormField

View File

@@ -166,7 +166,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaFooter <CredenzaFooter
className={cn( className={cn(
"mt-8 md:mt-0 -mx-6 px-6 py-4 border-t border-border", "mt-8 md:mt-0 -mx-6 -mb-4 px-6 py-4 border-t border-border",
className className
)} )}
{...props} {...props}

View File

@@ -57,8 +57,8 @@ export default function DashboardLoginForm({
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">

View File

@@ -195,8 +195,8 @@ export default function DeviceLoginForm({
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
function onCancel() { function onCancel() {
setMetadata(null); setMetadata(null);

View File

@@ -169,7 +169,7 @@ export default function EditRoleForm({
)} )}
/> />
{build !== "oss" && ( {build !== "oss" && (
<div className="pt-3"> <div>
<PaidFeaturesAlert /> <PaidFeaturesAlert />
<FormField <FormField

View File

@@ -11,7 +11,7 @@ export function InfoSections({
}) { }) {
return ( return (
<div <div
className={`grid md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start grid-cols-1`} className={`grid grid-cols-2 md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start`}
style={{ style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the // @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value // value of a CSS variable at runtime and tailwind will just reuse that value

View File

@@ -17,8 +17,8 @@ export default function LoginCardHeader({ subtitle }: LoginCardHeaderProps) {
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
return ( return (
<CardHeader className="border-b"> <CardHeader className="border-b">

View File

@@ -59,7 +59,6 @@ export default function MachineClientsTable({
const t = useTranslations(); const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>( const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null null
); );
@@ -152,8 +151,6 @@ export default function MachineClientsTable({
.then(() => { .then(() => {
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
setIsBlockModalOpen(false);
setSelectedClient(null);
}); });
}); });
}; };
@@ -421,8 +418,7 @@ export default function MachineClientsTable({
if (clientRow.blocked) { if (clientRow.blocked) {
unblockClient(clientRow.id); unblockClient(clientRow.id);
} else { } else {
setSelectedClient(clientRow); blockClient(clientRow.id);
setIsBlockModalOpen(true);
} }
}} }}
> >
@@ -482,28 +478,6 @@ export default function MachineClientsTable({
title="Delete Client" title="Delete Client"
/> />
)} )}
{selectedClient && (
<ConfirmDeleteDialog
open={isBlockModalOpen}
setOpen={(val) => {
setIsBlockModalOpen(val);
if (!val) {
setSelectedClient(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("blockClientQuestion")}</p>
<p>{t("blockClientMessage")}</p>
</div>
}
buttonText={t("blockClientConfirm")}
onConfirm={async () => blockClient(selectedClient!.id)}
string={selectedClient.name}
title={t("blockClient")}
/>
)}
<DataTable <DataTable
columns={columns} columns={columns}
data={machineClients || []} data={machineClients || []}

View File

@@ -42,8 +42,8 @@ export function OrgSelectionForm() {
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();

View File

@@ -201,8 +201,8 @@ export default function SignupForm({
? env.branding.logo?.authPage?.width || 175 ? env.branding.logo?.authPage?.width || 175
: 175; : 175;
const logoHeight = isUnlocked() const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 44
: 58; : 44;
const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp); const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
const orgBannerHref = redirect const orgBannerHref = redirect

View File

@@ -27,7 +27,7 @@ import ClientDownloadBanner from "./ClientDownloadBanner";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { build } from "@server/build"; import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; 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 = { export type ClientRow = {
id: number; id: number;
@@ -48,6 +48,16 @@ export type ClientRow = {
approvalState: "approved" | "pending" | "denied" | null; approvalState: "approved" | "pending" | "denied" | null;
archived?: boolean; archived?: boolean;
blocked?: 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 = { type ClientTableProps = {
@@ -55,12 +65,57 @@ type ClientTableProps = {
orgId: string; orgId: string;
}; };
function formatPlatform(platform: string | null | undefined): string {
if (!platform) return "-";
const platformMap: Record<string, string> = {
macos: "macOS",
windows: "Windows",
linux: "Linux",
ios: "iOS",
android: "Android",
unknown: "Unknown"
};
return platformMap[platform.toLowerCase()] || platform;
}
export default function UserDevicesTable({ userClients }: ClientTableProps) { export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); 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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>( const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null null
); );
@@ -152,8 +207,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
.then(() => { .then(() => {
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
setIsBlockModalOpen(false);
setSelectedClient(null);
}); });
}); });
}; };
@@ -185,7 +238,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: "Name", friendlyName: t("name"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -196,16 +249,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Name {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const fingerprintInfo = r.fingerprint
? formatFingerprintInfo(r.fingerprint)
: null;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{r.name}</span> <span>{r.name}</span>
{fingerprintInfo && (
<InfoPopup>
<div className="space-y-1 text-sm">
<div className="font-semibold mb-2">
{t("deviceInformation")}
</div>
<div className="text-muted-foreground whitespace-pre-line">
{fingerprintInfo}
</div>
</div>
</InfoPopup>
)}
{r.archived && ( {r.archived && (
<Badge variant="secondary"> <Badge variant="secondary">
{t("archived")} {t("archived")}
@@ -253,7 +321,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "userEmail", accessorKey: "userEmail",
friendlyName: "User", friendlyName: t("users"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -264,7 +332,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
User {t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -287,7 +355,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: "Connectivity", friendlyName: t("online"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -298,7 +366,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Connectivity {t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -309,14 +377,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span> <span>{t("offline")}</span>
</span> </span>
); );
} }
@@ -324,7 +392,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: "Data In", friendlyName: t("dataIn"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -335,7 +403,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Data In {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -343,7 +411,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: "Data Out", friendlyName: t("dataOut"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -354,7 +422,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Data Out {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -402,7 +470,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: "Address", friendlyName: t("address"),
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -413,7 +481,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
) )
} }
> >
Address {t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@@ -448,8 +516,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
> >
<span> <span>
{clientRow.archived {clientRow.archived
? "Unarchive" ? t("actionUnarchiveClient")
: "Archive"} : t("actionArchiveClient")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@@ -457,15 +525,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
if (clientRow.blocked) { if (clientRow.blocked) {
unblockClient(clientRow.id); unblockClient(clientRow.id);
} else { } else {
setSelectedClient(clientRow); blockClient(clientRow.id);
setIsBlockModalOpen(true);
} }
}} }}
> >
<span> <span>
{clientRow.blocked {clientRow.blocked
? "Unblock" ? t("actionUnblockClient")
: "Block"} : t("actionBlockClient")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
{!clientRow.userId && ( {!clientRow.userId && (
@@ -477,17 +544,17 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}} }}
> >
<span className="text-red-500"> <span className="text-red-500">
Delete {t("delete")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`} href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`}
> >
<Button variant={"outline"}> <Button variant={"outline"}>
View {t("viewDetails")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@@ -499,71 +566,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, t]); }, [hasRowsWithoutUserId, t]);
return ( const statusFilterOptions = useMemo(() => {
<> const allOptions = [
{selectedClient && !selectedClient.userId && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div className="space-y-2">
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
{selectedClient && (
<ConfirmDeleteDialog
open={isBlockModalOpen}
setOpen={(val) => {
setIsBlockModalOpen(val);
if (!val) {
setSelectedClient(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("blockClientQuestion")}</p>
<p>{t("blockClientMessage")}</p>
</div>
}
buttonText={t("blockClientConfirm")}
onConfirm={async () => blockClient(selectedClient!.id)}
string={selectedClient.name}
title={t("blockClient")}
/>
)}
<ClientDownloadBanner />
<DataTable
columns={columns}
data={userClients || []}
persistPageSize="user-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="user-clients"
columnVisibility={defaultUserColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
filters={[
{
id: "status",
label: t("status") || "Status",
multiSelect: true,
displayMode: "calculated",
options: [
{ {
id: "active", id: "active",
label: t("active"), label: t("active"),
@@ -589,7 +593,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
label: t("blocked"), label: t("blocked"),
value: "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 && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div className="space-y-2">
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText={t("actionDeleteClient")}
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title={t("actionDeleteClient")}
/>
)}
<ClientDownloadBanner />
<DataTable
columns={columns}
data={userClients || []}
persistPageSize="user-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="user-clients"
columnVisibility={defaultUserColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
filters={[
{
id: "status",
label: t("status") || "Status",
multiSelect: true,
displayMode: "calculated",
options: statusFilterOptions,
filterFn: ( filterFn: (
row: ClientRow, row: ClientRow,
selectedValues: (string | number | boolean)[] selectedValues: (string | number | boolean)[]
@@ -598,7 +660,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
const rowArchived = row.archived; const rowArchived = row.archived;
const rowBlocked = row.blocked; const rowBlocked = row.blocked;
const approvalState = row.approvalState; const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked; const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied";
if (selectedValues.includes("active") && isActive) if (selectedValues.includes("active") && isActive)
return true; return true;
@@ -624,7 +686,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return true; return true;
return false; return false;
}, },
defaultValues: ["active", "pending"] // Default to showing active clients defaultValues: statusFilterDefaultValues
} }
]} ]}
/> />

View File

@@ -133,6 +133,7 @@ export default function IdpLoginButtons({
loginWithIdp(idp.idpId); loginWithIdp(idp.idpId);
}} }}
disabled={loading} disabled={loading}
loading={loading}
> >
{effectiveType === "google" && ( {effectiveType === "google" && (
<Image <Image

View File

@@ -11,7 +11,7 @@ const alertVariants = cva(
default: "bg-card border text-foreground", default: "bg-card border text-foreground",
neutral: "bg-card bg-muted border text-foreground", neutral: "bg-card bg-muted border text-foreground",
destructive: destructive:
"border-destructive/50 border bg-destructive/8 text-destructive dark:border-destructive/50 [&>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: success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", "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", 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",

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useState, useRef, useEffect } from "react";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
import { import {
Popover, Popover,
@@ -17,25 +17,61 @@ interface InfoPopupProps {
} }
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) { export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(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 defaultTrigger = (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 rounded-full p-0" className="h-6 w-6 rounded-full p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
> >
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<span className="sr-only">Show info</span> <span className="sr-only">Show info</span>
</Button> </Button>
); );
const triggerElement = trigger ?? defaultTrigger;
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{text && <span>{text}</span>} {text && <span>{text}</span>}
<Popover> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger
{trigger ?? defaultTrigger} asChild
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{triggerElement}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80"> <PopoverContent
className="w-80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children || {children ||
(info && ( (info && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

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