Compare commits

...
10 Commits
Author SHA1 Message Date
KodekaTeam bbfe4c6680 feat(users): Convert the user and group management UI/UX from a native view to a modal
Build And Publish Doktainer Image / docker (push) Canceled after 0s
2026-09-13 12:54:36 +07:00
KodekaTeam 14411e0cf8 feat(api): add resolveApiExampleUrl function and related tests for dynamic API URL resolution 2026-09-13 11:47:37 +07:00
KodekaTeam c51c17e590 chore(release): bump version to v0.2.4 2026-09-13 10:52:03 +07:00
KodekaTeam 0bd696d37f feat(auth): add session history retention and lifecycle updates
- Add daily session history cleanup with a 180-day retention period
- Run cleanup on startup and every 24 hours in batches of up to 1,000 sessions
- Add tests for session retention and derived session status
- Allow viewers to manage their own sessions while preserving global access for super admins
- Prevent the current browser session from being revoked through session management
- Derive active, expired, and revoked status without writing during list requests
- Count only active sessions toward the 20-session per-user limit
- Remove unused session event queries and payloads from the session listing endpoint
- Update the session management UI for the revised access and revocation policies
2026-09-13 10:24:49 +07:00
KodekaTeam 6ce3dd590a fix(dependencies): update adm-zip and nodemailer to latest versions 2026-09-12 20:39:36 +07:00
KodekaTeam b39d9edbf7 feat(database): add explicit output target for custom dump restoration 2026-09-12 09:51:59 +07:00
KodekaTeam 25b10f04da feat(auth): enhance custom-domain same-origin handling and proxy trust validation 2026-09-12 09:25:27 +07:00
KodekaTeam eb80814d6a fix(database): make restores atomic and validate users schema before session migration 2026-09-12 08:39:42 +07:00
KodekaTeam fb3878edb8 feat(s3): enhance S3 deletion logic to handle MD5 errors and improve test coverage 2026-09-11 21:26:59 +07:00
KodekaTeam 87fdbb493b feat(sessions): implement user session management with session tracking and revocation
- Introduced new database schema for user sessions and session events.
- Implemented session management page with filtering, sorting, and revocation capabilities.
- Added backend services for creating, revoking, and expiring user sessions.
- Implemented session cookie handling and security measures.
- Created tests for session management functionalities and security features.
2026-09-11 13:34:01 +07:00
46 changed files with 3721 additions and 1659 deletions

No files matched your search

