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.
This commit is contained in:
KodekaTeam committed 2026-09-11 13:34:01 +07:00
1 parent b731194014
commit 87fdbb493b
33 files changed
+2878 -1296

No files matched your search

+1 -2
View File
@@ -32,8 +32,7 @@ NODE_ENV=production
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
+3 -3
View File
@@ -29,9 +29,9 @@ 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 ------------------------- */
+621 -625
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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",
@@ -52,7 +52,7 @@
"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",
@@ -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")
}
+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(
+442
View File
@@ -0,0 +1,442 @@
"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,
clearSessionState,
redirectToLogin,
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"> {
if (!session.revokedAt) return "active";
return session.revokeReason === "EXPIRED" ? "expired" : "revoked";
}
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 {
const response = await auth.revokeSession(pendingSession.id);
const revokedCurrent = response.data.revokedCurrent;
setPendingSession(null);
if (revokedCurrent) {
clearSessionState();
redirectToLogin();
return;
}
await refresh();
} catch (revokeError) {
setError(revokeError instanceof Error ? revokeError.message : "Failed to revoke session");
} finally {
setRevoking(false);
}
};
return (
<GuardedPage
route="/sessions"
title="Sessions"
subtitle="Manage login sessions across your organization"
currentUser={currentUser}
>
<ConfirmActionDialog
open={pendingSession !== null}
title={pendingSession?.current ? "Sign out this session?" : "Revoke login session?"}
description={
pendingSession?.current
? "This browser will return to the login page immediately."
: `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>
<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>
<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" }}>
<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();
};
}, []);
+112 -43
View File
@@ -4,12 +4,7 @@
*/
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,43 @@ export interface UserInfo {
activeOrganizationId?: string | null;
}
export type UserSessionEventType =
| "LOGIN"
| "LOGOUT"
| "REVOKED"
| "EXPIRED";
export interface UserSessionEventRecord {
id: string;
type: UserSessionEventType;
ipAddress: string | null;
userAgent: string | null;
details: Record<string, unknown> | null;
createdAt: string;
}
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;
current: boolean;
canRevoke: boolean;
user: {
id: string;
name: string;
email: string;
role: string;
};
events: UserSessionEventRecord[];
}
export interface OrganizationRecord {
id: string;
name: string;
@@ -706,8 +767,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 +788,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 +803,7 @@ async function requestRaw(
res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
credentials: "include",
signal: options.signal ?? controller.signal,
});
} catch (error) {
@@ -761,7 +821,7 @@ async function requestRaw(
}
if (res.status === 401) {
handleUnauthorizedSession(path, hadToken);
handleUnauthorizedSession(path);
}
return res;
@@ -813,11 +873,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 +886,6 @@ export const auth = {
post<
| {
success: boolean;
token: string;
user: UserInfo;
requiresTwoFactor?: false;
}
@@ -842,18 +897,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 +977,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: "OPERATOR",
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": "OPERATOR",
"/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();
};
}
+2 -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";
@@ -34,19 +34,6 @@ import { startS3StorageRetentionScheduler } from "./services/s3-storage-retentio
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 +107,6 @@ async function ensureDatabaseConnection() {
}
async function start() {
const jwtSecret = getJwtSecretOrThrow();
validateEncryptionConfiguration();
await ensureDatabaseConnection();
setDefaultSecurityHeaders();
@@ -149,11 +135,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 } });
+148 -79
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,57 @@ 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 getAllowedBrowserOrigins(env = process.env): Set<string> {
const origins = [env.FRONTEND_URL, ...(env.CORS_ORIGINS ?? "").split(",")]
.map((origin) => origin?.trim().replace(/\/$/, ""))
.filter((origin): origin is string => Boolean(origin));
return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]);
}
export function enforceBrowserMutationProtection(
req: FastifyRequest,
reply: FastifyReply,
): 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;
}
if (getHeaderValue(req.headers["sec-fetch-site"]) === "cross-site") {
reply.status(403).send({
success: false,
error: "Forbidden — cross-site request rejected",
});
return false;
}
const origin = getHeaderValue(req.headers.origin)?.replace(/\/$/, "");
if (origin && !getAllowedBrowserOrigins().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;
@@ -376,104 +436,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,
});
}
/**
+349 -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,
expireUserSession,
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,224 @@ export async function authRoutes(app: FastifyInstance) {
return reply.send({ success: true, message: "Logged out successfully" });
});
app.get("/sessions", { preHandler: [requireRole("OPERATOR")] }, async (req, reply) => {
if (!req.sessionId) {
return reply.status(403).send({
success: false,
error: "A browser login session is required",
});
}
if (!req.organizationId) {
return reply.status(400).send({
success: false,
error: "An active organization is required",
});
}
const organizationScope = {
user: {
organizationMemberships: {
some: { organizationId: req.organizationId },
},
},
};
const now = new Date();
const expiredSessions = await prisma.userSession.findMany({
where: {
...organizationScope,
revokedAt: null,
OR: [
{ idleExpiresAt: { lte: now } },
{ absoluteExpiresAt: { lte: now } },
],
},
take: 200,
select: { id: true },
});
await Promise.all(
expiredSessions.map((session) =>
expireUserSession(
session.id,
getClientIp(req) || null,
getRequestUserAgent(req),
),
),
);
const sessions = await prisma.userSession.findMany({
where: organizationScope,
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,
},
},
events: {
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
type: true,
ipAddress: true,
userAgent: true,
details: true,
createdAt: true,
},
},
},
});
return reply.send({
success: true,
data: sessions.map((session) => ({
...session,
current: session.id === req.sessionId,
canRevoke:
session.revokedAt === null &&
(session.user.id === req.userId || req.userRole === "SUPER_ADMIN"),
})),
});
});
app.delete(
"/sessions/:sessionId",
{ preHandler: [requireRole("OPERATOR")] },
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 (!req.organizationId) {
return reply.status(400).send({
success: false,
error: "An active organization is required",
});
}
const targetSession = await prisma.userSession.findFirst({
where: {
id: params.data.sessionId,
user: {
organizationMemberships: {
some: { organizationId: req.organizationId },
},
},
},
select: {
userId: true,
user: { select: { email: true } },
},
});
if (!targetSession) {
return reply
.status(404)
.send({ success: false, error: "Session not found" });
}
if (
!canRevokeManagedSession({
actorUserId: req.userId!,
actorRole: req.userRole!,
targetUserId: targetSession.userId,
})
) {
return reply.status(403).send({
success: false,
error: "Only a Super Admin can revoke another user's session",
});
}
const revoked = await revokeUserSession({
sessionId: params.data.sessionId,
userId: targetSession.userId,
reason:
params.data.sessionId === req.sessionId
? "CURRENT_SESSION_REVOKED"
: "USER_REVOKED",
ipAddress: getClientIp(req) || null,
userAgent: getRequestUserAgent(req),
});
if (!revoked) {
return reply
.status(404)
.send({ success: false, error: "Active session not found" });
}
const revokedCurrent = params.data.sessionId === req.sessionId;
if (revokedCurrent) clearSessionCookie(reply);
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "SESSION_REVOKE",
category: "AUTH",
level: "WARNING",
message: revokedCurrent
? "Current login session revoked"
: `Login session revoked for "${targetSession.user.email}"`,
meta: {
sessionId: params.data.sessionId,
targetUserId: targetSession.userId,
},
});
return reply.send({ success: true, data: { revokedCurrent } });
},
);
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 +898,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 +918,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;
+292
View File
@@ -0,0 +1,292 @@
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;
}): boolean {
return (
input.actorUserId === input.targetUserId ||
input.actorRole === "SUPER_ADMIN"
);
}
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 },
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,
},
});
}
@@ -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,152 @@
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,
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("only owners and super admins can revoke a managed login session", () => {
assert.equal(
canRevokeManagedSession({
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "user-2",
}),
false,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "operator-1",
actorRole: "OPERATOR",
targetUserId: "operator-1",
}),
true,
);
assert.equal(
canRevokeManagedSession({
actorUserId: "admin-1",
actorRole: "SUPER_ADMIN",
targetUserId: "user-2",
}),
true,
);
});
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": "OPERATOR"/);
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\("OPERATOR"\)\]/);
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("OPERATOR"\)\] \}/);
assert.match(routes, /A browser login session is required/);
});
+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,
+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"]
}