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:
1 parent
b731194014
commit
87fdbb493b
33 files changed
+2878
-1296
No files matched your search
+1
-2
@@ -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
@@ -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 ------------------------- */
|
||||
|
||||
Generated
+621
-625
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ export function formatRoleLabel(role: string | undefined) {
|
||||
}
|
||||
|
||||
export const managementRouteAccess = {
|
||||
"/sessions": "OPERATOR",
|
||||
"/users": "OPERATOR",
|
||||
"/api-keys": "DEVELOPER",
|
||||
"/settings": "OPERATOR",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user