+7 -5
View File
@@ -4,8 +4,8 @@
# /* -------------------------------- Runtime -------------------------------- */
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
NEXT_PUBLIC_VERSION=v0.2.3
NEXT_PUBLIC_BATCH=Batch-20260909
NEXT_PUBLIC_VERSION=v0.2.4
NEXT_PUBLIC_BATCH=Batch-20260913
NEXT_PUBLIC_API_PORT=4000
# Leave NEXT_PUBLIC_API_URL empty/unset in monolith Docker deployment so the
@@ -27,13 +27,15 @@ DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=p
# /* ------------------------------- App server ------------------------------ */
NODE_ENV=production
# Browser authentication in this production configuration requires HTTPS.
# Plain HTTP remains supported only by the local development configuration.
# Bind address for the backend process inside the container.
# This is NOT a public URL and must not be reused as FRONTEND_URL or NEXT_PUBLIC_PANEL_URL.
HOST=0.0.0.0
PORT=4000
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
JWT_EXPIRES_IN=7d
SESSION_ABSOLUTE_TIMEOUT_HOURS=168
# Required in production. Generate once, store securely, and keep it stable:
# openssl rand -hex 32
@@ -67,7 +69,7 @@ ENCRYPTION_KEY=dev-32-char-encryption-key-here!
# Optional comma-separated extra origins when the backend is exposed directly and CORS must allow them.
# Keep this explicit for security; this is not auto-derived from request origin.
# CORS_ORIGINS=https://panel.example.com,http://your-vps-ip:3128
# CORS_ORIGINS=https://panel.example.com
# Optional internal backend URL used by Next rewrites in monolith mode.
# This is internal-only and separate from the public panel URL above.
+10 -6
View File
@@ -5,8 +5,8 @@
# /* -------------------------------- Runtime -------------------------------- */
# Frontend runtime
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
NEXT_PUBLIC_VERSION=v0.2.3
NEXT_PUBLIC_BATCH=Batch-20260909
NEXT_PUBLIC_VERSION=v0.2.4
NEXT_PUBLIC_BATCH=Batch-20260913
# For Docker deployment, leave NEXT_PUBLIC_API_URL unset so the
# browser uses same-origin /api/v1 and Next proxies it to the backend.
@@ -15,7 +15,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:4000
# /* --------------- Connection settings for PostgreSQL database -------------- */
# Frontend server
# Frontend server. Optional for same-origin browser access; recommended as the
# canonical callback/fallback origin in production. Use https and no path.
FRONTEND_URL=http://localhost:3000
# Optional canonical/backend URL if ever needed
@@ -29,12 +30,15 @@ NODE_ENV=development
# Database (PostgreSQL)
DATABASE_URL="postgresql://doktainer:doktainerdb@localhost:5432/doktainer?schema=public"
# JWT settings
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
JWT_EXPIRES_IN=7d
# Absolute lifetime for database-backed login sessions. User settings control
# the shorter sliding inactivity timeout.
SESSION_ABSOLUTE_TIMEOUT_HOURS=168
# /* -------------------------- Reverse proxy / rate limiting ------------------------- */
# Set true only when Doktainer is behind a trusted reverse proxy that overwrites
# X-Forwarded-Host and X-Forwarded-Proto. This enables the effective-origin
# fallback for clients that do not send Fetch Metadata.
TRUST_PROXY=false
RATE_LIMIT_MAX=300
RATE_LIMIT_TIME_WINDOW=1 minute
+2 -2
View File
@@ -21,8 +21,8 @@ ARG NEXT_PUBLIC_API_URL=
ARG NEXT_PUBLIC_WS_URL=
ARG NEXT_PUBLIC_API_PORT=4000
ARG NEXT_PUBLIC_PANEL_NAME=DOKTAINER
ARG NEXT_PUBLIC_VERSION=v0.2.3
ARG NEXT_PUBLIC_BATCH=Batch-20260909
ARG NEXT_PUBLIC_VERSION=v0.2.4
ARG NEXT_PUBLIC_BATCH=Batch-20260913
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
+80
View File
@@ -45,6 +45,7 @@ NODE
if [ -d /app/prisma/migrations ] && [ "$(find /app/prisma/migrations -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
db_action="$(node <<'NODE'
const { PrismaClient } = require("@prisma/client");
const { readdirSync } = require("fs");
const prisma = new PrismaClient();
async function main() {
@@ -75,6 +76,7 @@ const prisma = new PrismaClient();
let failedMigrations = [];
let migrationCount = 0;
let appliedMigrationNames = new Set();
if (hasMigrationTable) {
failedMigrations = await prisma.$queryRaw`
@@ -90,8 +92,27 @@ const prisma = new PrismaClient();
`;
migrationCount = migrationCountResult[0].count;
const appliedMigrationRows = await prisma.$queryRaw`
SELECT migration_name
FROM "_prisma_migrations"
WHERE finished_at IS NOT NULL
AND rolled_back_at IS NULL
`;
appliedMigrationNames = new Set(
appliedMigrationRows.map((row) => row.migration_name)
);
}
const migrationNames = readdirSync("/app/prisma/migrations", {
withFileTypes: true,
})
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const pendingMigrationNames = migrationNames.filter(
(migrationName) => !appliedMigrationNames.has(migrationName)
);
/**
* STATE #1
* Fresh database
@@ -172,6 +193,65 @@ const prisma = new PrismaClient();
return;
}
if (pendingMigrationNames.length > 0) {
const idColumnIntegrity = await prisma.$queryRaw`
SELECT
table_class.relname AS "tableName",
attribute.attnotnull AS "isNotNull",
EXISTS (
SELECT 1
FROM pg_index i
WHERE i.indrelid = table_class.oid
AND i.indisunique
AND i.indisvalid
AND i.indisready
AND i.indnkeyatts = 1
AND i.indkey[0] = attribute.attnum
) AS "hasUniqueId"
FROM pg_class table_class
JOIN pg_namespace namespace
ON namespace.oid = table_class.relnamespace
JOIN pg_attribute attribute
ON attribute.attrelid = table_class.oid
WHERE namespace.nspname = 'public'
AND table_class.relkind IN ('r', 'p')
AND table_class.relname <> '_prisma_migrations'
AND attribute.attname = 'id'
AND NOT attribute.attisdropped
ORDER BY table_class.relname
`;
const invalidIdColumns = idColumnIntegrity.filter(
(row) => !row.hasUniqueId || !row.isNotNull
);
if (invalidIdColumns.length > 0) {
console.error(
`Database integrity check failed before applying pending migrations: ${pendingMigrationNames.join(", ")}.`
);
for (const invalidColumn of invalidIdColumns) {
const quotedTableName = `"${invalidColumn.tableName.replace(/"/g, '""')}"`;
const [counts] = await prisma.$queryRawUnsafe(`
SELECT
COUNT(*) FILTER (WHERE "id" IS NULL)::int AS "nullIds",
(COUNT(*) - COUNT(DISTINCT "id"))::int AS "duplicateIds"
FROM ${quotedTableName}
`);
console.error(
`${invalidColumn.tableName}.id must be NOT NULL and have a valid single-column PRIMARY KEY or UNIQUE index (duplicate rows: ${counts.duplicateIds}, null IDs: ${counts.nullIds}).`
);
}
console.error(
"Restore missing database constraints and resolve duplicate IDs before applying migrations."
);
console.log("blocked");
return;
}
}
console.error(
"Prisma migration history detected. Applying pending migrations."
);
+627 -631
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "doktainer",
"description": "Doktainer is a platform for managing containerized applications.",
"version": "v0.2.3",
"version": "v0.2.4",
"author": "Doktainer Team",
"license": "MIT",
"private": true,
@@ -27,8 +27,8 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1031.0",
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.2.0",
@@ -43,7 +43,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"adm-zip": "^0.5.17",
"adm-zip": "^0.6.1",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -52,10 +52,10 @@
"fastify": "^5.3.2",
"js-yaml": "^4.1.1",
"lucide-react": "^1.8.0",
"next": "^16.2.6",
"next": "16.3.4",
"node-ssh": "^13.2.0",
"node-xlsx": "^0.24.0",
"nodemailer": "^8.0.5",
"nodemailer": "^10.0.9",
"otplib": "^13.4.0",
"qrcode": "^1.5.4",
"react": "19.2.4",
@@ -78,7 +78,7 @@
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"eslint-config-next": "16.3.4",
"pino-pretty": "^13.1.3",
"prisma": "6.19.3",
"tailwindcss": "^4",
@@ -0,0 +1,44 @@
CREATE TYPE "SessionEventType" AS ENUM ('LOGIN', 'LOGOUT', 'REVOKED', 'EXPIRED');
CREATE TABLE "user_sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"createdIp" TEXT,
"lastIp" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"idleExpiresAt" TIMESTAMP(3) NOT NULL,
"absoluteExpiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"revokeReason" TEXT,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "user_session_events" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"type" "SessionEventType" NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_session_events_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "user_sessions_tokenHash_key" ON "user_sessions"("tokenHash");
CREATE INDEX "user_sessions_userId_revokedAt_idx" ON "user_sessions"("userId", "revokedAt");
CREATE INDEX "user_sessions_idleExpiresAt_idx" ON "user_sessions"("idleExpiresAt");
CREATE INDEX "user_sessions_absoluteExpiresAt_idx" ON "user_sessions"("absoluteExpiresAt");
CREATE INDEX "user_session_events_sessionId_createdAt_idx" ON "user_session_events"("sessionId", "createdAt");
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_session_events" ADD CONSTRAINT "user_session_events_sessionId_fkey"
FOREIGN KEY ("sessionId") REFERENCES "user_sessions"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+375 -331
View File
@@ -14,31 +14,32 @@ datasource db {
// Auth
// ========================
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role UserRole @default(VIEWER)
isActive Boolean @default(true)
allServersAccess Boolean @default(true)
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role UserRole @default(VIEWER)
isActive Boolean @default(true)
allServersAccess Boolean @default(true)
activeOrganizationId String?
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activeOrganization Organization? @relation("UserActiveOrganization", fields: [activeOrganizationId], references: [id], onDelete: SetNull)
activeOrganization Organization? @relation("UserActiveOrganization", fields: [activeOrganizationId], references: [id], onDelete: SetNull)
organizationMemberships OrganizationMembership[]
createdOrganizations Organization[] @relation("OrganizationCreator")
apiKeys ApiKey[]
auditLogs AuditLog[]
deployments Deployment[]
settings UserSettings?
notificationProviders UserNotificationProvider[]
integrations UserIntegration[]
gitProviders UserGitProvider[]
storageDestinations UserStorageDestination[]
serverAssignments UserServerAccess[]
sentInvitations UserInvitation[] @relation("InvitationCreator")
createdOrganizations Organization[] @relation("OrganizationCreator")
apiKeys ApiKey[]
auditLogs AuditLog[]
deployments Deployment[]
settings UserSettings?
notificationProviders UserNotificationProvider[]
integrations UserIntegration[]
gitProviders UserGitProvider[]
storageDestinations UserStorageDestination[]
serverAssignments UserServerAccess[]
sentInvitations UserInvitation[] @relation("InvitationCreator")
sessions UserSession[]
@@index([activeOrganizationId])
@@map("users")
@@ -55,7 +56,7 @@ model Organization {
updatedAt DateTime @updatedAt
members OrganizationMembership[]
activeUsers User[] @relation("UserActiveOrganization")
activeUsers User[] @relation("UserActiveOrganization")
servers Server[]
projects Project[]
domains Domain[]
@@ -86,17 +87,17 @@ model OrganizationMembership {
// Projects
// ========================
model Project {
id String @id @default(cuid())
id String @id @default(cuid())
organizationId String
name String
slug String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
environments Environment[]
deployments Deployment[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
environments Environment[]
deployments Deployment[]
@@unique([organizationId, slug])
@@index([organizationId])
@@ -104,18 +105,18 @@ model Project {
}
model Environment {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
serverId String
name String
slug String
kind EnvironmentKind
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
containers Container[]
deployments Deployment[]
@@ -135,51 +136,51 @@ enum EnvironmentKind {
}
model UserSettings {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
panelName String @default("DOKTAINER")
panelUrl String @default("http://localhost:3000")
timezone String @default("Asia/Jakarta")
theme String @default("dark")
sessionTimeoutMinutes Int @default(30)
twoFactorEnabled Boolean @default(false)
twoFactorSecretEnc String?
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
panelName String @default("DOKTAINER")
panelUrl String @default("http://localhost:3000")
timezone String @default("Asia/Jakarta")
theme String @default("dark")
sessionTimeoutMinutes Int @default(30)
twoFactorEnabled Boolean @default(false)
twoFactorSecretEnc String?
twoFactorPendingSecretEnc String?
ipWhitelistEnabled Boolean @default(false)
ipWhitelist String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipWhitelistEnabled Boolean @default(false)
ipWhitelist String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("user_settings")
}
model UserNotificationProvider {
id String @id @default(cuid())
userId String
organizationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type NotificationProviderType
name String
enabled Boolean @default(true)
actions String[] @default([])
channel String?
webhookUrlEnc String?
smtpHost String?
smtpPort Int @default(587)
smtpSecure Boolean @default(false)
smtpUsername String?
smtpPasswordEnc String?
smtpFromEmail String?
smtpFromName String?
telegramChatId String?
id String @id @default(cuid())
userId String
organizationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type NotificationProviderType
name String
enabled Boolean @default(true)
actions String[] @default([])
channel String?
webhookUrlEnc String?
smtpHost String?
smtpPort Int @default(587)
smtpSecure Boolean @default(false)
smtpUsername String?
smtpPasswordEnc String?
smtpFromEmail String?
smtpFromName String?
telegramChatId String?
telegramBotTokenEnc String?
serverUrl String?
topic String?
userKey String?
apiKeyEnc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
serverUrl String?
topic String?
userKey String?
apiKeyEnc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, organizationId, type])
@@index([organizationId])
@@ -187,20 +188,20 @@ model UserNotificationProvider {
}
model UserIntegration {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider IntegrationProviderType
connected Boolean @default(false)
zoneId String?
apiTokenEnc String?
accessKeyId String?
secretAccessKeyEnc String?
region String?
bucket String?
tokenEnc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider IntegrationProviderType
connected Boolean @default(false)
zoneId String?
apiTokenEnc String?
accessKeyId String?
secretAccessKeyEnc String?
region String?
bucket String?
tokenEnc String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, provider])
@@index([userId])
@@ -235,30 +236,30 @@ model UserStorageDestination {
}
model UserGitProvider {
id String @id @default(cuid())
userId String
organizationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
organizationId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
containerDeploymentSources ContainerDeploymentSource[]
provider GitProviderType
name String
enabled Boolean @default(true)
appName String
appId String?
clientId String?
clientSecretEnc String?
webhookSecretEnc String?
appUrl String?
installationUrl String?
providerUrl String?
internalUrl String?
accountUsername String?
accountEmail String?
namespace String?
organizationScoped Boolean @default(false)
organizationName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
provider GitProviderType
name String
enabled Boolean @default(true)
appName String
appId String?
clientId String?
clientSecretEnc String?
webhookSecretEnc String?
appUrl String?
installationUrl String?
providerUrl String?
internalUrl String?
accountUsername String?
accountEmail String?
namespace String?
organizationScoped Boolean @default(false)
organizationName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, organizationId, provider])
@@index([organizationId])
@@ -279,22 +280,22 @@ model UserServerAccess {
}
model UserInvitation {
id String @id @default(cuid())
id String @id @default(cuid())
organizationId String
email String
name String
role UserRole
tokenHash String @unique
allServersAccess Boolean @default(true)
tokenHash String @unique
allServersAccess Boolean @default(true)
serverIds String[]
invitedById String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([expiresAt])
@@ -354,20 +355,20 @@ enum S3StorageProvider {
}
model ApiKey {
id String @id @default(cuid())
id String @id @default(cuid())
organizationId String
name String
keyHash String @unique
keyPrefix String
permissions String[]
userId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastUsed DateTime?
expiresAt DateTime?
isActive Boolean @default(true)
requestCount Int @default(0)
createdAt DateTime @default(now())
name String
keyHash String @unique
keyPrefix String
permissions String[]
userId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastUsed DateTime?
expiresAt DateTime?
isActive Boolean @default(true)
requestCount Int @default(0)
createdAt DateTime @default(now())
@@index([organizationId])
@@map("api_keys")
@@ -377,36 +378,36 @@ model ApiKey {
// Servers
// ========================
model Server {
id String @id @default(cuid())
id String @id @default(cuid())
organizationId String
name String
ip String
sshPort Int @default(22)
username String @default("root")
authType SshAuthType @default(PASSWORD)
sshKeyEnc String? // encrypted private key
passwordEnc String? // encrypted password
status ServerStatus @default(UNKNOWN)
os String?
location String?
tags String[]
lastHealthAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
ip String
sshPort Int @default(22)
username String @default("root")
authType SshAuthType @default(PASSWORD)
sshKeyEnc String? // encrypted private key
passwordEnc String? // encrypted password
status ServerStatus @default(UNKNOWN)
os String?
location String?
tags String[]
lastHealthAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
containers Container[]
environments Environment[]
networks Network[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
containers Container[]
environments Environment[]
networks Network[]
storageDestinations UserStorageDestination[]
domains Domain[]
auditLogs AuditLog[]
deployments Deployment[]
metrics ServerMetric[]
appInstalls AppInstall[]
backups Backup[]
domains Domain[]
auditLogs AuditLog[]
deployments Deployment[]
metrics ServerMetric[]
appInstalls AppInstall[]
backups Backup[]
firewallRulePresets FirewallRulePreset[]
userAssignments UserServerAccess[]
userAssignments UserServerAccess[]
@@index([organizationId])
@@map("servers")
@@ -425,20 +426,20 @@ enum ServerStatus {
}
model ServerMetric {
id String @id @default(cuid())
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
cpuPct Float
ramPct Float
diskPct Float
ramUsed BigInt
ramTotal BigInt
diskUsed BigInt
diskTotal BigInt
id String @id @default(cuid())
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
cpuPct Float
ramPct Float
diskPct Float
ramUsed BigInt
ramTotal BigInt
diskUsed BigInt
diskTotal BigInt
networkRxBps BigInt?
networkTxBps BigInt?
uptimeSec BigInt
recordedAt DateTime @default(now())
uptimeSec BigInt
recordedAt DateTime @default(now())
@@index([serverId, recordedAt])
@@map("server_metrics")
@@ -448,30 +449,30 @@ model ServerMetric {
// Containers
// ========================
model Container {
id String @id @default(cuid())
dockerId String?
name String
image String
status ContainerStatus @default(STOPPED)
sourceType ContainerSourceType @default(MANUAL)
deployMode ContainerDeployMode?
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
environmentId String?
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
domains Domain[]
id String @id @default(cuid())
dockerId String?
name String
image String
status ContainerStatus @default(STOPPED)
sourceType ContainerSourceType @default(MANUAL)
deployMode ContainerDeployMode?
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
environmentId String?
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
domains Domain[]
deploymentSource ContainerDeploymentSource?
deployments Deployment[]
deploymentLock DeploymentLock?
ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}]
envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}]
volumes Json @default("[]")
labels Json @default("{}")
restartPolicy String @default("unless-stopped")
cpuUsage String?
ramUsage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deployments Deployment[]
deploymentLock DeploymentLock?
ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}]
envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}]
volumes Json @default("[]")
labels Json @default("{}")
restartPolicy String @default("unless-stopped")
cpuUsage String?
ramUsage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([environmentId])
@@index([serverId])
@@ -479,76 +480,76 @@ model Container {
}
model ContainerDeploymentSource {
id String @id @default(cuid())
containerId String @unique
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
projectName String?
gitProviderId String?
gitProvider UserGitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull)
repoUrl String?
repoBranch String?
repoVisibility RepositoryVisibility?
buildType String?
buildPath String?
startCommand String?
portOverride String?
publishDirectory String?
imageTag String?
accessTokenEnc String?
id String @id @default(cuid())
containerId String @unique
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
projectName String?
gitProviderId String?
gitProvider UserGitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull)
repoUrl String?
repoBranch String?
repoVisibility RepositoryVisibility?
buildType String?
buildPath String?
startCommand String?
portOverride String?
publishDirectory String?
imageTag String?
accessTokenEnc String?
composeEnvOverrides Json?
projectPath String?
composeFilePath String?
dockerfilePath String?
dockerContextPath String?
deploymentPath String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectPath String?
composeFilePath String?
dockerfilePath String?
dockerContextPath String?
deploymentPath String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([gitProviderId])
@@map("container_deployment_sources")
}
model Deployment {
id String @id @default(cuid())
containerId String
organizationId String
serverId String
projectId String?
environmentId String?
userId String?
idempotencyKey String?
status DeploymentStatus
trigger DeploymentTrigger
version String?
commitSha String?
branch String?
image String?
imageDigest String?
sourceSnapshot Json?
configSnapshot Json
configRevision String?
rollbackSnapshotEnc String?
strategy String?
candidateRef String?
id String @id @default(cuid())
containerId String
organizationId String
serverId String
projectId String?
environmentId String?
userId String?
idempotencyKey String?
status DeploymentStatus
trigger DeploymentTrigger
version String?
commitSha String?
branch String?
image String?
imageDigest String?
sourceSnapshot Json?
configSnapshot Json
configRevision String?
rollbackSnapshotEnc String?
strategy String?
candidateRef String?
previousDeploymentId String?
failureReason String?
rollbackReason String?
logReference String?
error String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
failureReason String?
rollbackReason String?
logReference String?
error String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
previousDeployment Deployment? @relation("DeploymentPredecessor", fields: [previousDeploymentId], references: [id], onDelete: SetNull)
nextDeployments Deployment[] @relation("DeploymentPredecessor")
events DeploymentEvent[]
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
previousDeployment Deployment? @relation("DeploymentPredecessor", fields: [previousDeploymentId], references: [id], onDelete: SetNull)
nextDeployments Deployment[] @relation("DeploymentPredecessor")
events DeploymentEvent[]
@@unique([containerId, idempotencyKey])
@@index([containerId, createdAt])
@@ -561,14 +562,57 @@ model Deployment {
@@map("deployments")
}
model UserSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
createdIp String?
lastIp String?
userAgent String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
idleExpiresAt DateTime
absoluteExpiresAt DateTime
revokedAt DateTime?
revokeReason String?
events UserSessionEvent[]
@@index([userId, revokedAt])
@@index([idleExpiresAt])
@@index([absoluteExpiresAt])
@@map("user_sessions")
}
model UserSessionEvent {
id String @id @default(cuid())
sessionId String
session UserSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
type SessionEventType
ipAddress String?
userAgent String?
details Json?
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
@@map("user_session_events")
}
enum SessionEventType {
LOGIN
LOGOUT
REVOKED
EXPIRED
}
model DeploymentEvent {
id String @id @default(cuid())
deploymentId String
status DeploymentStatus
level DeploymentEventLevel @default(INFO)
message String
metadata Json?
createdAt DateTime @default(now())
id String @id @default(cuid())
deploymentId String
status DeploymentStatus
level DeploymentEventLevel @default(INFO)
message String
metadata Json?
createdAt DateTime @default(now())
deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade)
@@ -651,8 +695,8 @@ model FirewallRulePreset {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([serverId])
@@unique([serverId, rule, action, direction, source])
@@index([serverId])
@@map("firewall_rule_presets")
}
@@ -687,30 +731,30 @@ enum RepositoryVisibility {
// Domains & SSL
// ========================
model Domain {
id String @id @default(cuid())
name String @unique
organizationId String
domainNameType DomainNameType @default(EXACT)
configMode DomainConfigMode @default(ISOLATED)
reviewStatus DomainReviewStatus @default(CONFIRMED)
isPrimary Boolean @default(false)
type DnsType @default(A)
value String // IP or CNAME target
serverId String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
server Server? @relation(fields: [serverId], references: [id])
id String @id @default(cuid())
name String @unique
organizationId String
domainNameType DomainNameType @default(EXACT)
configMode DomainConfigMode @default(ISOLATED)
reviewStatus DomainReviewStatus @default(CONFIRMED)
isPrimary Boolean @default(false)
type DnsType @default(A)
value String // IP or CNAME target
serverId String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
server Server? @relation(fields: [serverId], references: [id])
targetContainerId String?
targetContainer Container? @relation(fields: [targetContainerId], references: [id], onDelete: SetNull)
targetPort Int?
proxy ProxyType @default(NONE)
discoverySource DomainDiscoverySource @default(MANUAL)
sslEnabled Boolean @default(false)
autoRenew Boolean @default(true)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
targetContainer Container? @relation(fields: [targetContainerId], references: [id], onDelete: SetNull)
targetPort Int?
proxy ProxyType @default(NONE)
discoverySource DomainDiscoverySource @default(MANUAL)
sslEnabled Boolean @default(false)
autoRenew Boolean @default(true)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sslCert SslCert?
sslCert SslCert?
@@index([organizationId])
@@index([targetContainerId])
@@ -757,18 +801,18 @@ enum DomainReviewStatus {
}
model SslCert {
id String @id @default(cuid())
domainId String @unique
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
issuer String @default("Let's Encrypt")
certPem String? // certificate PEM
keyPem String? // private key PEM (encrypted)
issuedAt DateTime?
expiresAt DateTime?
status SslStatus @default(PENDING)
autoRenew Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
domainId String @unique
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
issuer String @default("Let's Encrypt")
certPem String? // certificate PEM
keyPem String? // private key PEM (encrypted)
issuedAt DateTime?
expiresAt DateTime?
status SslStatus @default(PENDING)
autoRenew Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("ssl_certs")
}
@@ -785,19 +829,19 @@ enum SslStatus {
// Audit Log
// ========================
model AuditLog {
id String @id @default(cuid())
userId String?
id String @id @default(cuid())
userId String?
organizationId String?
user User? @relation(fields: [userId], references: [id])
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
serverId String?
server Server? @relation(fields: [serverId], references: [id])
action String
category LogCategory
level LogLevel @default(INFO)
message String
meta Json?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
serverId String?
server Server? @relation(fields: [serverId], references: [id])
action String
category LogCategory
level LogLevel @default(INFO)
message String
meta Json?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([organizationId])
@@ -828,17 +872,17 @@ enum LogLevel {
// App Installer
// ========================
model AppInstall {
id String @id @default(cuid())
appId String // e.g. "wordpress", "nginx"
appName String
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
containerName String? // docker container name
port String? // host port used
status AppInstallStatus @default(PENDING)
error String?
installedAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
appId String // e.g. "wordpress", "nginx"
appName String
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
containerName String? // docker container name
port String? // host port used
status AppInstallStatus @default(PENDING)
error String?
installedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("app_installs")
}
@@ -860,23 +904,23 @@ enum AppInstallStatus {
// Backups
// ========================
model Backup {
id String @id @default(cuid())
name String
type BackupType
databaseEngine DatabaseEngine?
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
target String @default("Local")
sizeMb Float?
filePath String?
storageBucket String?
storageKey String?
storageEtag String?
id String @id @default(cuid())
name String
type BackupType
databaseEngine DatabaseEngine?
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
target String @default("Local")
sizeMb Float?
filePath String?
storageBucket String?
storageKey String?
storageEtag String?
storageDestinationId String?
status BackupStatus @default(PENDING)
error String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status BackupStatus @default(PENDING)
error String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("backups")
}
+22 -1
View File
@@ -7,10 +7,13 @@ import SearchField from "@/components/SearchField";
import TablePagination from "@/components/TablePagination";
import {
apiKeys as apiKeysApi,
getApiBaseUrl,
settingsApi,
type ApiKeyExpiryOption,
type ApiKeyRecord,
type CreateApiKeyBody,
} from "@/lib/api";
import { resolveApiExampleUrl } from "@/lib/api-example-url";
import { useTablePagination } from "@/lib/use-table-pagination";
import {
Activity,
@@ -172,6 +175,7 @@ export default function ApiKeysPage() {
const [generatedKey, setGeneratedKey] = useState<GeneratedKeyState | null>(
null,
);
const [panelUrl, setPanelUrl] = useState<string | null>(null);
const [sessionRawKeys, setSessionRawKeys] = useState<Record<string, string>>(
{},
);
@@ -212,8 +216,16 @@ export default function ApiKeysPage() {
useEffect(() => {
void loadKeys();
void loadPanelUrl();
}, []);
const apiExampleUrl = resolveApiExampleUrl({
panelUrl,
browserOrigin:
typeof window === "undefined" ? null : window.location.origin,
apiBaseUrl: getApiBaseUrl(),
});
async function loadKeys() {
setLoading(true);
setError("");
@@ -347,6 +359,15 @@ export default function ApiKeysPage() {
});
}
async function loadPanelUrl() {
try {
const response = await settingsApi.get();
setPanelUrl(response.data.general.panelUrl);
} catch {
// The active browser origin remains a valid same-origin API fallback.
}
}
async function removeKey(id: string) {
setRemovingId(id);
setError("");
@@ -1136,7 +1157,7 @@ export default function ApiKeysPage() {
fontFamily: "JetBrains Mono, monospace",
}}
>
{`curl -H "x-api-key: ${generatedKey.rawKey}" http://localhost:4000/api/v1/servers`}
{`curl -H "x-api-key: ${generatedKey.rawKey}" ${apiExampleUrl}`}
</code>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { getToken, gitProvidersApi, redirectToLogin } from "@/lib/api";
import { gitProvidersApi, redirectToLogin } from "@/lib/api";
import { decodeGithubManifestState } from "@/lib/github-manifest-state";
export default function GithubCallbackPage() {
@@ -67,7 +67,7 @@ export default function GithubCallbackPage() {
},
);
if (conversionResponse.status === 401 && getToken()) {
if (conversionResponse.status === 401) {
redirectToLogin("session-expired");
return;
}
+1 -2
View File
@@ -11,7 +11,7 @@ import {
Shield,
User,
} from "lucide-react";
import { auth, setToken, setUser, UserRole } from "@/lib/api";
import { auth, setUser, UserRole } from "@/lib/api";
function roleLabel(role: UserRole) {
switch (role) {
@@ -92,7 +92,6 @@ export default function InvitationPage() {
password: form.password,
});
setToken(response.token);
setUser(response.user);
router.replace("/");
} catch (err: unknown) {
+6 -5
View File
@@ -11,7 +11,7 @@ import {
Lock,
Mail,
} from "lucide-react";
import { auth, getToken, setToken, setUser } from "@/lib/api";
import { auth, loadCurrentSession, setUser } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
@@ -38,8 +38,10 @@ export default function LoginPage() {
if (reasonParam === "session-expired") {
setError("The login session has expired. Please log in again.");
} else if (getToken()) {
router.replace("/");
} else {
void loadCurrentSession().then((user) => {
if (user) router.replace("/");
});
}
let cancelled = false;
@@ -100,11 +102,10 @@ export default function LoginPage() {
return;
}
if (!("token" in res) || !("user" in res)) {
if (!("user" in res)) {
throw new Error("Authentication failed");
}
setToken(res.token);
setUser(res.user);
router.replace("/");
} catch (err: unknown) {
-1
View File
@@ -16,7 +16,6 @@ import {
AlertTriangle,
CheckCircle,
Clock,
Zap,
} from "lucide-react";
import {
AreaChart,
@@ -11,7 +11,6 @@ import {
X,
} from "lucide-react";
import {
getUser,
type Server as ServerType,
type ServerConfigSnapshot,
type ServerSshAccessUpdateBody,
@@ -19,6 +18,7 @@ import {
type ServerSystemUserCreateBody,
servers as serversApi,
} from "@/lib/api";
import { useCurrentUser } from "@/lib/auth-state";
import {
createUnavailableServerConfigSnapshot,
getServiceRestartDescription,
@@ -49,6 +49,7 @@ export default function ServerConfigModal({
onActionComplete,
title = "Server Config",
}: ServerConfigModalProps) {
const currentUser = useCurrentUser();
const [activeTab, setActiveTab] = useState<ServerConfigTab>("overview");
const [snapshot, setSnapshot] = useState<ServerConfigSnapshot | null>(null);
const [loading, setLoading] = useState(true);
@@ -94,7 +95,7 @@ export default function ServerConfigModal({
);
const canManageSystemAccounts = ["OPERATOR", "SUPER_ADMIN"].includes(
getUser()?.role ?? "VIEWER",
currentUser?.role ?? "VIEWER",
);
const getSystemAccountActionKey = useCallback(
@@ -923,27 +924,6 @@ export default function ServerConfigModal({
});
};
const requestSystemGroupDeleteConfirm = (options: {
groupName: string;
gid: number;
members: string[];
primaryUsers: string[];
confirmation: string;
}) => {
setPendingConfirm({
kind: "system-group-delete",
groupName: options.groupName,
expectedGid: options.gid,
expectedMembers: options.members,
expectedPrimaryUsers: options.primaryUsers,
confirmation: options.confirmation,
title: `Delete system group ${options.groupName}`,
description: `Permanently delete the empty group ${options.groupName} (GID ${options.gid}). System, privileged, changed, or in-use groups will be rejected by the host.`,
confirmLabel: "Delete Group",
tone: "danger",
});
};
const requestSshAccessUpdateConfirm = (
options: ServerSshAccessUpdateBody,
) => {
@@ -1007,7 +987,6 @@ export default function ServerConfigModal({
requestSystemUserSshKeyRevokeConfirm
}
onRequestDeleteUserConfirm={requestSystemUserDeleteConfirm}
onRequestDeleteGroupConfirm={requestSystemGroupDeleteConfirm}
/>
);
case "ssh-access":
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
Eye,
EyeOff,
@@ -13,6 +14,7 @@ import {
ShieldAlert,
Trash2,
UserPlus,
X,
} from "lucide-react";
import type {
ServerConfigSnapshot,
@@ -63,28 +65,10 @@ interface ServerConfigUsersPanelProps {
confirmation: string;
removeHome: boolean;
}) => void;
onRequestDeleteGroupConfirm: (options: {
groupName: string;
gid: number;
members: string[];
primaryUsers: string[];
confirmation: string;
}) => void;
}
const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/;
const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]);
const protectedGroupNames = new Set([
"root",
"sudo",
"wheel",
"docker",
"adm",
"www-data",
"systemd-journal",
"ssh",
"sshd",
]);
const publicKeyPattern = /^(?:ssh-(?:ed25519|rsa)|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/;
export default function ServerConfigUsersPanel({
@@ -101,9 +85,9 @@ export default function ServerConfigUsersPanel({
onRequestSshKeyAddConfirm,
onRequestSshKeyRevokeConfirm,
onRequestDeleteUserConfirm,
onRequestDeleteGroupConfirm,
}: ServerConfigUsersPanelProps) {
const [previewMode, setPreviewMode] = useState<"user" | "group">("user");
const [managementModalOpen, setManagementModalOpen] = useState(false);
const [previewUsername, setPreviewUsername] = useState("deploy");
const [previewGroupName, setPreviewGroupName] = useState("docker");
const [userPurpose, setUserPurpose] = useState<"ssh" | "local">("ssh");
@@ -129,12 +113,6 @@ export default function ServerConfigUsersPanel({
);
const [deleteUserConfirmation, setDeleteUserConfirmation] = useState("");
const [deleteUserHome, setDeleteUserHome] = useState(false);
const [managedGroup, setManagedGroup] = useState(
snapshot.systemGroups.includes("docker")
? "docker"
: snapshot.systemGroups[0] ?? "",
);
const [deleteGroupConfirmation, setDeleteGroupConfirmation] = useState("");
const [passwordEditorOpen, setPasswordEditorOpen] = useState(false);
const [editPassword, setEditPassword] = useState("");
const [editPasswordConfirm, setEditPasswordConfirm] = useState("");
@@ -145,6 +123,19 @@ export default function ServerConfigUsersPanel({
const [sshKeyLabel, setSshKeyLabel] = useState("Personal laptop");
const [sshPublicKey, setSshPublicKey] = useState("");
const [sshKeyError, setSshKeyError] = useState<string | null>(null);
useEffect(() => {
if (!managementModalOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setManagementModalOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [managementModalOpen]);
const users = useMemo(
() => [
...(snapshot.rootUser ? [snapshot.rootUser] : []),
@@ -219,24 +210,6 @@ export default function ServerConfigUsersPanel({
: loginMethod === "password"
? `Create ${normalizedUsername || "<username>"} and prepare password-based SSH login.`
: `Create ${normalizedUsername || "<username>"} and install an SSH public key.`;
const managedGroupDetails = snapshot.systemGroupDetails.find(
(group) => group.name === managedGroup,
);
const managedGroupUsers = managedGroupDetails?.members ?? [];
const managedGroupPrimaryUsers = managedGroupDetails?.primaryUsers ?? [];
const managedGroupBlockReason = !managedGroup
? "Select a group to inspect."
: !managedGroupDetails || snapshot.gidMin == null
? "Group metadata or the host GID_MIN policy is unavailable. Refresh Server Config."
: protectedGroupNames.has(managedGroup)
? `${managedGroup} is protected because it is a system or privileged group.`
: managedGroupDetails.gid < snapshot.gidMin
? `GID ${managedGroupDetails.gid} is below the host GID_MIN (${snapshot.gidMin}).`
: managedGroupPrimaryUsers.length > 0
? `This is the primary group for ${managedGroupPrimaryUsers.join(", ")}.`
: managedGroupUsers.length > 0
? `Remove its members first: ${managedGroupUsers.join(", ")}.`
: null;
const toggleGroup = (group: string) => {
setSelectedGroups((current) =>
@@ -292,6 +265,7 @@ export default function ServerConfigUsersPanel({
remoteLogin,
credential,
});
setManagementModalOpen(false);
setGuidedSetupError(null);
setSetupPassword("");
setSetupPasswordConfirm("");
@@ -408,57 +382,78 @@ export default function ServerConfigUsersPanel({
style={{ display: "grid", gap: 16 }}
hidden={snapshotLoadError != null}
>
<div className="card" style={{ padding: 18 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
User Inventory
</strong>
<p
style={{ marginTop: 6, color: "var(--text-muted)", fontSize: 13 }}
>
Showing root and non-root accounts together with their detected
groups.
</p>
</div>
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
alignItems: "center",
}}
>
<UserBadge
label={`${snapshot.hasRootUser ? 1 : 0} root`}
tone="danger"
/>
<UserBadge
label={`${snapshot.nonRootUsers.length} non-root`}
tone="info"
/>
</div>
</div>
</div>
<div
className="card"
style={{
padding: 18,
display: "grid",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 16,
flexWrap: "wrap",
borderColor: "rgba(59,130,246,0.24)",
background:
"linear-gradient(180deg, rgba(59,130,246,0.045), var(--bg-card) 68%)",
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
User & Group Management
</strong>
<p
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 13,
maxWidth: 620,
}}
>
Create host accounts, create groups, and assign supplementary
group access without opening a terminal.
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setManagementModalOpen(true)}
style={{ minHeight: 44, justifyContent: "center" }}
>
<UserPlus size={14} /> Manage Users & Groups
</button>
</div>
{managementModalOpen && typeof document !== "undefined"
? createPortal(
<div className="modal-overlay" style={{ zIndex: 1003 }}>
<div className="modal-shell" style={{ maxWidth: 920 }}>
<button
type="button"
className="modal-close"
onClick={() => setManagementModalOpen(false)}
aria-label="Close user and group management modal"
autoFocus
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
role="dialog"
aria-modal="true"
aria-labelledby="user-group-management-title"
style={{
position: "relative",
width: "100%",
maxWidth: 920,
maxHeight: "90vh",
padding: 24,
display: "grid",
gap: 16,
overflowY: "auto",
borderColor: "rgba(59,130,246,0.24)",
background:
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
}}
>
<div
style={{
display: "flex",
@@ -469,7 +464,10 @@ export default function ServerConfigUsersPanel({
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
<strong
id="user-group-management-title"
style={{ color: "var(--text-primary)", fontSize: 14 }}
>
User & Group Management
</strong>
<p
@@ -480,8 +478,8 @@ export default function ServerConfigUsersPanel({
maxWidth: 620,
}}
>
Create passwordless host accounts, create groups, and assign
supplementary group access without opening a terminal.
Create host accounts, create groups, and assign supplementary
group access without opening a terminal.
</p>
</div>
<UserBadge label="Audited action" tone="success" />
@@ -490,7 +488,8 @@ export default function ServerConfigUsersPanel({
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(260px, 100%), 1fr))",
gap: 16,
alignItems: "start",
}}
@@ -501,7 +500,7 @@ export default function ServerConfigUsersPanel({
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
height: 40,
minHeight: 44,
alignSelf: "start",
}}
>
@@ -518,8 +517,7 @@ export default function ServerConfigUsersPanel({
className="btn"
onClick={() => setPreviewMode(item.key as "user" | "group")}
style={{
height: 40,
minHeight: 40,
minHeight: 44,
padding: "0 12px",
justifyContent: "center",
borderColor: active ? "var(--accent)" : "var(--border)",
@@ -1035,6 +1033,7 @@ export default function ServerConfigUsersPanel({
if (previewMode === "user") {
stageGuidedUserSetup();
} else {
setManagementModalOpen(false);
onRequestCreateGroupConfirm(normalizedGroupName);
}
}}
@@ -1068,170 +1067,12 @@ export default function ServerConfigUsersPanel({
) : null}
</div>
</div>
</div>
<div
className="card"
style={{
padding: 18,
display: "grid",
gap: 14,
borderColor: "rgba(239,68,68,0.18)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Existing Groups
</strong>
<p
style={{
marginTop: 5,
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
}}
>
Inspect group usage before opening the protected delete flow.
</p>
</div>
<UserBadge label="Audited action" tone="success" />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
gap: 14,
alignItems: "start",
}}
>
<div style={{ display: "grid", gap: 10 }}>
<SearchableSelect
value={managedGroup}
options={snapshot.systemGroupDetails.map((group) => ({
value: group.name,
label: group.name,
description: protectedGroupNames.has(group.name) ||
(snapshot.gidMin != null && group.gid < snapshot.gidMin)
? "Protected group"
: group.members.length > 0 || group.primaryUsers.length > 0
? "In use"
: `GID ${group.gid} · eligible for review`,
}))}
onChange={(value) => {
setManagedGroup(value);
setDeleteGroupConfirmation("");
}}
placeholder="Select an existing group..."
searchPlaceholder="Search group..."
emptyText="No matching group found"
/>
{managedGroup ? (
<div
style={{
display: "flex",
gap: 7,
flexWrap: "wrap",
alignItems: "center",
}}
>
<UserBadge
label={managedGroupBlockReason ? "Deletion locked" : "Candidate"}
tone={managedGroupBlockReason ? "warning" : "success"}
/>
<UserBadge
label={`${managedGroupUsers.length} detected member${managedGroupUsers.length === 1 ? "" : "s"}`}
tone="neutral"
/>
</div>
</div>
) : null}
</div>
<div
style={{
display: "grid",
gap: 10,
padding: 12,
borderRadius: 9,
border: managedGroupBlockReason
? "1px solid rgba(245,158,11,0.24)"
: "1px solid rgba(239,68,68,0.24)",
background: managedGroupBlockReason
? "rgba(245,158,11,0.055)"
: "rgba(239,68,68,0.035)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{managedGroupBlockReason ? (
<LockKeyhole size={15} color="#f59e0b" />
) : (
<Trash2 size={15} color="#ef4444" />
)}
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
Delete group
</strong>
</div>
<p
style={{
color: managedGroupBlockReason ? "#b45309" : "var(--text-muted)",
fontSize: 11,
lineHeight: 1.5,
}}
>
{managedGroupBlockReason ??
"No members were detected. The live feature will re-check GID_MIN, primary-group usage, and membership on the host immediately before deletion."}
</p>
{!managedGroupBlockReason && managedGroup ? (
<>
<input
className="input"
value={deleteGroupConfirmation}
onChange={(event) =>
setDeleteGroupConfirmation(event.target.value)
}
placeholder={`Type ${managedGroup} to confirm`}
autoComplete="off"
/>
<button
type="button"
className="btn btn-sm"
disabled={
deleteGroupConfirmation !== managedGroup ||
!canManageSystemAccounts ||
!managedGroupDetails ||
isActionRunning(`system-group:delete:${managedGroup}`)
}
onClick={() => {
if (!managedGroupDetails) return;
onRequestDeleteGroupConfirm({
groupName: managedGroupDetails.name,
gid: managedGroupDetails.gid,
members: managedGroupDetails.members,
primaryUsers: managedGroupDetails.primaryUsers,
confirmation: deleteGroupConfirmation,
});
}}
style={{
justifyContent: "center",
color: "#ef4444",
borderColor: "rgba(239,68,68,0.32)",
}}
>
<Trash2 size={13} /> Review Group Deletion
</button>
</>
) : null}
</div>
</div>
</div>
</div>,
document.body,
)
: null}
{users.length > 0 ? (
<div
style={{
+430
View File
@@ -0,0 +1,430 @@
"use client";
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import GuardedPage from "@/components/GuardedPage";
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
import SearchField from "@/components/SearchField";
import TablePagination from "@/components/TablePagination";
import { useCurrentUser } from "@/lib/auth-state";
import { auth, type UserSessionRecord } from "@/lib/api";
import { useTablePagination } from "@/lib/use-table-pagination";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Loader2,
LogOut,
MonitorSmartphone,
RefreshCw,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
type StatusFilter = "all" | "active" | "expired" | "revoked";
type SortKey = "user" | "ip" | "device" | "status" | "createdAt" | "expiresAt";
function describeDevice(userAgent: string | null) {
if (!userAgent) return { browser: "Unknown browser", platform: "Unknown device" };
const browser = userAgent.includes("Edg/")
? "Edge"
: userAgent.includes("Firefox/")
? "Firefox"
: userAgent.includes("Chrome/")
? "Chrome"
: userAgent.includes("Safari/")
? "Safari"
: "Other browser";
const platform = userAgent.includes("Windows")
? "Windows"
: userAgent.includes("Mac OS")
? "macOS"
: userAgent.includes("Android")
? "Android"
: userAgent.includes("iPhone") || userAgent.includes("iPad")
? "iOS"
: userAgent.includes("Linux")
? "Linux"
: "Unknown device";
return { browser, platform };
}
function getSessionStatus(session: UserSessionRecord): Exclude<StatusFilter, "all"> {
return session.status;
}
function getEffectiveExpiry(session: UserSessionRecord) {
return new Date(
Math.min(
new Date(session.idleExpiresAt).getTime(),
new Date(session.absoluteExpiresAt).getTime(),
),
).toISOString();
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function SortButton({
label,
column,
sortKey,
direction,
onSort,
}: {
label: string;
column: SortKey;
sortKey: SortKey;
direction: "asc" | "desc";
onSort: (column: SortKey) => void;
}) {
const active = sortKey === column;
const Icon = !active ? ArrowUpDown : direction === "asc" ? ArrowUp : ArrowDown;
return (
<button
type="button"
onClick={() => onSort(column)}
aria-label={`Sort by ${label}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "inherit",
background: "none",
border: 0,
padding: 0,
font: "inherit",
cursor: "pointer",
}}
>
{label}
<Icon size={11} aria-hidden="true" />
</button>
);
}
export default function SessionsPage() {
const currentUser = useCurrentUser();
const [sessions, setSessions] = useState<UserSessionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [userFilter, setUserFilter] = useState("all");
const [sortKey, setSortKey] = useState<SortKey>("createdAt");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [pendingSession, setPendingSession] = useState<UserSessionRecord | null>(null);
const [revoking, setRevoking] = useState(false);
useEffect(() => {
let cancelled = false;
void auth
.sessions()
.then((response) => {
if (!cancelled) setSessions(response.data);
})
.catch((loadError) => {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "Failed to load sessions");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const users = useMemo(() => {
const unique = new Map<string, UserSessionRecord["user"]>();
for (const session of sessions) unique.set(session.user.id, session.user);
return [...unique.values()].sort((left, right) => left.name.localeCompare(right.name));
}, [sessions]);
const filteredSessions = useMemo(() => {
const query = search.trim().toLowerCase();
const filtered = sessions.filter((session) => {
const device = describeDevice(session.userAgent);
const status = getSessionStatus(session);
const matchesSearch =
!query ||
[
session.user.name,
session.user.email,
session.lastIp,
session.createdIp,
device.browser,
device.platform,
].some((value) => value?.toLowerCase().includes(query));
return (
matchesSearch &&
(statusFilter === "all" || status === statusFilter) &&
(userFilter === "all" || session.user.id === userFilter)
);
});
const direction = sortDirection === "asc" ? 1 : -1;
return filtered.sort((left, right) => {
const leftDevice = describeDevice(left.userAgent);
const rightDevice = describeDevice(right.userAgent);
const values: Record<SortKey, [string | number, string | number]> = {
user: [left.user.name.toLowerCase(), right.user.name.toLowerCase()],
ip: [left.lastIp ?? left.createdIp ?? "", right.lastIp ?? right.createdIp ?? ""],
device: [leftDevice.browser, rightDevice.browser],
status: [getSessionStatus(left), getSessionStatus(right)],
createdAt: [new Date(left.createdAt).getTime(), new Date(right.createdAt).getTime()],
expiresAt: [
new Date(getEffectiveExpiry(left)).getTime(),
new Date(getEffectiveExpiry(right)).getTime(),
],
};
const [leftValue, rightValue] = values[sortKey];
return typeof leftValue === "number" && typeof rightValue === "number"
? (leftValue - rightValue) * direction
: String(leftValue).localeCompare(String(rightValue)) * direction;
});
}, [search, sessions, sortDirection, sortKey, statusFilter, userFilter]);
const pagination = useTablePagination({
items: filteredSessions,
pageSize: 10,
resetKey: `${search}|${statusFilter}|${userFilter}|${sortKey}|${sortDirection}`,
});
const handleSort = (column: SortKey) => {
if (column === sortKey) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortKey(column);
setSortDirection(column === "createdAt" || column === "expiresAt" ? "desc" : "asc");
};
const refresh = async () => {
setRefreshing(true);
setError("");
try {
const response = await auth.sessions();
setSessions(response.data);
} catch (refreshError) {
setError(refreshError instanceof Error ? refreshError.message : "Failed to refresh sessions");
} finally {
setRefreshing(false);
}
};
const revoke = async () => {
if (!pendingSession || revoking) return;
setRevoking(true);
setError("");
try {
await auth.revokeSession(pendingSession.id);
setPendingSession(null);
await refresh();
} catch (revokeError) {
setError(revokeError instanceof Error ? revokeError.message : "Failed to revoke session");
} finally {
setRevoking(false);
}
};
return (
<GuardedPage
route="/sessions"
title="Sessions"
subtitle="Review and revoke login sessions"
currentUser={currentUser}
>
<ConfirmActionDialog
open={pendingSession !== null}
title="Revoke login session?"
description={`Revoke access for ${pendingSession?.user.name ?? "this user"} on ${describeDevice(pendingSession?.userAgent ?? null).browser}?`}
confirmLabel={revoking ? "Revoking…" : "Revoke session"}
note="The affected browser must log in again. API keys are not changed."
onClose={() => !revoking && setPendingSession(null)}
onConfirm={() => void revoke()}
/>
<div className="animate-slide-in" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{error ? (
<IssueDetailsSummary
label="Sessions"
message={error}
description="Login session data could not be loaded or updated."
/>
) : null}
<div
className="card ui-responsive-toolbar users-toolbar"
style={{ padding: "12px 16px" }}
>
<SearchField
placeholder="Search users, IP addresses, browsers, or devices…"
value={search}
onChange={(event) => setSearch(event.target.value)}
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
/>
<select
className="input users-toolbar-filter"
aria-label="Filter sessions by status"
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value as StatusFilter)
}
style={{ fontSize: 12 }}
>
<option value="all">All statuses</option>
<option value="active">Active</option>
<option value="expired">Expired</option>
<option value="revoked">Revoked</option>
</select>
{currentUser?.role === "SUPER_ADMIN" ? (
<select
className="input users-toolbar-filter"
aria-label="Filter sessions by user"
value={userFilter}
onChange={(event) => setUserFilter(event.target.value)}
style={{ fontSize: 12 }}
>
<option value="all">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
) : null}
<div className="ui-toolbar-actions">
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12 }}
onClick={() => void refresh()}
disabled={loading || refreshing}
>
{refreshing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Refresh
</button>
</div>
</div>
<section className="card" style={{ overflow: "hidden" }}>
<div style={{ padding: "18px 20px", borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<MonitorSmartphone size={18} aria-hidden="true" />
<h2 style={{ fontSize: 16, fontWeight: 650 }}>Login Sessions</h2>
</div>
<p style={{ marginTop: 5, fontSize: 12, color: "var(--text-muted)" }}>
Review active and historical sign-ins. Revoke a session to force logout.
</p>
</div>
{loading ? (
<div style={{ padding: 40, display: "flex", justifyContent: "center", gap: 9, color: "var(--text-muted)" }}>
<Loader2 size={16} className="animate-spin" /> Loading sessions…
</div>
) : pagination.totalItems === 0 ? (
<div style={{ padding: 40, textAlign: "center", color: "var(--text-muted)", fontSize: 13 }}>
{sessions.length === 0 ? "No login session history is available yet." : "No sessions match these filters."}
</div>
) : (
<>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 1000 }}>
<thead>
<tr>
<th><SortButton label="User" column="user" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th><SortButton label="IP Address" column="ip" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th><SortButton label="Device" column="device" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th><SortButton label="Status" column="status" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th><SortButton label="Active Since" column="createdAt" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th><SortButton label="Expires" column="expiresAt" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{pagination.paginatedItems.map((session) => {
const device = describeDevice(session.userAgent);
const status = getSessionStatus(session);
return (
<tr key={session.id}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div>
<div style={{ fontWeight: 600, color: "var(--text-primary)" }}>{session.user.name}</div>
<div style={{ marginTop: 2, fontSize: 11, color: "var(--text-muted)" }}>{session.user.email}</div>
</div>
{session.current ? (
<span className="ui-badge badge-info">Current</span>
) : null}
</div>
</td>
<td style={{ fontFamily: "JetBrains Mono, monospace", color: "var(--text-primary)" }}>
{session.lastIp ?? session.createdIp ?? "Unknown"}
</td>
<td>
<div style={{ color: "var(--text-primary)" }}>{device.browser}</div>
<div style={{ marginTop: 2, fontSize: 11, color: "var(--text-muted)" }}>{device.platform}</div>
</td>
<td>
<span
className={`ui-badge ${status === "active" ? "badge-online" : "badge-danger"}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</td>
<td>{formatDate(session.createdAt)}</td>
<td>{formatDate(getEffectiveExpiry(session))}</td>
<td style={{ textAlign: "right" }}>
{session.current ? null : (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "6px 8px", color: session.canRevoke ? "#ef4444" : "var(--text-muted)" }}
onClick={() => setPendingSession(session)}
disabled={!session.canRevoke || revoking}
title={
session.canRevoke
? "Revoke session"
: status !== "active"
? "Session is no longer active"
: "Only a Super Admin can revoke another user's session"
}
aria-label={`Revoke session for ${session.user.name}`}
>
<LogOut size={14} aria-hidden="true" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
itemLabel="sessions"
onPageChange={pagination.setCurrentPage}
/>
</>
)}
</section>
</div>
</GuardedPage>
);
}
@@ -103,6 +103,10 @@ export default function GeneralSettingsPanel({
</option>
))}
</select>
<p style={{ marginTop: 6, fontSize: 12, color: "var(--text-muted)" }}>
Activity extends the inactivity window. Every login still follows
the server&apos;s absolute session lifetime.
</p>
</div>
</div>
@@ -16,7 +16,7 @@ export const SESSION_TIMEOUT_OPTIONS = [
{ label: "1 hour", value: 60 },
{ label: "4 hours", value: 240 },
{ label: "24 hours", value: 1440 },
{ label: "Never", value: 0 },
{ label: "No inactivity timeout", value: 0 },
];
export const SETTINGS_TABS: Array<{
+167 -32
View File
@@ -4,13 +4,18 @@ import Image from "next/image";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { auth, clearToken, redirectToLogin } from "@/lib/api";
import { auth, clearSessionState, redirectToLogin } from "@/lib/api";
import { useCurrentUser } from "@/lib/auth-state";
import OrganizationSwitcher from "@/components/OrganizationSwitcher";
import { navigation } from "@/lib/navigation";
import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences";
import { formatRoleLabel, hasMinimumRole } from "@/lib/permissions";
import { LogOut, ChevronsUpDown, X, Crown } from "lucide-react";
import {
LogOut,
ChevronsUpDown,
X,
Crown,
} from "lucide-react";
interface SidebarProps {
/** Desktop: whether sidebar is in collapsed (icon-only) state */
@@ -44,11 +49,13 @@ export default function Sidebar({
),
}))
.filter((section) => section.items.length > 0);
const accountMenuSections = visibleNavigation.filter(
(section) => section.label === "INTEGRATIONS" || section.label === "MANAGEMENT",
const organizationQuickAccessSections = visibleNavigation.filter(
(section) =>
section.label === "INTEGRATIONS" || section.label === "MANAGEMENT",
);
const sidebarNavigation = visibleNavigation.filter(
(section) => section.label !== "INTEGRATIONS" && section.label !== "MANAGEMENT",
(section) =>
section.label !== "INTEGRATIONS" && section.label !== "MANAGEMENT",
);
const handleLogout = async () => {
@@ -61,7 +68,7 @@ export default function Sidebar({
} catch {
// Best effort server-side logout; local session is cleared regardless.
} finally {
clearToken();
clearSessionState();
onMobileClose();
redirectToLogin();
}
@@ -86,8 +93,11 @@ export default function Sidebar({
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!accountMenuRef.current?.contains(event.target as Node)) setAccountOpen(false);
if (!accountMenuRef.current?.contains(event.target as Node)) {
setAccountOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, []);
@@ -333,13 +343,19 @@ export default function Sidebar({
aria-haspopup="menu"
aria-label="Open account menu"
onClick={() => setAccountOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setAccountOpen((value) => !value);
}
}}
style={{
padding: isCollapsed ? "12px 8px" : "12px 16px",
borderTop: "1px solid var(--border)",
display: "flex",
position: "relative",
alignItems: "center",
gap: 10,
position: "relative",
display: "flex",
cursor: "pointer",
}}
>
@@ -385,31 +401,150 @@ export default function Sidebar({
)}
{!isCollapsed && <ChevronsUpDown size={15} color="var(--text-muted)" />}
{accountOpen && (
<div role="menu" aria-label="Account menu" onClick={(event) => event.stopPropagation()} style={{ position: "absolute", bottom: "calc(100% + 8px)", left: isCollapsed ? "calc(100% + 14px)" : 8, width: 300, maxHeight: "min(520px, calc(100vh - 32px))", overflowY: "auto", padding: 12, background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: 8, boxShadow: "0 16px 40px rgba(0,0,0,0.22)", cursor: "default", zIndex: 60 }}>
<div style={{ padding: "2px 6px 10px" }}><div style={{ color: "var(--text-primary)", fontSize: 13, fontWeight: 600 }}>{currentUser?.name || currentUser?.email || "Authenticated User"} <span style={{ color: "var(--text-muted)", fontSize: 10 }}>· {formatRoleLabel(currentUser?.role)}{currentUser?.role === "SUPER_ADMIN" && <span aria-label="Super Admin" style={{ alignItems: "center", color: "#f5c451", display: "inline-flex", marginLeft: 3, verticalAlign: "middle" }}><Crown size={12} strokeWidth={2.25} /></span>}</span></div><div style={{ color: "var(--text-muted)", fontSize: 11, marginTop: 2 }}>{currentUser?.email}</div></div>
{accountMenuSections.map((section) => <div key={section.label} style={{ marginTop: 8 }}><div style={{ color: "var(--text-muted)", fontSize: 10, fontWeight: 600, letterSpacing: "0.08em", padding: "4px 6px 6px" }}>{section.label}</div>{section.items.map((item) => { const Icon = item.icon; const active = pathname === item.href; return <Link key={item.href} href={item.href} role="menuitem" onClick={() => { setAccountOpen(false); onMobileClose(); }} style={{ alignItems: "center", background: active ? "rgba(59, 130, 246, 0.12)" : "transparent", borderRadius: 7, color: active ? "#3b82f6" : "var(--text-secondary)", display: "flex", fontSize: 13, fontWeight: 500, gap: 9, padding: "8px 6px", textDecoration: "none" }}><Icon size={15} />{item.label}</Link>; })}</div>)}
<div style={{ height: 1, background: "var(--border)", margin: "12px 0 6px" }} />
<button type="button" role="menuitem" onClick={() => void handleLogout()} disabled={loggingOut} style={{ alignItems: "center", background: "transparent", border: "none", borderRadius: 7, color: loggingOut ? "#3b82f6" : "var(--text-secondary)", cursor: loggingOut ? "default" : "pointer", display: "flex", fontSize: 13, fontWeight: 500, gap: 9, padding: "8px 6px", width: "100%" }}><LogOut size={15} />{loggingOut ? "Logging out..." : "Log out"}</button>
<div
role="menu"
aria-label="Account menu"
onClick={(event) => event.stopPropagation()}
style={{
position: "absolute",
bottom: "calc(100% + 8px)",
left: isCollapsed ? "calc(100% + 14px)" : 8,
width: 300,
maxHeight: "min(520px, calc(100vh - 32px))",
overflowY: "auto",
padding: 12,
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 16px 40px rgba(0,0,0,0.22)",
cursor: "default",
zIndex: 60,
}}
>
<div style={{ padding: "2px 6px 10px" }}>
<div
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 600,
}}
>
{currentUser?.name ||
currentUser?.email ||
"Authenticated User"}{" "}
<span
style={{
color: "var(--text-muted)",
fontSize: 10,
marginTop: 2,
}}
>
· {formatRoleLabel(currentUser?.role)}
{currentUser?.role === "SUPER_ADMIN" && (
<span
aria-label="Super Admin"
style={{
alignItems: "center",
color: "#f5c451",
display: "inline-flex",
marginLeft: 3,
verticalAlign: "middle",
}}
>
<Crown size={12} strokeWidth={2.25} />
</span>
)}
</span>
</div>
<div
style={{
color: "var(--text-muted)",
fontSize: 11,
marginTop: 2,
}}
>
{currentUser?.email}
</div>
</div>
{organizationQuickAccessSections.map((section) => (
<div key={section.label} style={{ marginTop: 8 }}>
<div
style={{
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
padding: "4px 6px 6px",
}}
>
{section.label}
</div>
{section.items.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
role="menuitem"
onClick={() => {
setAccountOpen(false);
onMobileClose();
}}
style={{
alignItems: "center",
background: active
? "rgba(59, 130, 246, 0.12)"
: "transparent",
borderRadius: 7,
color: active ? "#3b82f6" : "var(--text-secondary)",
display: "flex",
fontSize: 13,
fontWeight: 500,
gap: 9,
padding: "8px 6px",
textDecoration: "none",
}}
>
<Icon size={15} />
{item.label}
</Link>
);
})}
</div>
))}
<div
style={{
height: 1,
background: "var(--border)",
margin: "12px 0 6px",
}}
/>
<button
type="button"
role="menuitem"
onClick={() => void handleLogout()}
disabled={loggingOut}
style={{
alignItems: "center",
background: "transparent",
border: "none",
borderRadius: 7,
color: loggingOut ? "#3b82f6" : "var(--text-secondary)",
cursor: loggingOut ? "default" : "pointer",
display: "flex",
fontSize: 13,
fontWeight: 500,
gap: 9,
padding: "8px 6px",
width: "100%",
}}
>
<LogOut size={15} />
{loggingOut ? "Logging out..." : "Log out"}
</button>
</div>
)}
<button
onClick={() => void handleLogout()}
disabled={loggingOut}
style={{
marginLeft: isCollapsed ? "auto" : 0,
background: "transparent",
border: "none",
color: loggingOut ? "#3b82f6" : "var(--text-muted)",
cursor: loggingOut ? "default" : "pointer",
padding: 4,
borderRadius: 6,
opacity: loggingOut ? 0.8 : 1,
display: "none",
}}
title={loggingOut ? "Logging out..." : "Logout"}
>
<LogOut size={14} />
</button>
</div>
</aside>
);
-3
View File
@@ -5,7 +5,6 @@ import {
addPreferencesListener,
applyTheme,
getStoredTheme,
initializeSessionTimeoutWatcher,
} from "@/lib/preferences";
export default function ThemeController() {
@@ -25,14 +24,12 @@ export default function ThemeController() {
window.addEventListener("pageshow", syncTheme);
document.addEventListener("visibilitychange", syncTheme);
const removePreferencesListener = addPreferencesListener(syncTheme);
const stopSessionWatcher = initializeSessionTimeoutWatcher();
return () => {
media.removeEventListener("change", onThemeMediaChange);
window.removeEventListener("pageshow", syncTheme);
document.removeEventListener("visibilitychange", syncTheme);
removePreferencesListener();
stopSessionWatcher();
};
}, []);
+62
View File
@@ -0,0 +1,62 @@
type ApiExampleUrlOptions = {
panelUrl?: string | null;
browserOrigin?: string | null;
apiBaseUrl?: string | null;
};
function normalizeHttpUrl(value: string | null | undefined) {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed;
} catch {
return null;
}
}
function isLoopbackHost(hostname: string) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1"
);
}
export function resolveApiExampleUrl({
panelUrl,
browserOrigin,
apiBaseUrl,
}: ApiExampleUrlOptions) {
const storedPanelUrl = normalizeHttpUrl(panelUrl);
const runtimeOrigin = normalizeHttpUrl(browserOrigin);
const storedPanelIsStaleLocalDefault =
storedPanelUrl &&
runtimeOrigin &&
isLoopbackHost(storedPanelUrl.hostname) &&
!isLoopbackHost(runtimeOrigin.hostname);
const publicOrigin = storedPanelIsStaleLocalDefault
? runtimeOrigin
: storedPanelUrl || runtimeOrigin;
if (publicOrigin) {
return new URL("/api/v1/servers", publicOrigin.origin).toString();
}
const configuredApiBase = normalizeHttpUrl(apiBaseUrl);
if (configuredApiBase) {
return new URL(
`${configuredApiBase.pathname.replace(/\/+$/, "")}/servers`,
configuredApiBase.origin,
).toString();
}
return "http://localhost:3000/api/v1/servers";
}
+98 -44
View File
@@ -1,15 +1,10 @@
/**
* API Client — DOKTAINER
* Centralized HTTP client with auth token injection
* Centralized HTTP client with cookie-based browser authentication
*/
import { emitAuthStateChanged } from "@/lib/auth-events";
import {
getSensitiveStorageItem,
removeSensitiveStorageItem,
sensitiveStorageKeys,
setSensitiveStorageItem,
} from "@/lib/browser-storage";
import { clearLegacyAuthStorage } from "@/lib/browser-storage";
import {
buildTerminalWebSocketUrl,
resolveWebSocketBaseUrl,
@@ -59,6 +54,7 @@ const PUBLIC_AUTH_PATHS = [
"/auth/register",
"/auth/invitations",
"/auth/registration-status",
"/auth/me",
] as const;
const READ_ONLY_ALLOWED_MUTATION_PATHS = new Set(["/auth/logout"]);
@@ -124,52 +120,80 @@ export function redirectToLogin(reason?: string): void {
function handleUnauthorizedSession(
path: string,
hadToken: boolean,
): never | void {
if (!hadToken || isPublicAuthPath(path)) {
if (isPublicAuthPath(path)) {
return;
}
clearToken();
clearSessionState();
redirectToLogin("session-expired");
throw new Error("Unauthorized - session expired");
}
// ─── Token helpers ────────────────────────────────────────────────────────────
// ─── Browser session state ────────────────────────────────────────────────────
export function getToken(): string | null {
return getSensitiveStorageItem(sensitiveStorageKeys.token);
}
let currentUser: UserInfo | null = null;
let sessionRequest: Promise<UserInfo | null> | null = null;
let sessionLoaded = false;
let sessionGeneration = 0;
export function setToken(token: string): void {
setSensitiveStorageItem(sensitiveStorageKeys.token, token);
emitAuthStateChanged();
}
export function clearToken(): void {
removeSensitiveStorageItem(sensitiveStorageKeys.token);
removeSensitiveStorageItem(sensitiveStorageKeys.user);
export function clearSessionState(): void {
currentUser = null;
sessionRequest = null;
sessionLoaded = false;
sessionGeneration += 1;
clearLegacyAuthStorage();
clearStoredOrganizationId();
emitAuthStateChanged();
emitAuthStateChanged(true);
}
export function getUser(): UserInfo | null {
try {
const raw = getSensitiveStorageItem(sensitiveStorageKeys.user);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
return currentUser;
}
export function setUser(user: UserInfo): void {
setSensitiveStorageItem(sensitiveStorageKeys.user, JSON.stringify(user));
currentUser = user;
sessionLoaded = true;
sessionGeneration += 1;
if (user.activeOrganizationId) {
setStoredOrganizationId(user.activeOrganizationId);
}
emitAuthStateChanged();
}
export function loadCurrentSession(force = false): Promise<UserInfo | null> {
if (sessionRequest) return sessionRequest;
if (!force && sessionLoaded) return Promise.resolve(currentUser);
clearLegacyAuthStorage();
const requestGeneration = sessionGeneration;
const request = auth
.me()
.then((response) => {
if (requestGeneration !== sessionGeneration) {
return currentUser;
}
setUser(response.user);
return response.user;
})
.catch(() => {
if (requestGeneration !== sessionGeneration) {
return currentUser;
}
currentUser = null;
sessionLoaded = true;
emitAuthStateChanged();
return null;
})
.finally(() => {
if (sessionRequest === request) {
sessionRequest = null;
}
});
sessionRequest = request;
return request;
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UserInfo {
@@ -180,6 +204,28 @@ export interface UserInfo {
activeOrganizationId?: string | null;
}
export interface UserSessionRecord {
id: string;
createdIp: string | null;
lastIp: string | null;
userAgent: string | null;
createdAt: string;
lastSeenAt: string;
idleExpiresAt: string;
absoluteExpiresAt: string;
revokedAt: string | null;
revokeReason: string | null;
status: "active" | "expired" | "revoked";
current: boolean;
canRevoke: boolean;
user: {
id: string;
name: string;
email: string;
role: string;
};
}
export interface OrganizationRecord {
id: string;
name: string;
@@ -706,8 +752,6 @@ async function requestRaw(
): Promise<Response> {
enforceViewerReadOnlyClient(path, options.method);
const token = getToken();
const hadToken = Boolean(token);
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
@@ -729,8 +773,8 @@ async function requestRaw(
headers["Content-Type"] = "application/json";
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
if (methodRequiresWriteAccess(options.method)) {
headers["x-doktainer-request"] = "1";
}
const organizationId = getStoredOrganizationId();
@@ -744,6 +788,7 @@ async function requestRaw(
res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
credentials: "include",
signal: options.signal ?? controller.signal,
});
} catch (error) {
@@ -761,7 +806,7 @@ async function requestRaw(
}
if (res.status === 401) {
handleUnauthorizedSession(path, hadToken);
handleUnauthorizedSession(path);
}
return res;
@@ -813,11 +858,7 @@ function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
// ─── WebSocket helper ─────────────────────────────────────────────────────────
export function createWs(path: string): WebSocket {
const token = getToken();
const sep = path.includes("?") ? "&" : "?";
return new WebSocket(
`${getWebSocketBaseUrl()}${path}${token ? `${sep}token=${encodeURIComponent(token)}` : ""}`,
);
return new WebSocket(`${getWebSocketBaseUrl()}${path}`);
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
@@ -830,7 +871,6 @@ export const auth = {
post<
| {
success: boolean;
token: string;
user: UserInfo;
requiresTwoFactor?: false;
}
@@ -842,18 +882,32 @@ export const auth = {
}),
register: (name: string, email: string, password: string) =>
post<{ success: boolean; token: string; user: UserInfo }>(
post<{ success: boolean; user: UserInfo }>(
"/auth/register",
{ name, email, password },
),
me: () => get<{ success: boolean; data: UserInfo }>("/auth/me"),
me: () => get<{ success: boolean; user: UserInfo }>("/auth/me"),
logout: () =>
request<{ success: boolean; message: string }>("/auth/logout", {
method: "POST",
}),
sessions: () =>
get<{ success: boolean; data: UserSessionRecord[] }>("/auth/sessions"),
revokeSession: (sessionId: string) =>
del<{ success: boolean; data: { revokedCurrent: boolean } }>(
`/auth/sessions/${encodeURIComponent(sessionId)}`,
),
revokeOtherSessions: () =>
post<{ success: boolean; data: { revokedCount: number } }>(
"/auth/sessions/revoke-others",
{},
),
changePassword: (currentPassword: string, newPassword: string) =>
patch<{ success: boolean; message: string }>("/auth/password", {
currentPassword,
@@ -908,7 +962,7 @@ export const auth = {
token: string,
body: { name?: string; password: string },
) =>
post<{ success: boolean; token: string; user: UserInfo }>(
post<{ success: boolean; user: UserInfo }>(
`/auth/invitations/${token}/accept`,
body,
),
+30 -4
View File
@@ -1,13 +1,39 @@
const AUTH_EVENT = "vps-auth-changed";
const AUTH_CHANNEL = "doktainer-auth";
export function emitAuthStateChanged() {
function getAuthChannel(): BroadcastChannel | null {
if (typeof window === "undefined" || !("BroadcastChannel" in window)) {
return null;
}
return new BroadcastChannel(AUTH_CHANNEL);
}
export type AuthEventSource = "local" | "broadcast";
export function emitAuthStateChanged(broadcast = false) {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(AUTH_EVENT));
if (broadcast) {
const channel = getAuthChannel();
channel?.postMessage({ type: AUTH_EVENT });
channel?.close();
}
}
export function addAuthStateListener(listener: () => void) {
export function addAuthStateListener(
listener: (source: AuthEventSource) => void,
) {
if (typeof window === "undefined") return () => {};
window.addEventListener(AUTH_EVENT, listener);
return () => window.removeEventListener(AUTH_EVENT, listener);
const onLocalEvent = () => listener("local");
window.addEventListener(AUTH_EVENT, onLocalEvent);
const channel = getAuthChannel();
const onChannelMessage = () => listener("broadcast");
channel?.addEventListener("message", onChannelMessage);
return () => {
window.removeEventListener(AUTH_EVENT, onLocalEvent);
channel?.removeEventListener("message", onChannelMessage);
channel?.close();
};
}
+42 -16
View File
@@ -1,23 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { getToken, getUser, redirectToLogin, type UserInfo } from "@/lib/api";
import {
getUser,
loadCurrentSession,
redirectToLogin,
type UserInfo,
} from "@/lib/api";
import { addAuthStateListener } from "@/lib/auth-events";
export function useCurrentUser() {
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
const [currentUser, setCurrentUser] = useState<UserInfo | null>(getUser());
useEffect(() => {
const syncCurrentUser = () => {
let active = true;
const syncCurrentUser = (source: "local" | "broadcast") => {
if (source === "broadcast") {
void loadCurrentSession(true).then((user) => {
if (active) setCurrentUser(user);
});
return;
}
setCurrentUser(getUser());
};
syncCurrentUser();
window.addEventListener("storage", syncCurrentUser);
void loadCurrentSession().then((user) => {
if (active) setCurrentUser(user);
});
const removeAuthStateListener = addAuthStateListener(syncCurrentUser);
return () => {
window.removeEventListener("storage", syncCurrentUser);
active = false;
removeAuthStateListener();
};
}, []);
@@ -26,21 +39,34 @@ export function useCurrentUser() {
}
export function useRequireAuth() {
const [hasToken, setHasToken] = useState(false);
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
const syncAuth = () => {
const authenticated = Boolean(getToken());
setHasToken(authenticated);
if (!authenticated) {
redirectToLogin();
let active = true;
const syncAuth = (source: "local" | "broadcast") => {
if (source === "broadcast") {
void loadCurrentSession(true).then((user) => {
if (!active) return;
setAuthenticated(Boolean(user));
if (!user) redirectToLogin();
});
return;
}
setAuthenticated(Boolean(getUser()));
};
syncAuth();
return addAuthStateListener(syncAuth);
void loadCurrentSession().then((user) => {
if (!active) return;
setAuthenticated(Boolean(user));
if (!user) redirectToLogin();
});
const removeAuthStateListener = addAuthStateListener(syncAuth);
return () => {
active = false;
removeAuthStateListener();
};
}, []);
return hasToken;
return authenticated === true;
}
+14 -8
View File
@@ -1,11 +1,13 @@
const TOKEN_KEY = "doktainer_token";
const USER_KEY = "doktainer_user";
const ORGANIZATION_STORAGE_KEY = "doktainer_active_organization";
type SensitiveStorageKey =
| typeof TOKEN_KEY
| typeof USER_KEY
| typeof ORGANIZATION_STORAGE_KEY;
type SensitiveStorageKey = typeof ORGANIZATION_STORAGE_KEY;
const LEGACY_AUTH_STORAGE_KEYS = [
"doktainer_token",
"doktainer_user",
"vps_token",
"vps_user",
] as const;
function getLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
@@ -34,8 +36,12 @@ export function removeSensitiveStorageItem(key: SensitiveStorageKey): void {
getLocalStorage()?.removeItem(key);
}
export function clearLegacyAuthStorage(): void {
const storage = getLocalStorage();
if (!storage) return;
for (const key of LEGACY_AUTH_STORAGE_KEYS) storage.removeItem(key);
}
export const sensitiveStorageKeys = {
token: TOKEN_KEY,
user: USER_KEY,
organization: ORGANIZATION_STORAGE_KEY,
} as const;
+9 -1
View File
@@ -16,6 +16,7 @@ import {
LayoutDashboard,
Lock,
MessageCircle,
MonitorSmartphone,
Network,
Package,
RotateCcw,
@@ -181,10 +182,17 @@ export const navigation: NavSection[] = [
{
label: "MANAGEMENT",
items: [
{
href: "/sessions",
icon: MonitorSmartphone,
label: "Sessions",
minRole: "VIEWER",
keywords: ["login", "sessions", "devices", "security"],
},
{
href: "/users",
icon: Users,
label: "Users & RBAC",
label: "Users",
minRole: "OPERATOR",
keywords: ["roles", "permissions", "team"],
},
+1
View File
@@ -22,6 +22,7 @@ export function formatRoleLabel(role: string | undefined) {
}
export const managementRouteAccess = {
"/sessions": "VIEWER",
"/users": "OPERATOR",
"/api-keys": "DEVELOPER",
"/settings": "OPERATOR",
-50
View File
@@ -1,4 +1,3 @@
import { clearToken, getToken } from "@/lib/api";
import {
applyTheme,
THEME_STORAGE_KEY,
@@ -101,52 +100,3 @@ export function addPreferencesListener(listener: () => void) {
window.removeEventListener(SETTINGS_EVENT, listener);
};
}
export function initializeSessionTimeoutWatcher() {
if (typeof window === "undefined") return () => undefined;
let timer: number | undefined;
const schedule = () => {
if (timer) {
window.clearTimeout(timer);
}
const minutes = getStoredSessionTimeoutMinutes();
if (!minutes || minutes <= 0) return;
if (!getToken()) return;
timer = window.setTimeout(
() => {
clearToken();
window.location.href = "/login?reason=session-expired";
},
minutes * 60 * 1000,
);
};
const events: Array<keyof WindowEventMap> = [
"click",
"keydown",
"mousemove",
"scroll",
"touchstart",
];
for (const eventName of events) {
window.addEventListener(eventName, schedule, { passive: true });
}
const removePreferencesListener = addPreferencesListener(schedule);
schedule();
return () => {
if (timer) {
window.clearTimeout(timer);
}
for (const eventName of events) {
window.removeEventListener(eventName, schedule);
}
removePreferencesListener();
};
}
+4 -20
View File
@@ -1,7 +1,7 @@
import "dotenv/config";
import Fastify, { FastifyError } from "fastify";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import multipart from "@fastify/multipart";
import websocket from "@fastify/websocket";
@@ -30,23 +30,11 @@ import { gitProviderRoutes } from "./routes/git-providers";
import { storageDestinationRoutes } from "./routes/storage-destinations";
import { commitHistoryRoutes } from "./routes/commit-history";
import { startS3StorageRetentionScheduler } from "./services/s3-storage-retention.service";
import { startAuthSessionRetentionScheduler } from "./services/auth-session-retention.service";
const PORT = parseInt(process.env.PORT || "4000");
const HOST = process.env.HOST || "0.0.0.0";
export function getJwtSecretOrThrow(env = process.env): string {
const secret = env.JWT_SECRET?.trim();
if (!secret) {
throw new Error(
"JWT_SECRET must be configured before starting the backend",
);
}
if (secret.length < 32) {
throw new Error("JWT_SECRET must be at least 32 characters long");
}
return secret;
}
function getRateLimitMax(env = process.env): number {
const raw = Number.parseInt(env.RATE_LIMIT_MAX || "300", 10);
return Number.isFinite(raw) && raw > 0 ? raw : 300;
@@ -120,7 +108,6 @@ async function ensureDatabaseConnection() {
}
async function start() {
const jwtSecret = getJwtSecretOrThrow();
validateEncryptionConfiguration();
await ensureDatabaseConnection();
setDefaultSecurityHeaders();
@@ -149,11 +136,7 @@ async function start() {
}),
});
await app.register(jwt, {
secret: jwtSecret,
sign: { expiresIn: process.env.JWT_EXPIRES_IN || "7d" },
});
await app.register(cookie);
await app.register(websocket);
await app.register(multipart, { limits: { fileSize: 512 * 1024 * 1024, files: 1 } });
@@ -211,6 +194,7 @@ async function start() {
// ── Start ─────────────────────────────────────────────
try {
await app.listen({ port: PORT, host: HOST });
startAuthSessionRetentionScheduler();
startS3StorageRetentionScheduler();
console.log(
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
+39
View File
@@ -0,0 +1,39 @@
import { Transform } from "node:stream";
export const CUSTOM_RESTORE_SCRIPT_ARGS = [
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
"--file=-",
] as const;
export const ATOMIC_PSQL_RESTORE_ARGS = [
"--single-transaction",
"--set",
"ON_ERROR_STOP=1",
] as const;
export function isUnsupportedRestoreSetting(line: string) {
return line.trim() === "SET transaction_timeout = 0;";
}
export function createRestoreSqlSanitizer() {
let pending = "";
return new Transform({
transform(chunk, _encoding, callback) {
pending += chunk.toString();
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? "";
const output = lines
.filter((line) => !isUnsupportedRestoreSetting(line))
.map((line) => `${line}\n`)
.join("");
callback(null, output);
},
flush(callback) {
callback(null, isUnsupportedRestoreSetting(pending) ? "" : pending);
},
});
}
+201 -95
View File
@@ -2,6 +2,14 @@ import crypto from "crypto";
import { isIP } from "node:net";
import { FastifyReply, FastifyRequest } from "fastify";
import prisma from "../lib/prisma";
import {
clearSessionCookie,
expireUserSession,
getRequestUserAgent,
getSessionToken,
hashSessionToken,
touchUserSession,
} from "../services/auth-session.service";
declare module "fastify" {
interface FastifyRequest {
@@ -10,7 +18,8 @@ declare module "fastify" {
organizationId?: string;
apiKeyId?: string;
apiKeyPermissions?: string[];
authMethod?: "jwt" | "api-key";
sessionId?: string;
authMethod?: "session" | "api-key";
}
}
@@ -24,6 +33,110 @@ function getHeaderValue(value: string | string[] | undefined): string | null {
return null;
}
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const BROWSER_MUTATION_HEADER = "x-doktainer-request";
function normalizeHttpOrigin(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
function firstForwardedValue(value: string | null): string | null {
return value?.split(",")[0]?.trim() || null;
}
function getEffectiveRequestOrigin(
req: FastifyRequest,
env: NodeJS.ProcessEnv,
): string | null {
const trustProxy = env.TRUST_PROXY === "true";
const forwardedHost = trustProxy
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-host"]))
: null;
const forwardedProtocol = trustProxy
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-proto"]))
: null;
const host = forwardedHost || getHeaderValue(req.headers.host);
const protocol = forwardedProtocol || req.protocol;
if (!host || !protocol) return null;
return normalizeHttpOrigin(`${protocol.replace(/:$/, "")}://${host}`);
}
function getAllowedBrowserOrigins(
req: FastifyRequest,
env: NodeJS.ProcessEnv,
): Set<string> {
const origins = [
env.NEXT_PUBLIC_PANEL_URL,
env.FRONTEND_URL,
...(env.CORS_ORIGINS ?? "").split(","),
]
.map(normalizeHttpOrigin)
.filter((origin): origin is string => Boolean(origin));
const requestOrigin = getEffectiveRequestOrigin(req, env);
if (requestOrigin) origins.push(requestOrigin);
return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]);
}
export function enforceBrowserMutationProtection(
req: FastifyRequest,
reply: FastifyReply,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
if (getHeaderValue(req.headers[BROWSER_MUTATION_HEADER]) !== "1") {
reply.status(403).send({
success: false,
error: "Forbidden — missing browser request verification",
});
return false;
}
const fetchSite = getHeaderValue(req.headers["sec-fetch-site"]);
if (fetchSite === "cross-site") {
reply.status(403).send({
success: false,
error: "Forbidden — cross-site request rejected",
});
return false;
}
const rawOrigin = getHeaderValue(req.headers.origin);
const origin = normalizeHttpOrigin(rawOrigin);
const browserConfirmedSameOrigin = fetchSite === "same-origin";
if (
rawOrigin &&
(!origin ||
(!browserConfirmedSameOrigin &&
!getAllowedBrowserOrigins(req, env).has(origin)))
) {
reply.status(403).send({
success: false,
error: "Forbidden — request origin is not allowed",
});
return false;
}
return true;
}
export async function requireBrowserMutationVerification(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
enforceBrowserMutationProtection(req, reply);
}
function extractApiKey(req: FastifyRequest): string | null {
const headerKey = getHeaderValue(req.headers["x-api-key"]);
if (headerKey) return headerKey;
@@ -281,7 +394,6 @@ function enforceUserSecurityPolicies(
req: FastifyRequest,
reply: FastifyReply,
settings: UserSecuritySettings,
tokenIssuedAt?: number,
): boolean {
if (!settings) {
return true;
@@ -298,21 +410,6 @@ function enforceUserSecurityPolicies(
}
}
if (
typeof tokenIssuedAt === "number" &&
Number.isFinite(tokenIssuedAt) &&
settings.sessionTimeoutMinutes > 0
) {
const maxSessionAgeMs = settings.sessionTimeoutMinutes * 60 * 1000;
if (Date.now() - tokenIssuedAt * 1000 > maxSessionAgeMs) {
reply.status(401).send({
success: false,
error: "Unauthorized — session expired",
});
return false;
}
}
return true;
}
@@ -376,104 +473,113 @@ async function authenticateApiKey(
enforceUserSecurityPolicies(req, reply, apiKey.user.settings);
}
/**
* Middleware: verify JWT token from Authorization header
* Attaches userId and userRole to request
*/
/** Authenticate an API key or an opaque database-backed browser session. */
export async function authenticate(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const query = req.query as { token?: string } | undefined;
const queryToken = query?.token;
if (queryToken && !req.headers.authorization) {
req.headers.authorization = `Bearer ${queryToken}`;
}
const apiKey = extractApiKey(req);
if (apiKey) {
await authenticateApiKey(apiKey, req, reply);
return;
}
try {
const payload = await req.jwtVerify<{
sub: string;
role: string;
iat?: number;
}>();
const rawToken = getSessionToken(req);
if (!rawToken) {
reply.status(401).send({
success: false,
error: "Unauthorized — login session required",
});
return;
}
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
role: true,
isActive: true,
activeOrganizationId: true,
organizationMemberships: {
select: {
organizationId: true,
isDefault: true,
const session = await prisma.userSession.findUnique({
where: { tokenHash: hashSessionToken(rawToken) },
include: {
user: {
select: {
id: true,
role: true,
isActive: true,
activeOrganizationId: true,
organizationMemberships: {
select: { organizationId: true, isDefault: true },
},
},
settings: {
select: {
sessionTimeoutMinutes: true,
ipWhitelistEnabled: true,
ipWhitelist: true,
settings: {
select: {
sessionTimeoutMinutes: true,
ipWhitelistEnabled: true,
ipWhitelist: true,
},
},
},
},
});
},
});
if (!user || !user.isActive) {
reply.status(401).send({
success: false,
error: "Unauthorized — invalid or expired token",
});
return;
}
const availableOrganizationIds = user.organizationMemberships.map(
(membership) => membership.organizationId,
);
const requestedOrganizationId = extractOrganizationId(req);
if (
requestedOrganizationId &&
!availableOrganizationIds.includes(requestedOrganizationId)
) {
reply.status(403).send({
success: false,
error: "Forbidden — you do not have access to that organization",
});
return;
}
const fallbackOrganizationId =
(user.activeOrganizationId &&
availableOrganizationIds.includes(user.activeOrganizationId)
? user.activeOrganizationId
: null) ??
user.organizationMemberships.find((membership) => membership.isDefault)
?.organizationId ??
availableOrganizationIds[0];
req.userId = user.id;
req.userRole = user.role;
req.organizationId = requestedOrganizationId ?? fallbackOrganizationId;
req.authMethod = "jwt";
if (!enforceUserSecurityPolicies(req, reply, user.settings, payload.iat)) {
return;
}
} catch {
if (!session || session.revokedAt || !session.user.isActive) {
clearSessionCookie(reply);
reply.status(401).send({
success: false,
error: "Unauthorized — invalid or expired token",
error: "Unauthorized — invalid or revoked login session",
});
return;
}
const now = new Date();
const ipAddress = getClientIp(req) || null;
const userAgent = getRequestUserAgent(req);
if (session.idleExpiresAt <= now || session.absoluteExpiresAt <= now) {
await expireUserSession(session.id, ipAddress, userAgent);
clearSessionCookie(reply);
reply.status(401).send({
success: false,
error: "Unauthorized — login session expired",
});
return;
}
if (!enforceBrowserMutationProtection(req, reply)) return;
if (!enforceUserSecurityPolicies(req, reply, session.user.settings)) return;
const availableOrganizationIds = session.user.organizationMemberships.map(
(membership) => membership.organizationId,
);
const requestedOrganizationId = extractOrganizationId(req);
if (
requestedOrganizationId &&
!availableOrganizationIds.includes(requestedOrganizationId)
) {
reply.status(403).send({
success: false,
error: "Forbidden — you do not have access to that organization",
});
return;
}
const fallbackOrganizationId =
(session.user.activeOrganizationId &&
availableOrganizationIds.includes(session.user.activeOrganizationId)
? session.user.activeOrganizationId
: null) ??
session.user.organizationMemberships.find(
(membership) => membership.isDefault,
)?.organizationId ??
availableOrganizationIds[0];
req.userId = session.user.id;
req.userRole = session.user.role;
req.organizationId = requestedOrganizationId ?? fallbackOrganizationId;
req.sessionId = session.id;
req.authMethod = "session";
await touchUserSession({
sessionId: session.id,
lastSeenAt: session.lastSeenAt,
sessionTimeoutMinutes: session.user.settings?.sessionTimeoutMinutes ?? 30,
absoluteExpiresAt: session.absoluteExpiresAt,
ipAddress,
});
}
/**
+296 -27
View File
@@ -1,10 +1,16 @@
import { FastifyInstance } from "fastify";
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { z } from "zod";
import prisma from "../lib/prisma";
import { auditLog } from "../services/audit.service";
import { authenticate } from "../middleware/auth";
import {
authenticate,
getClientIp,
ipMatchesWhitelist,
requireBrowserMutationVerification,
requireRole,
} from "../middleware/auth";
import { dispatchRuntimeNotification } from "../services/notification.service";
import {
createTwoFactorSetup,
@@ -12,6 +18,18 @@ import {
verifyEncryptedTwoFactorToken,
} from "../services/two-factor.service";
import { createOrganizationForUser } from "../lib/organizations";
import {
clearSessionCookie,
canRevokeManagedSession,
changePasswordAndRevokeOtherSessions,
createUserSession,
getManagedSessionStatus,
getRequestUserAgent,
revokeOtherUserSessions,
revokeUserSession,
setSessionCookie,
} from "../services/auth-session.service";
import { SessionEventType } from "@prisma/client";
const LoginSchema = z.object({
email: z.string().email(),
@@ -69,7 +87,6 @@ async function ensureUserSettings(userId: string) {
}
function buildAuthResponse(
app: FastifyInstance,
user: {
id: string;
role: string;
@@ -78,15 +95,7 @@ function buildAuthResponse(
activeOrganizationId?: string | null;
},
) {
const token = app.jwt.sign({
sub: user.id,
role: user.role,
name: user.name,
email: user.email,
});
return {
token,
user: {
id: user.id,
name: user.name,
@@ -97,6 +106,31 @@ function buildAuthResponse(
};
}
async function establishLoginSession(
req: FastifyRequest,
reply: FastifyReply,
user: Parameters<typeof buildAuthResponse>[0],
) {
const settings = await ensureUserSettings(user.id);
const ipAddress = getClientIp(req) || null;
const userAgent = getRequestUserAgent(req);
const created = await createUserSession({
userId: user.id,
sessionTimeoutMinutes: settings.sessionTimeoutMinutes,
ipAddress,
userAgent,
});
setSessionCookie(
reply,
created.rawToken,
created.session.absoluteExpiresAt,
);
return {
...buildAuthResponse(user),
sessionId: created.session.id,
};
}
async function getPublicRegistrationStatus() {
const userCount = await prisma.user.count();
@@ -118,7 +152,11 @@ export async function authRoutes(app: FastifyInstance) {
app.post(
"/login",
{ bodyLimit: authBodyLimit, config: { rateLimit: authRateLimit } },
{
preHandler: [requireBrowserMutationVerification],
bodyLimit: authBodyLimit,
config: { rateLimit: authRateLimit },
},
async (req, reply) => {
const body = LoginSchema.safeParse(req.body);
if (!body.success) {
@@ -135,6 +173,8 @@ export async function authRoutes(app: FastifyInstance) {
select: {
twoFactorEnabled: true,
twoFactorSecretEnc: true,
ipWhitelistEnabled: true,
ipWhitelist: true,
},
},
},
@@ -149,7 +189,11 @@ export async function authRoutes(app: FastifyInstance) {
category: "AUTH",
level: "WARNING",
message: `Failed login attempt for \"${user.email}\"`,
meta: { reason: user.isActive ? "unknown" : "inactive_user" },
meta: {
reason: user.isActive ? "unknown" : "inactive_user",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
},
});
}
return reply
@@ -166,7 +210,11 @@ export async function authRoutes(app: FastifyInstance) {
category: "AUTH",
level: "WARNING",
message: `Failed login attempt for \"${user.email}\"`,
meta: { reason: "invalid_password" },
meta: {
reason: "invalid_password",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
},
});
if (user.activeOrganizationId) {
@@ -217,7 +265,11 @@ export async function authRoutes(app: FastifyInstance) {
category: "AUTH",
level: "WARNING",
message: `Invalid two-factor code for \"${user.email}\"`,
meta: { reason: "invalid_totp" },
meta: {
reason: "invalid_totp",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
},
});
if (user.activeOrganizationId) {
@@ -241,12 +293,35 @@ export async function authRoutes(app: FastifyInstance) {
.send({ success: false, error: "Invalid authentication code" });
}
if (
user.settings?.ipWhitelistEnabled &&
user.settings.ipWhitelist.length > 0 &&
!ipMatchesWhitelist(getClientIp(req), user.settings.ipWhitelist)
) {
await auditLog({
userId: user.id,
organizationId: user.activeOrganizationId ?? undefined,
action: "LOGIN_IP_BLOCKED",
category: "AUTH",
level: "WARNING",
message: `Login blocked by IP policy for "${user.email}"`,
meta: {
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
},
});
return reply.status(403).send({
success: false,
error: "Login is not permitted from this IP address",
});
}
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
const auth = buildAuthResponse(app, user);
const auth = await establishLoginSession(req, reply, user);
await auditLog({
userId: user.id,
@@ -254,16 +329,20 @@ export async function authRoutes(app: FastifyInstance) {
category: "AUTH",
level: "INFO",
message: `User "${user.email}" logged in`,
meta: { twoFactorEnabled },
meta: { twoFactorEnabled, sessionId: auth.sessionId },
});
return reply.send({ success: true, ...auth });
return reply.send({ success: true, user: auth.user });
},
);
app.post(
"/register",
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
{
preHandler: [requireBrowserMutationVerification],
bodyLimit: authBodyLimit,
config: { rateLimit: registerRateLimit },
},
async (req, reply) => {
const body = RegisterSchema.safeParse(req.body);
if (!body.success) {
@@ -314,7 +393,7 @@ export async function authRoutes(app: FastifyInstance) {
});
});
const auth = buildAuthResponse(app, user);
const auth = await establishLoginSession(req, reply, user);
await auditLog({
userId: user.id,
@@ -324,7 +403,7 @@ export async function authRoutes(app: FastifyInstance) {
message: `New user registered: "${email}"`,
});
return reply.status(201).send({ success: true, ...auth });
return reply.status(201).send({ success: true, user: auth.user });
},
);
@@ -396,7 +475,11 @@ export async function authRoutes(app: FastifyInstance) {
app.post(
"/invitations/:token/accept",
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
{
preHandler: [requireBrowserMutationVerification],
bodyLimit: authBodyLimit,
config: { rateLimit: registerRateLimit },
},
async (req, reply) => {
const { token } = req.params as { token: string };
const body = InvitationAcceptSchema.safeParse(req.body);
@@ -504,7 +587,7 @@ export async function authRoutes(app: FastifyInstance) {
return createdUser;
});
const auth = buildAuthResponse(app, user);
const auth = await establishLoginSession(req, reply, user);
await auditLog({
userId: user.id,
@@ -515,7 +598,7 @@ export async function authRoutes(app: FastifyInstance) {
message: `Invitation accepted for "${user.email}"`,
});
return reply.send({ success: true, ...auth });
return reply.send({ success: true, user: auth.user });
},
);
@@ -541,6 +624,17 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post("/logout", { preHandler: [authenticate] }, async (req, reply) => {
if (req.sessionId && req.userId) {
await revokeUserSession({
sessionId: req.sessionId,
userId: req.userId,
reason: "USER_LOGOUT",
eventType: SessionEventType.LOGOUT,
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
}
clearSessionCookie(reply);
await auditLog({
userId: req.userId,
action: "LOGOUT",
@@ -551,6 +645,171 @@ export async function authRoutes(app: FastifyInstance) {
return reply.send({ success: true, message: "Logged out successfully" });
});
app.get("/sessions", { preHandler: [requireRole("VIEWER")] }, async (req, reply) => {
if (!req.sessionId) {
return reply.status(403).send({
success: false,
error: "A browser login session is required",
});
}
const now = new Date();
const sessions = await prisma.userSession.findMany({
where: req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! },
orderBy: { createdAt: "desc" },
take: 200,
select: {
id: true,
createdIp: true,
lastIp: true,
userAgent: true,
createdAt: true,
lastSeenAt: true,
idleExpiresAt: true,
absoluteExpiresAt: true,
revokedAt: true,
revokeReason: true,
user: {
select: {
id: true,
name: true,
email: true,
role: true,
},
},
},
});
return reply.send({
success: true,
data: sessions.map((session) => {
const status = getManagedSessionStatus(session, now);
return {
...session,
status,
current: session.id === req.sessionId,
canRevoke:
status === "active" &&
canRevokeManagedSession({
actorUserId: req.userId!,
actorRole: req.userRole!,
targetUserId: session.user.id,
currentSessionId: req.sessionId!,
targetSessionId: session.id,
}),
};
}),
});
});
app.delete(
"/sessions/:sessionId",
{ preHandler: [requireRole("VIEWER")] },
async (req, reply) => {
if (!req.sessionId) {
return reply.status(403).send({
success: false,
error: "A browser login session is required",
});
}
const params = z
.object({ sessionId: z.string().trim().min(1).max(64) })
.safeParse(req.params);
if (!params.success) {
return reply
.status(400)
.send({ success: false, error: params.error.flatten() });
}
if (params.data.sessionId === req.sessionId) {
return reply.status(400).send({
success: false,
error: "The current session cannot be revoked; use logout instead",
});
}
const targetSession = await prisma.userSession.findFirst({
where: {
id: params.data.sessionId,
...(req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! }),
},
select: {
userId: true,
revokedAt: true,
revokeReason: true,
idleExpiresAt: true,
absoluteExpiresAt: true,
user: { select: { email: true } },
},
});
if (!targetSession) {
return reply
.status(404)
.send({ success: false, error: "Session not found" });
}
if (getManagedSessionStatus(targetSession) !== "active") {
return reply
.status(404)
.send({ success: false, error: "Active session not found" });
}
const revoked = await revokeUserSession({
sessionId: params.data.sessionId,
userId: targetSession.userId,
reason: "USER_REVOKED",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
if (!revoked) {
return reply
.status(404)
.send({ success: false, error: "Active session not found" });
}
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "SESSION_REVOKE",
category: "AUTH",
level: "WARNING",
message: `Login session revoked for "${targetSession.user.email}"`,
meta: {
sessionId: params.data.sessionId,
targetUserId: targetSession.userId,
},
});
return reply.send({ success: true, data: { revokedCurrent: false } });
},
);
app.post(
"/sessions/revoke-others",
{ preHandler: [authenticate] },
async (req, reply) => {
if (!req.sessionId) {
return reply.status(400).send({
success: false,
error: "A browser login session is required",
});
}
const revokedCount = await revokeOtherUserSessions({
userId: req.userId!,
currentSessionId: req.sessionId,
reason: "USER_REVOKED_OTHER_SESSIONS",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "SESSION_REVOKE_OTHERS",
category: "AUTH",
level: "WARNING",
message: `${revokedCount} other login session${revokedCount === 1 ? "" : "s"} revoked`,
meta: { revokedCount },
});
return reply.send({ success: true, data: { revokedCount } });
},
);
app.patch(
"/password",
{
@@ -586,9 +845,18 @@ export async function authRoutes(app: FastifyInstance) {
}
const passwordHash = await bcrypt.hash(body.data.newPassword, 12);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash },
if (!req.sessionId) {
return reply.status(403).send({
success: false,
error: "A browser login session is required to change a password",
});
}
const revokedCount = await changePasswordAndRevokeOtherSessions({
userId: user.id,
currentSessionId: req.sessionId,
passwordHash,
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
await auditLog({
@@ -597,6 +865,7 @@ export async function authRoutes(app: FastifyInstance) {
category: "AUTH",
level: "INFO",
message: "Password changed",
meta: { revokedSessionCount: revokedCount },
});
return reply.send({
+6
View File
@@ -1590,9 +1590,15 @@ function writeProcessJobStreamEvent(
function buildInternalJobHeaders(headers: Record<string, unknown>) {
const nextHeaders: Record<string, string> = {
"content-type": "application/json",
"x-doktainer-request": "1",
"x-process-job-sync": "true",
};
const cookie = headers.cookie;
if (typeof cookie === "string") {
nextHeaders.cookie = cookie;
}
const authorization = headers.authorization;
if (typeof authorization === "string") {
nextHeaders.authorization = authorization;
+62 -42
View File
@@ -7,6 +7,11 @@ import { promisify } from "node:util";
import { pipeline } from "node:stream/promises";
import { z } from "zod";
import { resolvePublicAppOrigin } from "../lib/public-url";
import {
ATOMIC_PSQL_RESTORE_ARGS,
createRestoreSqlSanitizer,
CUSTOM_RESTORE_SCRIPT_ARGS,
} from "../lib/database-restore";
import prisma from "../lib/prisma";
import { decrypt, encrypt } from "../lib/crypto";
import { authenticate, requireRole } from "../middleware/auth";
@@ -714,7 +719,7 @@ export async function settingsRoutes(app: FastifyInstance) {
options: {
inputPath?: string;
outputPath?: string;
allowTransactionTimeoutCompatibilityError?: boolean;
connectToDatabase?: boolean;
} = {},
) => {
const databaseUrl = process.env.DATABASE_URL?.trim();
@@ -771,26 +776,7 @@ export async function settingsRoutes(app: FastifyInstance) {
? rejectedResult.reason.message
: "";
const onlyTransactionTimeoutCompatibilityError =
options.allowTransactionTimeoutCompatibilityError &&
stderr.includes(
'unrecognized configuration parameter "transaction_timeout"',
) &&
stderr
.replace(
/pg_restore: error: could not execute query: ERROR:\s+unrecognized configuration parameter "transaction_timeout"[\r\n]+Command was:\s*SET transaction_timeout = 0;\s*/g,
"",
)
.replace(
/pg_restore: warning: errors ignored on restore: 1\s*/g,
"",
)
.trim().length === 0;
if (
!rejectedResult &&
(exitCode === 0 || onlyTransactionTimeoutCompatibilityError)
) {
if (!rejectedResult && exitCode === 0) {
resolve();
return;
}
@@ -806,20 +792,29 @@ export async function settingsRoutes(app: FastifyInstance) {
const databaseContainer =
process.env.DATABASE_BACKUP_CONTAINER?.trim() ||
`${process.env.COMPOSE_PROJECT_NAME?.trim() || "doktainer"}-postgres`;
const databaseArgs =
options.connectToDatabase === false
? []
: ["--dbname", databaseCliUrl];
const dockerArgs = [
"exec",
"-i",
databaseContainer,
command,
"--dbname",
databaseCliUrl,
...databaseArgs,
...args,
];
const localBinary = await findLocalPostgresBinary(command);
const runLocal = async () => {
if (!localBinary) return false;
const localArgs = ["--dbname", databaseCliUrl, ...args];
if (options.inputPath) localArgs.push(options.inputPath);
const localArgs = [...databaseArgs, ...args];
if (options.inputPath) {
if (command === "psql") {
localArgs.push("--file", options.inputPath);
} else {
localArgs.push(options.inputPath);
}
}
await run(
localBinary,
localArgs,
@@ -898,6 +893,8 @@ export async function settingsRoutes(app: FastifyInstance) {
.status(400)
.send({ success: false, error: "Select a PostgreSQL dump file" });
const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`);
const restoredSqlPath = `${filePath}.sql`;
const sanitizedSqlPath = `${filePath}.sanitized.sql`;
try {
await pipeline(upload.file, createWriteStream(filePath));
const header = Buffer.alloc(5);
@@ -909,24 +906,43 @@ export async function settingsRoutes(app: FastifyInstance) {
}
const isCustomArchive = header.toString("ascii") === "PGDMP";
if (isCustomArchive) {
await runDatabaseCommand(
"pg_restore",
[...CUSTOM_RESTORE_SCRIPT_ARGS],
{
inputPath: filePath,
outputPath: restoredSqlPath,
connectToDatabase: false,
},
);
await pipeline(
createReadStream(restoredSqlPath),
createRestoreSqlSanitizer(),
createWriteStream(sanitizedSqlPath),
);
}
await runDatabaseCommand(
isCustomArchive ? "pg_restore" : "psql",
isCustomArchive
? ["--clean", "--if-exists", "--no-owner"]
: ["--set", "ON_ERROR_STOP=1"],
{
inputPath: filePath,
allowTransactionTimeoutCompatibilityError: isCustomArchive,
},
"psql",
[...ATOMIC_PSQL_RESTORE_ARGS],
{ inputPath: isCustomArchive ? sanitizedSqlPath : filePath },
);
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "DATABASE_RESTORE",
category: "SYSTEM",
level: "WARNING",
message: "Doktainer database restored from uploaded dump",
});
try {
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "DATABASE_RESTORE",
category: "SYSTEM",
level: "WARNING",
message: "Doktainer database restored from uploaded dump",
});
} catch (error) {
app.log.warn(
{ err: error },
"Database restore completed, but its audit event could not be recorded",
);
}
return reply.send({
success: true,
message: "Database restore completed",
@@ -942,7 +958,11 @@ export async function settingsRoutes(app: FastifyInstance) {
: "Database restore failed",
});
} finally {
await fs.rm(filePath, { force: true });
await Promise.all([
fs.rm(filePath, { force: true }),
fs.rm(restoredSqlPath, { force: true }),
fs.rm(sanitizedSqlPath, { force: true }),
]);
}
},
);
@@ -0,0 +1,70 @@
import prisma from "../lib/prisma";
export const SESSION_HISTORY_RETENTION_DAYS = 180;
export const SESSION_RETENTION_BATCH_SIZE = 1000;
const SESSION_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
let cleanupInProgress = false;
export function getSessionHistoryCutoff(now = new Date()): Date {
return new Date(
now.getTime() - SESSION_HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
}
export function getSessionHistoryRetentionFilter(now = new Date()) {
const cutoff = getSessionHistoryCutoff(now);
return {
OR: [
{ revokedAt: { lte: cutoff } },
{
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: cutoff } },
{ absoluteExpiresAt: { lte: cutoff } },
],
},
],
};
}
export async function runAuthSessionRetentionCleanup(now = new Date()) {
if (cleanupInProgress) return { skipped: true, deletedSessions: 0 };
cleanupInProgress = true;
try {
const sessions = await prisma.userSession.findMany({
where: getSessionHistoryRetentionFilter(now),
orderBy: { createdAt: "asc" },
take: SESSION_RETENTION_BATCH_SIZE,
select: { id: true },
});
if (sessions.length === 0) {
return { skipped: false, deletedSessions: 0 };
}
const deleted = await prisma.userSession.deleteMany({
where: { id: { in: sessions.map((session) => session.id) } },
});
return { skipped: false, deletedSessions: deleted.count };
} finally {
cleanupInProgress = false;
}
}
export function startAuthSessionRetentionScheduler() {
const runCleanup = async () => {
try {
await runAuthSessionRetentionCleanup();
} catch (error) {
console.error("[Session retention] Cleanup failed:", error);
}
};
const timer = setInterval(() => void runCleanup(), SESSION_RETENTION_INTERVAL_MS);
timer.unref?.();
void runCleanup();
return () => clearInterval(timer);
}
+319
View File
@@ -0,0 +1,319 @@
import crypto from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
import { SessionEventType } from "@prisma/client";
import prisma from "../lib/prisma";
const DEVELOPMENT_SESSION_COOKIE = "doktainer_session";
const PRODUCTION_SESSION_COOKIE = "__Host-doktainer_session";
const DEFAULT_ABSOLUTE_SESSION_HOURS = 7 * 24;
const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const MAX_USER_AGENT_LENGTH = 512;
const MAX_ACTIVE_SESSIONS_PER_USER = 20;
export function canRevokeManagedSession(input: {
actorUserId: string;
actorRole: string;
targetUserId: string;
currentSessionId: string;
targetSessionId: string;
}): boolean {
if (input.currentSessionId === input.targetSessionId) return false;
return (
input.actorUserId === input.targetUserId ||
input.actorRole === "SUPER_ADMIN"
);
}
export type ManagedSessionStatus = "active" | "expired" | "revoked";
export function getManagedSessionStatus(
session: {
revokedAt: Date | null;
revokeReason: string | null;
idleExpiresAt: Date;
absoluteExpiresAt: Date;
},
now = new Date(),
): ManagedSessionStatus {
if (session.revokedAt) {
return session.revokeReason === "EXPIRED" ? "expired" : "revoked";
}
return session.idleExpiresAt <= now || session.absoluteExpiresAt <= now
? "expired"
: "active";
}
export function getSessionCookieName(env = process.env): string {
return env.NODE_ENV === "production"
? PRODUCTION_SESSION_COOKIE
: DEVELOPMENT_SESSION_COOKIE;
}
function getAbsoluteSessionHours(env = process.env): number {
const parsed = Number.parseInt(env.SESSION_ABSOLUTE_TIMEOUT_HOURS ?? "", 10);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_ABSOLUTE_SESSION_HOURS;
}
export function hashSessionToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function normalizeSessionUserAgent(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim();
return normalized ? normalized.slice(0, MAX_USER_AGENT_LENGTH) : null;
}
export function getRequestUserAgent(req: FastifyRequest): string | null {
return normalizeSessionUserAgent(req.headers["user-agent"]);
}
function sessionCookieOptions(expires?: Date) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
priority: "high" as const,
...(expires ? { expires } : {}),
};
}
export function setSessionCookie(
reply: FastifyReply,
token: string,
expiresAt: Date,
): void {
reply.setCookie(getSessionCookieName(), token, sessionCookieOptions(expiresAt));
}
export function clearSessionCookie(reply: FastifyReply): void {
for (const cookieName of [
DEVELOPMENT_SESSION_COOKIE,
PRODUCTION_SESSION_COOKIE,
]) {
reply.clearCookie(cookieName, sessionCookieOptions());
}
}
export function getSessionToken(req: FastifyRequest): string | null {
return req.cookies?.[getSessionCookieName()]?.trim() || null;
}
export async function createUserSession(input: {
userId: string;
sessionTimeoutMinutes: number;
ipAddress: string | null;
userAgent: string | null;
}) {
const now = new Date();
const absoluteExpiresAt = new Date(
now.getTime() + getAbsoluteSessionHours() * 60 * 60 * 1000,
);
const idleExpiresAt =
input.sessionTimeoutMinutes > 0
? new Date(now.getTime() + input.sessionTimeoutMinutes * 60 * 1000)
: absoluteExpiresAt;
const rawToken = crypto.randomBytes(32).toString("base64url");
const session = await prisma.userSession.create({
data: {
userId: input.userId,
tokenHash: hashSessionToken(rawToken),
createdIp: input.ipAddress,
lastIp: input.ipAddress,
userAgent: input.userAgent,
idleExpiresAt,
absoluteExpiresAt,
events: {
create: {
type: SessionEventType.LOGIN,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
},
},
},
select: {
id: true,
absoluteExpiresAt: true,
},
});
const overflowSessions = await prisma.userSession.findMany({
where: {
userId: input.userId,
revokedAt: null,
idleExpiresAt: { gt: now },
absoluteExpiresAt: { gt: now },
},
orderBy: { createdAt: "desc" },
skip: MAX_ACTIVE_SESSIONS_PER_USER,
select: { id: true },
});
await Promise.all(
overflowSessions.map((overflowSession) =>
revokeUserSession({
sessionId: overflowSession.id,
userId: input.userId,
reason: "ACTIVE_SESSION_LIMIT",
ipAddress: input.ipAddress,
userAgent: input.userAgent,
}),
),
);
return { rawToken, session };
}
export async function expireUserSession(
sessionId: string,
ipAddress: string | null,
userAgent: string | null,
): Promise<void> {
await prisma.$transaction(async (tx) => {
const now = new Date();
const updated = await tx.userSession.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: now, revokeReason: "EXPIRED" },
});
if (updated.count > 0) {
await tx.userSessionEvent.create({
data: {
sessionId,
type: SessionEventType.EXPIRED,
ipAddress,
userAgent,
},
});
}
});
}
export async function revokeUserSession(input: {
sessionId: string;
userId: string;
reason: string;
eventType?: SessionEventType;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const now = new Date();
const updated = await tx.userSession.updateMany({
where: { id: input.sessionId, userId: input.userId, revokedAt: null },
data: { revokedAt: now, revokeReason: input.reason },
});
if (updated.count === 0) return false;
await tx.userSessionEvent.create({
data: {
sessionId: input.sessionId,
type: input.eventType ?? SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: input.reason },
},
});
return true;
});
}
export async function revokeOtherUserSessions(input: {
userId: string;
currentSessionId: string;
reason: string;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<number> {
return prisma.$transaction(async (tx) => {
const now = new Date();
const sessions = await tx.userSession.updateManyAndReturn({
where: {
userId: input.userId,
id: { not: input.currentSessionId },
revokedAt: null,
},
data: { revokedAt: now, revokeReason: input.reason },
select: { id: true },
});
if (sessions.length === 0) return 0;
await tx.userSessionEvent.createMany({
data: sessions.map((session) => ({
sessionId: session.id,
type: SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: input.reason },
})),
});
return sessions.length;
});
}
export async function changePasswordAndRevokeOtherSessions(input: {
userId: string;
currentSessionId: string;
passwordHash: string;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<number> {
return prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: input.userId },
data: { passwordHash: input.passwordHash },
});
const now = new Date();
const sessions = await tx.userSession.updateManyAndReturn({
where: {
userId: input.userId,
id: { not: input.currentSessionId },
revokedAt: null,
},
data: { revokedAt: now, revokeReason: "PASSWORD_CHANGED" },
select: { id: true },
});
if (sessions.length > 0) {
await tx.userSessionEvent.createMany({
data: sessions.map((session) => ({
sessionId: session.id,
type: SessionEventType.REVOKED,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
details: { reason: "PASSWORD_CHANGED" },
})),
});
}
return sessions.length;
});
}
export async function touchUserSession(input: {
sessionId: string;
lastSeenAt: Date;
sessionTimeoutMinutes: number;
absoluteExpiresAt: Date;
ipAddress: string | null;
}): Promise<void> {
const now = new Date();
if (now.getTime() - input.lastSeenAt.getTime() < SESSION_TOUCH_INTERVAL_MS) {
return;
}
await prisma.userSession.updateMany({
where: { id: input.sessionId, revokedAt: null },
data: {
lastSeenAt: now,
lastIp: input.ipAddress,
idleExpiresAt:
input.sessionTimeoutMinutes > 0
? new Date(
now.getTime() + input.sessionTimeoutMinutes * 60 * 1000,
)
: input.absoluteExpiresAt,
},
});
}
+70 -8
View File
@@ -1,4 +1,5 @@
import {
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
S3Client,
@@ -99,19 +100,81 @@ export async function deleteS3Objects(
if (keys.length === 0) return;
const client = createS3Client(destination);
await deleteS3ObjectsWithClient(client, destination.bucket, keys);
}
type S3DeleteClient = Pick<S3Client, "send">;
export function isMissingContentMd5Error(error: unknown) {
if (!error || typeof error !== "object") return false;
const details = error as {
Code?: unknown;
code?: unknown;
name?: unknown;
message?: unknown;
};
const code = String(details.Code ?? details.code ?? details.name ?? "");
const message = String(details.message ?? "");
return (
(code === "InvalidRequest" || code === "MissingContentMD5") &&
/content-?md5/i.test(message)
);
}
async function deleteS3ObjectsIndividually(
client: S3DeleteClient,
bucket: string,
keys: string[],
) {
const failedKeys: string[] = [];
for (const key of keys) {
try {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
} catch {
failedKeys.push(key);
}
}
if (failedKeys.length > 0) {
throw new Error(
`Failed to delete ${failedKeys.length} S3 object(s): ${failedKeys.join(", ")}`,
);
}
}
export async function deleteS3ObjectsWithClient(
client: S3DeleteClient,
bucket: string,
keys: string[],
) {
for (let offset = 0; offset < keys.length; offset += 1000) {
const identifiers: ObjectIdentifier[] = keys
.slice(offset, offset + 1000)
.map((key) => ({ Key: key }));
const response = await client.send(
new DeleteObjectsCommand({
Bucket: destination.bucket,
Delete: { Objects: identifiers, Quiet: true },
}),
);
let response;
try {
response = await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: identifiers, Quiet: true },
}),
);
} catch (error) {
if (!isMissingContentMd5Error(error)) throw error;
if (response.Errors && response.Errors.length > 0) {
await deleteS3ObjectsIndividually(
client,
bucket,
identifiers.map(({ Key }) => Key).filter((key): key is string => Boolean(key)),
);
continue;
}
if ("Errors" in response && response.Errors && response.Errors.length > 0) {
const failedKeys = response.Errors.map((error) => error.Key).filter(
(key): key is string => Boolean(key),
);
@@ -121,4 +184,3 @@ export async function deleteS3Objects(
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveApiExampleUrl } from "../src/lib/api-example-url";
test("uses the Panel Access URL stored in settings", () => {
assert.equal(
resolveApiExampleUrl({
panelUrl: "https://panel.example.com/",
browserOrigin: "http://192.168.1.20:3000",
apiBaseUrl: "http://localhost:4000/api/v1",
}),
"https://panel.example.com/api/v1/servers",
);
});
test("uses the active browser origin when the stored panel URL is a stale loopback default", () => {
assert.equal(
resolveApiExampleUrl({
panelUrl: "http://localhost:3000",
browserOrigin: "http://192.168.1.20:3000",
apiBaseUrl: "http://localhost:4000/api/v1",
}),
"http://192.168.1.20:3000/api/v1/servers",
);
});
test("keeps the stored loopback URL during local access", () => {
assert.equal(
resolveApiExampleUrl({
panelUrl: "http://localhost:3000",
browserOrigin: "http://127.0.0.1:3000",
apiBaseUrl: "http://localhost:4000/api/v1",
}),
"http://localhost:3000/api/v1/servers",
);
});
test("falls back to the configured API base when no public panel origin is available", () => {
assert.equal(
resolveApiExampleUrl({
panelUrl: "not-a-url",
browserOrigin: null,
apiBaseUrl: "https://api.example.com/api/v1/",
}),
"https://api.example.com/api/v1/servers",
);
});
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getSessionHistoryCutoff,
getSessionHistoryRetentionFilter,
SESSION_HISTORY_RETENTION_DAYS,
SESSION_RETENTION_BATCH_SIZE,
} from "../../src/server/services/auth-session-retention.service";
test("session history retention keeps 180 days and deletes in bounded batches", () => {
assert.equal(SESSION_HISTORY_RETENTION_DAYS, 180);
assert.equal(SESSION_RETENTION_BATCH_SIZE, 1000);
assert.equal(
getSessionHistoryCutoff(new Date("2026-09-12T00:00:00.000Z")).toISOString(),
"2026-03-16T00:00:00.000Z",
);
assert.deepEqual(
getSessionHistoryRetentionFilter(new Date("2026-09-12T00:00:00.000Z")),
{
OR: [
{ revokedAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
{
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
{ absoluteExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
],
},
],
},
);
});
@@ -28,6 +28,16 @@ test("redeploy endpoints require container write authorization", () => {
assert.match(routes.slice(previewStart, previewStart + 180), /containerReadAccess/);
});
test("internal deployment jobs retain browser session authentication", () => {
const helperStart = routes.indexOf("function buildInternalJobHeaders");
assert.ok(helperStart >= 0, "internal job header builder should exist");
const helper = routes.slice(helperStart, helperStart + 900);
assert.match(helper, /"x-doktainer-request": "1"/);
assert.match(helper, /const cookie = headers\.cookie/);
assert.match(helper, /nextHeaders\.cookie = cookie/);
});
test("redeploy restores the stored current revision without invoking source rebuild", () => {
assert.match(redeployService, /redeployContainerCurrentRevision/);
assert.match(redeployService, /getRollbackSnapshot\(input\)/);
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { Readable } from "node:stream";
import test from "node:test";
import {
ATOMIC_PSQL_RESTORE_ARGS,
createRestoreSqlSanitizer,
CUSTOM_RESTORE_SCRIPT_ARGS,
isUnsupportedRestoreSetting,
} from "../../src/server/lib/database-restore";
async function sanitizeChunks(chunks: string[]) {
const output: Buffer[] = [];
const sanitizer = createRestoreSqlSanitizer();
sanitizer.on("data", (chunk: Buffer) => output.push(chunk));
await new Promise<void>((resolve, reject) => {
Readable.from(chunks).pipe(sanitizer).once("finish", resolve).once("error", reject);
});
return Buffer.concat(output).toString("utf8");
}
test("database restores execute atomically and stop on SQL errors", () => {
assert.deepEqual(ATOMIC_PSQL_RESTORE_ARGS, [
"--single-transaction",
"--set",
"ON_ERROR_STOP=1",
]);
assert.deepEqual(CUSTOM_RESTORE_SCRIPT_ARGS, [
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
"--file=-",
]);
});
test("restore sanitizer removes only the unsupported transaction timeout setting", async () => {
assert.equal(isUnsupportedRestoreSetting("SET transaction_timeout = 0;"), true);
assert.equal(isUnsupportedRestoreSetting("SET statement_timeout = 0;"), false);
assert.equal(
await sanitizeChunks([
"SET statement_timeout = 0;\nSET trans",
"action_timeout = 0;\nCREATE TABLE example (id text);\n",
]),
"SET statement_timeout = 0;\nCREATE TABLE example (id text);\n",
);
});
test("container startup validates id invariants before any pending migration", () => {
const entrypoint = readFileSync("docker-entrypoint.sh", "utf8");
assert.doesNotMatch(entrypoint, /20260909_add_database_sessions/);
assert.match(entrypoint, /pendingMigrationNames\.length > 0/);
assert.match(entrypoint, /table_class\.relname <> '_prisma_migrations'/);
assert.match(entrypoint, /i\.indisunique/);
assert.match(entrypoint, /attribute\.attnotnull/);
assert.match(entrypoint, /Database integrity check failed/);
assert.match(entrypoint, /resolve duplicate IDs before applying migrations/);
});
@@ -0,0 +1,279 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
enforceBrowserMutationProtection,
} from "../../src/server/middleware/auth";
import {
canRevokeManagedSession,
getManagedSessionStatus,
getSessionCookieName,
hashSessionToken,
normalizeSessionUserAgent,
} from "../../src/server/services/auth-session.service";
function createReplyRecorder() {
return {
statusCode: 200,
payload: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
send(payload: unknown) {
this.payload = payload;
return this;
},
};
}
test("session tokens are stored as deterministic hashes", () => {
const rawToken = "raw-session-token";
const tokenHash = hashSessionToken(rawToken);
assert.equal(tokenHash.length, 64);
assert.equal(tokenHash, hashSessionToken(rawToken));
assert.notEqual(tokenHash, rawToken);
});
test("production uses a host-bound session cookie name", () => {
assert.equal(
getSessionCookieName({ NODE_ENV: "production" } as NodeJS.ProcessEnv),
"__Host-doktainer_session",
);
assert.equal(
getSessionCookieName({ NODE_ENV: "development" } as NodeJS.ProcessEnv),
"doktainer_session",
);
});
test("session user agents drop control characters and remain bounded", () => {
const normalized = normalizeSessionUserAgent(`Browser\n${"x".repeat(600)}`);
assert.equal(normalized?.includes("\n"), false);
assert.equal(normalized?.length, 512);
});
test("browser mutation protection requires the application header", () => {
const reply = createReplyRecorder();
const allowed = enforceBrowserMutationProtection(
{ method: "POST", headers: {} } as never,
reply as never,
);
assert.equal(allowed, false);
assert.equal(reply.statusCode, 403);
});
test("browser mutation protection rejects cross-site requests", () => {
const reply = createReplyRecorder();
const allowed = enforceBrowserMutationProtection(
{
method: "DELETE",
headers: {
"x-doktainer-request": "1",
"sec-fetch-site": "cross-site",
},
} as never,
reply as never,
);
assert.equal(allowed, false);
assert.equal(reply.statusCode, 403);
});
test("safe methods and verified same-origin mutations are accepted", () => {
assert.equal(
enforceBrowserMutationProtection(
{ method: "GET", headers: {} } as never,
createReplyRecorder() as never,
),
true,
);
assert.equal(
enforceBrowserMutationProtection(
{
method: "PATCH",
headers: {
"x-doktainer-request": "1",
"sec-fetch-site": "same-origin",
origin: "http://localhost:3000",
},
} as never,
createReplyRecorder() as never,
),
true,
);
});
test("browser-confirmed same-origin mutations use the active origin without env configuration", () => {
const allowed = enforceBrowserMutationProtection(
{
method: "POST",
protocol: "http",
headers: {
host: "127.0.0.1:4000",
origin: "https://panel.example.com",
"sec-fetch-site": "same-origin",
"x-doktainer-request": "1",
},
} as never,
createReplyRecorder() as never,
{} as NodeJS.ProcessEnv,
);
assert.equal(allowed, true);
});
test("verified mutations accept the effective custom-domain request origin", () => {
const allowed = enforceBrowserMutationProtection(
{
method: "POST",
protocol: "https",
headers: {
host: "panel.example.com",
origin: "https://panel.example.com",
"sec-fetch-site": "same-site",
"x-doktainer-request": "1",
},
} as never,
createReplyRecorder() as never,
{
FRONTEND_URL: "http://localhost:3000",
TRUST_PROXY: "false",
} as NodeJS.ProcessEnv,
);
assert.equal(allowed, true);
});
test("forwarded custom-domain origin is trusted only behind an enabled proxy", () => {
const request = {
method: "POST",
protocol: "http",
headers: {
host: "127.0.0.1:4000",
origin: "https://panel.example.com",
"sec-fetch-site": "same-site",
"x-doktainer-request": "1",
"x-forwarded-host": "panel.example.com",
"x-forwarded-proto": "https",
},
} as never;
assert.equal(
enforceBrowserMutationProtection(
request,
createReplyRecorder() as never,
{ TRUST_PROXY: "true" } as NodeJS.ProcessEnv,
),
true,
);
const untrustedReply = createReplyRecorder();
assert.equal(
enforceBrowserMutationProtection(
request,
untrustedReply as never,
{ TRUST_PROXY: "false" } as NodeJS.ProcessEnv,
),
false,
);
assert.equal(untrustedReply.statusCode, 403);
});
test("only owners and super admins can revoke a managed login session", () => {
assert.equal(
canRevokeManagedSession({
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "user-2",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
false,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "operator-1",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
true,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "admin-1",
actorRole: "SUPER_ADMIN",
targetUserId: "user-2",
currentSessionId: "session-1",
targetSessionId: "session-2",
}),
true,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "admin-1",
actorRole: "SUPER_ADMIN",
targetUserId: "admin-1",
currentSessionId: "session-1",
targetSessionId: "session-1",
}),
false,
);
});
test("session status is derived without persisting expiry from list requests", () => {
const now = new Date("2026-09-12T12:00:00.000Z");
const activeSession = {
revokedAt: null,
revokeReason: null,
idleExpiresAt: new Date("2026-09-12T13:00:00.000Z"),
absoluteExpiresAt: new Date("2026-09-13T12:00:00.000Z"),
};
assert.equal(getManagedSessionStatus(activeSession, now), "active");
assert.equal(
getManagedSessionStatus(
{ ...activeSession, idleExpiresAt: new Date("2026-09-12T11:59:59.000Z") },
now,
),
"expired",
);
assert.equal(
getManagedSessionStatus(
{
...activeSession,
revokedAt: new Date("2026-09-12T10:00:00.000Z"),
revokeReason: "USER_REVOKED",
},
now,
),
"revoked",
);
});
test("session management is a dedicated protected page before Users and RBAC", () => {
const navigation = readFileSync("src/lib/navigation.ts", "utf8");
const permissions = readFileSync("src/lib/permissions.ts", "utf8");
const dashboard = readFileSync("src/app/page.tsx", "utf8");
const page = readFileSync("src/app/sessions/page.tsx", "utf8");
const routes = readFileSync("src/server/routes/auth.ts", "utf8");
assert.ok(navigation.indexOf('href: "/sessions"') < navigation.indexOf('href: "/users"'));
assert.match(permissions, /"\/sessions": "VIEWER"/);
assert.doesNotMatch(dashboard, /LoginSessionsPanel/);
assert.match(page, /route="\/sessions"/);
assert.match(page, /All statuses/);
assert.match(page, /All users/);
assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("VIEWER"\)\]/);
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("VIEWER"\)\] \}/);
assert.match(routes, /A browser login session is required/);
assert.doesNotMatch(routes, /expiredSessions/);
assert.doesNotMatch(routes, /events:\s*\{/);
assert.match(routes, /The current session cannot be revoked/);
assert.match(page, /session\.current \? null/);
});
+3 -3
View File
@@ -25,7 +25,7 @@ test("viewer role is blocked from write permissions", () => {
const allowed = enforceUserRolePermissions(
{
authMethod: "jwt",
authMethod: "session",
userRole: "VIEWER",
} as never,
reply as never,
@@ -45,7 +45,7 @@ test("viewer role keeps read permissions", () => {
const allowed = enforceUserRolePermissions(
{
authMethod: "jwt",
authMethod: "session",
userRole: "VIEWER",
} as never,
reply as never,
@@ -61,7 +61,7 @@ test("developer role keeps write permissions", () => {
const allowed = enforceUserRolePermissions(
{
authMethod: "jwt",
authMethod: "session",
userRole: "DEVELOPER",
} as never,
reply as never,
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DeleteObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
import { getExpiredObjectKeys } from "../../src/server/services/s3-storage-retention.service";
import {
deleteS3ObjectsWithClient,
isMissingContentMd5Error,
} from "../../src/server/services/s3-storage.service";
const now = Date.parse("2026-07-15T00:00:00.000Z");
@@ -70,3 +75,45 @@ test("maximum backup count keeps the newest objects", () => {
assert.deepEqual(keys, ["oldest.tar.gz"]);
});
test("S3 deletion falls back to individual requests when batch MD5 is rejected", async () => {
const commands: unknown[] = [];
const client = {
async send(command: unknown) {
commands.push(command);
if (command instanceof DeleteObjectsCommand) {
throw Object.assign(new Error("Missing required header for this request: Content-MD5"), {
Code: "InvalidRequest",
});
}
return {};
},
};
await deleteS3ObjectsWithClient(
client as never,
"bucket",
["backups/one.tar.gz", "backups/two.tar.gz"],
);
assert.equal(commands.length, 3);
assert.ok(commands[0] instanceof DeleteObjectsCommand);
assert.ok(commands[1] instanceof DeleteObjectCommand);
assert.ok(commands[2] instanceof DeleteObjectCommand);
});
test("S3 deletion does not hide unrelated batch failures", async () => {
const failure = Object.assign(new Error("Access denied"), {
Code: "AccessDenied",
});
const client = {
async send() {
throw failure;
},
};
assert.equal(isMissingContentMd5Error(failure), false);
await assert.rejects(
deleteS3ObjectsWithClient(client as never, "bucket", ["backup.tar.gz"]),
failure,
);
});
+34 -31
View File
@@ -2,12 +2,15 @@ import assert from "node:assert/strict";
import test, { afterEach, beforeEach } from "node:test";
import {
getSensitiveStorageItem,
removeSensitiveStorageItem,
clearLegacyAuthStorage,
sensitiveStorageKeys,
setSensitiveStorageItem,
} from "../src/lib/browser-storage.ts";
import { auth, getToken } from "../src/lib/api.ts";
import {
auth,
getUser,
setUser,
} from "../src/lib/api.ts";
import {
consumeGithubManifestState,
encodeGithubManifestState,
@@ -141,38 +144,33 @@ afterEach(() => {
globalThis.window = originalWindow;
});
test("reads sensitive token storage from localStorage", () => {
window.localStorage.setItem(sensitiveStorageKeys.token, "legacy-token");
test("removes legacy auth credentials without deleting organization preference", () => {
window.localStorage.setItem("doktainer_token", "legacy-token");
window.localStorage.setItem("doktainer_user", '{"id":"legacy"}');
setSensitiveStorageItem(sensitiveStorageKeys.organization, "org-1");
clearLegacyAuthStorage();
assert.equal(window.localStorage.getItem("doktainer_token"), null);
assert.equal(window.localStorage.getItem("doktainer_user"), null);
assert.equal(
getSensitiveStorageItem(sensitiveStorageKeys.token),
"legacy-token",
window.localStorage.getItem(sensitiveStorageKeys.organization),
"org-1",
);
assert.equal(
window.localStorage.getItem(sensitiveStorageKeys.token),
"legacy-token",
);
});
test("writes and removes sensitive values in localStorage", () => {
setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"new"}');
assert.equal(
window.localStorage.getItem(sensitiveStorageKeys.user),
'{"id":"new"}',
);
removeSensitiveStorageItem(sensitiveStorageKeys.user);
assert.equal(window.localStorage.getItem(sensitiveStorageKeys.user), null);
});
test("clears stored session and redirects to login on authenticated 401", async () => {
setSensitiveStorageItem(sensitiveStorageKeys.token, "stale-token");
setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"user-1"}');
setUser({
id: "user-1",
name: "User",
email: "[email protected]",
role: "OPERATOR",
});
let requestInit: RequestInit | undefined;
globalThis.fetch = async () =>
new Response(
globalThis.fetch = async (_input, init) => {
requestInit = init;
return new Response(
JSON.stringify({
success: false,
error: "Unauthorized - session expired",
@@ -182,11 +180,16 @@ test("clears stored session and redirects to login on authenticated 401", async
headers: { "Content-Type": "application/json" },
},
);
};
await assert.rejects(() => auth.me(), /Unauthorized - session expired/);
await assert.rejects(() => auth.logout(), /Unauthorized - session expired/);
assert.equal(getToken(), null);
assert.equal(getSensitiveStorageItem(sensitiveStorageKeys.user), null);
assert.equal(getUser(), null);
assert.equal(requestInit?.credentials, "include");
assert.equal(
(requestInit?.headers as Record<string, string>)["x-doktainer-request"],
"1",
);
assert.equal(window.location.pathname, "/login");
assert.equal(window.location.search, "?reason=session-expired");
});
+1 -2
View File
@@ -26,10 +26,9 @@
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "dist", "**/*.original.tsx"]
"exclude": ["node_modules", "dist", "tests", "**/*.original.tsx"]
}