Compare commits

...

14 Commits

Author SHA1 Message Date
miloschwartz
34e2fbefb9 add view user device page with fingerprint and actions 2026-01-17 20:59:20 -08:00
Owen
f7cede4713 Use /etc/sysctl.d/99-podman.conf
Fixes #2253
2026-01-17 12:22:43 -08:00
Owen
610b20c1ff Use the right driver
Fixes #2254
2026-01-17 12:21:47 -08:00
miloschwartz
fb19e10cdc Merge branch 'dev' into bubble-errors-up 2026-01-17 12:00:55 -08:00
miloschwartz
2f1756ccf2 add more error messages for org access policy 2026-01-17 12:00:27 -08:00
Owen
ce632a25cf Consolidate the messages into the same enum 2026-01-17 11:41:10 -08:00
Owen
888f5f8bb6 Dont terminate on archive 2026-01-16 17:06:16 -08:00
Owen
9114dd5992 Send terminate error messages 2026-01-16 14:57:54 -08:00
Owen
a126494c12 Add pending 2026-01-16 14:37:06 -08:00
Milo Schwartz
79ba804c88 Merge pull request #2252 from Fredkiss3/fix/request-analytics-loading-state
Fix: better loading state for analytics
2026-01-16 14:35:55 -08:00
Owen
e2cbe11a5f Send error codes down to olm 2026-01-16 14:19:36 -08:00
Fred KISSIE
f4496bb23a ♻️ show all country list 2026-01-16 17:36:48 +01:00
Fred KISSIE
c93766bb48 💄fix countries list grid items 2026-01-16 17:35:17 +01:00
Fred KISSIE
1065004fa3 🚸 show a better loading state for analytics 2026-01-16 02:07:08 +01:00
30 changed files with 1099 additions and 112 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

@@ -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

@@ -1308,6 +1308,7 @@
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate Status", "certificateStatus": "Certificate Status",
"loading": "Loading", "loading": "Loading",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart", "restart": "Restart",
"domains": "Domains", "domains": "Domains",
"domainsDescription": "Create and manage domains available in the organization", "domainsDescription": "Create and manage domains available in the organization",
@@ -2482,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

@@ -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

@@ -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

@@ -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

@@ -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

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,6 +41,7 @@ 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;
} }
@@ -64,11 +53,23 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!client) { if (!client) {
logger.warn("Client ID not found"); logger.warn("Client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
return; return;
} }
if (client.blocked) { if (client.blocked) {
logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); 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; return;
} }
@@ -80,12 +81,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!org) { if (!org) {
logger.warn("Org not found"); logger.warn("Org not found");
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return; return;
} }
if (orgId) { if (orgId) {
if (!olm.userId) { if (!olm.userId) {
logger.warn("Olm has no user ID"); logger.warn("Olm has no user ID");
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
return; return;
} }
@@ -93,10 +96,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await validateSessionToken(userToken); await validateSessionToken(userToken);
if (!userSession || !user) { if (!userSession || !user) {
logger.warn("Invalid user session for olm register"); 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 sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
return;
} }
if (user.userId !== olm.userId) { if (user.userId !== olm.userId) {
logger.warn("User ID mismatch for olm register"); logger.warn("User ID mismatch for olm register");
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
return; return;
} }
@@ -110,10 +115,46 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sessionId // this is the user token passed in the message sessionId // this is the user token passed in the message
}); });
if (!policyCheck.allowed) { 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) {
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) {
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) {
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( logger.warn(
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
} }
} }
@@ -151,7 +192,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));

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

@@ -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

@@ -0,0 +1,507 @@
"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}
className="gap-2"
>
<Check className="size-4" />
{t("approve")}
</Button>
<Button
onClick={handleDeny}
disabled={isRefreshing || !approvalId}
loading={isRefreshing}
variant="destructive"
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

@@ -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

@@ -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

@@ -48,6 +48,7 @@ import {
TooltipTrigger TooltipTrigger
} from "./ui/tooltip"; } from "./ui/tooltip";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
export type AnalyticsContentProps = { export type AnalyticsContentProps = {
orgId: string; orgId: string;
@@ -276,13 +277,32 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
</CardHeader> </CardHeader>
</Card> </Card>
<Card className="w-full h-full flex flex-col gap-8"> <Card className="w-full h-full flex flex-col gap-8 relative">
{isLoadingAnalytics && (
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
<div className="flex items-center gap-2 p-6">
<LoaderIcon className="size-4 animate-spin" />
{t("loadingAnalytics")}
</div>
</div>
)}
<CardHeader> <CardHeader>
<h3 className="font-semibold">{t("requestsByDay")}</h3> <h3 className="font-semibold">{t("requestsByDay")}</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="relative">
{isLoadingAnalytics && (
<div className="backdrop-blur-[2px] z-10 absolute inset-0"></div>
)}
<RequestChart <RequestChart
data={stats?.requestsPerDay ?? []} className={cn(
isLoadingAnalytics &&
"opacity-50 pointer-events-none"
)}
data={
stats?.requestsPerDay ??
generateSampleDailyRequests()
}
isLoading={isLoadingAnalytics} isLoading={isLoadingAnalytics}
/> />
</CardContent> </CardContent>
@@ -323,6 +343,28 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
); );
} }
function generateSampleDailyRequests(): QueryRequestAnalyticsResponse["requestsPerDay"] {
const today = new Date();
// generate sample data for the last 7 days
const requestsPerDay = Array.from({ length: 7 }, (_, i) => {
const date = new Date(today);
date.setDate(date.getDate() - (6 - i));
// generate a random number of requests between 1 and 100
const totalCount = Math.floor(Math.random() * 100) + 1;
// generate a random number of requests between 1 and totalCount
const blockedCount = Math.floor(Math.random() * (totalCount + 1));
return {
day: date.toISOString().split("T")[0],
allowedCount: totalCount - blockedCount,
blockedCount,
totalCount
};
});
return requestsPerDay;
}
type RequestChartProps = { type RequestChartProps = {
data: { data: {
day: string; day: string;
@@ -331,6 +373,7 @@ type RequestChartProps = {
totalCount: number; totalCount: number;
}[]; }[];
isLoading: boolean; isLoading: boolean;
className?: string;
}; };
function RequestChart(props: RequestChartProps) { function RequestChart(props: RequestChartProps) {
@@ -359,7 +402,7 @@ function RequestChart(props: RequestChartProps) {
return ( return (
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="min-h-[200px] w-full h-80" className={cn("min-h-50 w-full h-80", props.className)}
> >
<LineChart accessibilityLayer data={props.data}> <LineChart accessibilityLayer data={props.data}>
<ChartLegend content={<ChartLegendContent />} /> <ChartLegend content={<ChartLegendContent />} />
@@ -467,7 +510,7 @@ function TopCountriesList(props: TopCountriesListProps) {
</div> </div>
)} )}
{/* `aspect-475/335` is the same aspect ratio as the world map component */} {/* `aspect-475/335` is the same aspect ratio as the world map component */}
<ol className="w-full overflow-auto grid gap-1 aspect-475/335"> <ol className="w-full overflow-auto gap-1 aspect-475/335 flex flex-col">
{props.countries.length === 0 && ( {props.countries.length === 0 && (
<div className="flex items-center justify-center size-full text-muted-foreground gap-1"> <div className="flex items-center justify-center size-full text-muted-foreground gap-1">
{props.isLoading ? ( {props.isLoading ? (
@@ -485,7 +528,7 @@ function TopCountriesList(props: TopCountriesListProps) {
return ( return (
<li <li
key={country.code} key={country.code}
className="grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm" className="w-full grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm"
> >
<div <div
className={cn( className={cn(

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

@@ -60,7 +60,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
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 UserDevicesTable({ userClients }: ClientTableProps) {
.then(() => { .then(() => {
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
setIsBlockModalOpen(false);
setSelectedClient(null);
}); });
}); });
}; };
@@ -457,8 +454,7 @@ 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);
} }
}} }}
> >
@@ -484,7 +480,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</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 View
@@ -520,28 +516,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
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")}
/>
)}
<ClientDownloadBanner /> <ClientDownloadBanner />
<DataTable <DataTable

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");