Compare commits
10
Commits
b731194014
...
bbfe4c6680
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfe4c6680 | ||
|
|
14411e0cf8 | ||
|
|
c51c17e590 | ||
|
|
0bd696d37f | ||
|
|
6ce3dd590a | ||
|
|
b39d9edbf7 | ||
|
|
25b10f04da | ||
|
|
eb80814d6a | ||
|
|
fb3878edb8 | ||
|
|
87fdbb493b |
No files matched your search
+7
-5
@@ -4,8 +4,8 @@
|
||||
|
||||
# /* -------------------------------- Runtime -------------------------------- */
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.2.3
|
||||
NEXT_PUBLIC_BATCH=Batch-20260909
|
||||
NEXT_PUBLIC_VERSION=v0.2.4
|
||||
NEXT_PUBLIC_BATCH=Batch-20260913
|
||||
NEXT_PUBLIC_API_PORT=4000
|
||||
|
||||
# Leave NEXT_PUBLIC_API_URL empty/unset in monolith Docker deployment so the
|
||||
@@ -27,13 +27,15 @@ DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=p
|
||||
# /* ------------------------------- App server ------------------------------ */
|
||||
NODE_ENV=production
|
||||
|
||||
# Browser authentication in this production configuration requires HTTPS.
|
||||
# Plain HTTP remains supported only by the local development configuration.
|
||||
|
||||
# Bind address for the backend process inside the container.
|
||||
# This is NOT a public URL and must not be reused as FRONTEND_URL or NEXT_PUBLIC_PANEL_URL.
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
|
||||
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
SESSION_ABSOLUTE_TIMEOUT_HOURS=168
|
||||
|
||||
# Required in production. Generate once, store securely, and keep it stable:
|
||||
# openssl rand -hex 32
|
||||
@@ -67,7 +69,7 @@ ENCRYPTION_KEY=dev-32-char-encryption-key-here!
|
||||
# Optional comma-separated extra origins when the backend is exposed directly and CORS must allow them.
|
||||
# Keep this explicit for security; this is not auto-derived from request origin.
|
||||
|
||||
# CORS_ORIGINS=https://panel.example.com,http://your-vps-ip:3128
|
||||
# CORS_ORIGINS=https://panel.example.com
|
||||
|
||||
# Optional internal backend URL used by Next rewrites in monolith mode.
|
||||
# This is internal-only and separate from the public panel URL above.
|
||||
|
||||
+10
-6
@@ -5,8 +5,8 @@
|
||||
# /* -------------------------------- Runtime -------------------------------- */
|
||||
# Frontend runtime
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.2.3
|
||||
NEXT_PUBLIC_BATCH=Batch-20260909
|
||||
NEXT_PUBLIC_VERSION=v0.2.4
|
||||
NEXT_PUBLIC_BATCH=Batch-20260913
|
||||
|
||||
# For Docker deployment, leave NEXT_PUBLIC_API_URL unset so the
|
||||
# browser uses same-origin /api/v1 and Next proxies it to the backend.
|
||||
@@ -15,7 +15,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:4000
|
||||
|
||||
|
||||
# /* --------------- Connection settings for PostgreSQL database -------------- */
|
||||
# Frontend server
|
||||
# Frontend server. Optional for same-origin browser access; recommended as the
|
||||
# canonical callback/fallback origin in production. Use https and no path.
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Optional canonical/backend URL if ever needed
|
||||
@@ -29,12 +30,15 @@ NODE_ENV=development
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL="postgresql://doktainer:doktainerdb@localhost:5432/doktainer?schema=public"
|
||||
|
||||
# JWT settings
|
||||
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
# Absolute lifetime for database-backed login sessions. User settings control
|
||||
# the shorter sliding inactivity timeout.
|
||||
SESSION_ABSOLUTE_TIMEOUT_HOURS=168
|
||||
|
||||
|
||||
# /* -------------------------- Reverse proxy / rate limiting ------------------------- */
|
||||
# Set true only when Doktainer is behind a trusted reverse proxy that overwrites
|
||||
# X-Forwarded-Host and X-Forwarded-Proto. This enables the effective-origin
|
||||
# fallback for clients that do not send Fetch Metadata.
|
||||
TRUST_PROXY=false
|
||||
RATE_LIMIT_MAX=300
|
||||
RATE_LIMIT_TIME_WINDOW=1 minute
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ ARG NEXT_PUBLIC_API_URL=
|
||||
ARG NEXT_PUBLIC_WS_URL=
|
||||
ARG NEXT_PUBLIC_API_PORT=4000
|
||||
ARG NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
ARG NEXT_PUBLIC_VERSION=v0.2.3
|
||||
ARG NEXT_PUBLIC_BATCH=Batch-20260909
|
||||
ARG NEXT_PUBLIC_VERSION=v0.2.4
|
||||
ARG NEXT_PUBLIC_BATCH=Batch-20260913
|
||||
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
|
||||
@@ -45,6 +45,7 @@ NODE
|
||||
if [ -d /app/prisma/migrations ] && [ "$(find /app/prisma/migrations -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
|
||||
db_action="$(node <<'NODE'
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { readdirSync } = require("fs");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
@@ -75,6 +76,7 @@ const prisma = new PrismaClient();
|
||||
|
||||
let failedMigrations = [];
|
||||
let migrationCount = 0;
|
||||
let appliedMigrationNames = new Set();
|
||||
|
||||
if (hasMigrationTable) {
|
||||
failedMigrations = await prisma.$queryRaw`
|
||||
@@ -90,8 +92,27 @@ const prisma = new PrismaClient();
|
||||
`;
|
||||
|
||||
migrationCount = migrationCountResult[0].count;
|
||||
|
||||
const appliedMigrationRows = await prisma.$queryRaw`
|
||||
SELECT migration_name
|
||||
FROM "_prisma_migrations"
|
||||
WHERE finished_at IS NOT NULL
|
||||
AND rolled_back_at IS NULL
|
||||
`;
|
||||
appliedMigrationNames = new Set(
|
||||
appliedMigrationRows.map((row) => row.migration_name)
|
||||
);
|
||||
}
|
||||
|
||||
const migrationNames = readdirSync("/app/prisma/migrations", {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
const pendingMigrationNames = migrationNames.filter(
|
||||
(migrationName) => !appliedMigrationNames.has(migrationName)
|
||||
);
|
||||
|
||||
/**
|
||||
* STATE #1
|
||||
* Fresh database
|
||||
@@ -172,6 +193,65 @@ const prisma = new PrismaClient();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingMigrationNames.length > 0) {
|
||||
const idColumnIntegrity = await prisma.$queryRaw`
|
||||
SELECT
|
||||
table_class.relname AS "tableName",
|
||||
attribute.attnotnull AS "isNotNull",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_index i
|
||||
WHERE i.indrelid = table_class.oid
|
||||
AND i.indisunique
|
||||
AND i.indisvalid
|
||||
AND i.indisready
|
||||
AND i.indnkeyatts = 1
|
||||
AND i.indkey[0] = attribute.attnum
|
||||
) AS "hasUniqueId"
|
||||
FROM pg_class table_class
|
||||
JOIN pg_namespace namespace
|
||||
ON namespace.oid = table_class.relnamespace
|
||||
JOIN pg_attribute attribute
|
||||
ON attribute.attrelid = table_class.oid
|
||||
WHERE namespace.nspname = 'public'
|
||||
AND table_class.relkind IN ('r', 'p')
|
||||
AND table_class.relname <> '_prisma_migrations'
|
||||
AND attribute.attname = 'id'
|
||||
AND NOT attribute.attisdropped
|
||||
ORDER BY table_class.relname
|
||||
`;
|
||||
|
||||
const invalidIdColumns = idColumnIntegrity.filter(
|
||||
(row) => !row.hasUniqueId || !row.isNotNull
|
||||
);
|
||||
|
||||
if (invalidIdColumns.length > 0) {
|
||||
console.error(
|
||||
`Database integrity check failed before applying pending migrations: ${pendingMigrationNames.join(", ")}.`
|
||||
);
|
||||
|
||||
for (const invalidColumn of invalidIdColumns) {
|
||||
const quotedTableName = `"${invalidColumn.tableName.replace(/"/g, '""')}"`;
|
||||
const [counts] = await prisma.$queryRawUnsafe(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE "id" IS NULL)::int AS "nullIds",
|
||||
(COUNT(*) - COUNT(DISTINCT "id"))::int AS "duplicateIds"
|
||||
FROM ${quotedTableName}
|
||||
`);
|
||||
|
||||
console.error(
|
||||
`${invalidColumn.tableName}.id must be NOT NULL and have a valid single-column PRIMARY KEY or UNIQUE index (duplicate rows: ${counts.duplicateIds}, null IDs: ${counts.nullIds}).`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Restore missing database constraints and resolve duplicate IDs before applying migrations."
|
||||
);
|
||||
console.log("blocked");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Prisma migration history detected. Applying pending migrations."
|
||||
);
|
||||
|
||||
Generated
+627
-631
File diff suppressed because it is too large.
Load diff
+6
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "doktainer",
|
||||
"description": "Doktainer is a platform for managing containerized applications.",
|
||||
"version": "v0.2.3",
|
||||
"version": "v0.2.4",
|
||||
"author": "Doktainer Team",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -27,8 +27,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1031.0",
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"adm-zip": "^0.6.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -52,10 +52,10 @@
|
||||
"fastify": "^5.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^16.2.6",
|
||||
"next": "16.3.4",
|
||||
"node-ssh": "^13.2.0",
|
||||
"node-xlsx": "^0.24.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^10.0.9",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
CREATE TYPE "SessionEventType" AS ENUM ('LOGIN', 'LOGOUT', 'REVOKED', 'EXPIRED');
|
||||
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"createdIp" TEXT,
|
||||
"lastIp" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"idleExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"absoluteExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"revokeReason" TEXT,
|
||||
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "user_session_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"type" "SessionEventType" NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"details" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_session_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "user_sessions_tokenHash_key" ON "user_sessions"("tokenHash");
|
||||
CREATE INDEX "user_sessions_userId_revokedAt_idx" ON "user_sessions"("userId", "revokedAt");
|
||||
CREATE INDEX "user_sessions_idleExpiresAt_idx" ON "user_sessions"("idleExpiresAt");
|
||||
CREATE INDEX "user_sessions_absoluteExpiresAt_idx" ON "user_sessions"("absoluteExpiresAt");
|
||||
CREATE INDEX "user_session_events_sessionId_createdAt_idx" ON "user_session_events"("sessionId", "createdAt");
|
||||
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "user_session_events" ADD CONSTRAINT "user_session_events_sessionId_fkey"
|
||||
FOREIGN KEY ("sessionId") REFERENCES "user_sessions"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+375
-331
@@ -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")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import SearchField from "@/components/SearchField";
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import {
|
||||
apiKeys as apiKeysApi,
|
||||
getApiBaseUrl,
|
||||
settingsApi,
|
||||
type ApiKeyExpiryOption,
|
||||
type ApiKeyRecord,
|
||||
type CreateApiKeyBody,
|
||||
} from "@/lib/api";
|
||||
import { resolveApiExampleUrl } from "@/lib/api-example-url";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import {
|
||||
Activity,
|
||||
@@ -172,6 +175,7 @@ export default function ApiKeysPage() {
|
||||
const [generatedKey, setGeneratedKey] = useState<GeneratedKeyState | null>(
|
||||
null,
|
||||
);
|
||||
const [panelUrl, setPanelUrl] = useState<string | null>(null);
|
||||
const [sessionRawKeys, setSessionRawKeys] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
@@ -212,8 +216,16 @@ export default function ApiKeysPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadKeys();
|
||||
void loadPanelUrl();
|
||||
}, []);
|
||||
|
||||
const apiExampleUrl = resolveApiExampleUrl({
|
||||
panelUrl,
|
||||
browserOrigin:
|
||||
typeof window === "undefined" ? null : window.location.origin,
|
||||
apiBaseUrl: getApiBaseUrl(),
|
||||
});
|
||||
|
||||
async function loadKeys() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -347,6 +359,15 @@ export default function ApiKeysPage() {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPanelUrl() {
|
||||
try {
|
||||
const response = await settingsApi.get();
|
||||
setPanelUrl(response.data.general.panelUrl);
|
||||
} catch {
|
||||
// The active browser origin remains a valid same-origin API fallback.
|
||||
}
|
||||
}
|
||||
|
||||
async function removeKey(id: string) {
|
||||
setRemovingId(id);
|
||||
setError("");
|
||||
@@ -1136,7 +1157,7 @@ export default function ApiKeysPage() {
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{`curl -H "x-api-key: ${generatedKey.rawKey}" http://localhost:4000/api/v1/servers`}
|
||||
{`curl -H "x-api-key: ${generatedKey.rawKey}" ${apiExampleUrl}`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,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(
|
||||
@@ -923,27 +924,6 @@ export default function ServerConfigModal({
|
||||
});
|
||||
};
|
||||
|
||||
const requestSystemGroupDeleteConfirm = (options: {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
confirmation: string;
|
||||
}) => {
|
||||
setPendingConfirm({
|
||||
kind: "system-group-delete",
|
||||
groupName: options.groupName,
|
||||
expectedGid: options.gid,
|
||||
expectedMembers: options.members,
|
||||
expectedPrimaryUsers: options.primaryUsers,
|
||||
confirmation: options.confirmation,
|
||||
title: `Delete system group ${options.groupName}`,
|
||||
description: `Permanently delete the empty group ${options.groupName} (GID ${options.gid}). System, privileged, changed, or in-use groups will be rejected by the host.`,
|
||||
confirmLabel: "Delete Group",
|
||||
tone: "danger",
|
||||
});
|
||||
};
|
||||
|
||||
const requestSshAccessUpdateConfirm = (
|
||||
options: ServerSshAccessUpdateBody,
|
||||
) => {
|
||||
@@ -1007,7 +987,6 @@ export default function ServerConfigModal({
|
||||
requestSystemUserSshKeyRevokeConfirm
|
||||
}
|
||||
onRequestDeleteUserConfirm={requestSystemUserDeleteConfirm}
|
||||
onRequestDeleteGroupConfirm={requestSystemGroupDeleteConfirm}
|
||||
/>
|
||||
);
|
||||
case "ssh-access":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ServerConfigSnapshot,
|
||||
@@ -63,28 +65,10 @@ interface ServerConfigUsersPanelProps {
|
||||
confirmation: string;
|
||||
removeHome: boolean;
|
||||
}) => void;
|
||||
onRequestDeleteGroupConfirm: (options: {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
confirmation: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]);
|
||||
const protectedGroupNames = new Set([
|
||||
"root",
|
||||
"sudo",
|
||||
"wheel",
|
||||
"docker",
|
||||
"adm",
|
||||
"www-data",
|
||||
"systemd-journal",
|
||||
"ssh",
|
||||
"sshd",
|
||||
]);
|
||||
const publicKeyPattern = /^(?:ssh-(?:ed25519|rsa)|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/;
|
||||
|
||||
export default function ServerConfigUsersPanel({
|
||||
@@ -101,9 +85,9 @@ export default function ServerConfigUsersPanel({
|
||||
onRequestSshKeyAddConfirm,
|
||||
onRequestSshKeyRevokeConfirm,
|
||||
onRequestDeleteUserConfirm,
|
||||
onRequestDeleteGroupConfirm,
|
||||
}: ServerConfigUsersPanelProps) {
|
||||
const [previewMode, setPreviewMode] = useState<"user" | "group">("user");
|
||||
const [managementModalOpen, setManagementModalOpen] = useState(false);
|
||||
const [previewUsername, setPreviewUsername] = useState("deploy");
|
||||
const [previewGroupName, setPreviewGroupName] = useState("docker");
|
||||
const [userPurpose, setUserPurpose] = useState<"ssh" | "local">("ssh");
|
||||
@@ -129,12 +113,6 @@ export default function ServerConfigUsersPanel({
|
||||
);
|
||||
const [deleteUserConfirmation, setDeleteUserConfirmation] = useState("");
|
||||
const [deleteUserHome, setDeleteUserHome] = useState(false);
|
||||
const [managedGroup, setManagedGroup] = useState(
|
||||
snapshot.systemGroups.includes("docker")
|
||||
? "docker"
|
||||
: snapshot.systemGroups[0] ?? "",
|
||||
);
|
||||
const [deleteGroupConfirmation, setDeleteGroupConfirmation] = useState("");
|
||||
const [passwordEditorOpen, setPasswordEditorOpen] = useState(false);
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
const [editPasswordConfirm, setEditPasswordConfirm] = useState("");
|
||||
@@ -145,6 +123,19 @@ export default function ServerConfigUsersPanel({
|
||||
const [sshKeyLabel, setSshKeyLabel] = useState("Personal laptop");
|
||||
const [sshPublicKey, setSshPublicKey] = useState("");
|
||||
const [sshKeyError, setSshKeyError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managementModalOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setManagementModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [managementModalOpen]);
|
||||
const users = useMemo(
|
||||
() => [
|
||||
...(snapshot.rootUser ? [snapshot.rootUser] : []),
|
||||
@@ -219,24 +210,6 @@ export default function ServerConfigUsersPanel({
|
||||
: loginMethod === "password"
|
||||
? `Create ${normalizedUsername || "<username>"} and prepare password-based SSH login.`
|
||||
: `Create ${normalizedUsername || "<username>"} and install an SSH public key.`;
|
||||
const managedGroupDetails = snapshot.systemGroupDetails.find(
|
||||
(group) => group.name === managedGroup,
|
||||
);
|
||||
const managedGroupUsers = managedGroupDetails?.members ?? [];
|
||||
const managedGroupPrimaryUsers = managedGroupDetails?.primaryUsers ?? [];
|
||||
const managedGroupBlockReason = !managedGroup
|
||||
? "Select a group to inspect."
|
||||
: !managedGroupDetails || snapshot.gidMin == null
|
||||
? "Group metadata or the host GID_MIN policy is unavailable. Refresh Server Config."
|
||||
: protectedGroupNames.has(managedGroup)
|
||||
? `${managedGroup} is protected because it is a system or privileged group.`
|
||||
: managedGroupDetails.gid < snapshot.gidMin
|
||||
? `GID ${managedGroupDetails.gid} is below the host GID_MIN (${snapshot.gidMin}).`
|
||||
: managedGroupPrimaryUsers.length > 0
|
||||
? `This is the primary group for ${managedGroupPrimaryUsers.join(", ")}.`
|
||||
: managedGroupUsers.length > 0
|
||||
? `Remove its members first: ${managedGroupUsers.join(", ")}.`
|
||||
: null;
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
setSelectedGroups((current) =>
|
||||
@@ -292,6 +265,7 @@ export default function ServerConfigUsersPanel({
|
||||
remoteLogin,
|
||||
credential,
|
||||
});
|
||||
setManagementModalOpen(false);
|
||||
setGuidedSetupError(null);
|
||||
setSetupPassword("");
|
||||
setSetupPasswordConfirm("");
|
||||
@@ -408,57 +382,78 @@ export default function ServerConfigUsersPanel({
|
||||
style={{ display: "grid", gap: 16 }}
|
||||
hidden={snapshotLoadError != null}
|
||||
>
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
User Inventory
|
||||
</strong>
|
||||
<p
|
||||
style={{ marginTop: 6, color: "var(--text-muted)", fontSize: 13 }}
|
||||
>
|
||||
Showing root and non-root accounts together with their detected
|
||||
groups.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UserBadge
|
||||
label={`${snapshot.hasRootUser ? 1 : 0} root`}
|
||||
tone="danger"
|
||||
/>
|
||||
<UserBadge
|
||||
label={`${snapshot.nonRootUsers.length} non-root`}
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
display: "grid",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
borderColor: "rgba(59,130,246,0.24)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), var(--bg-card) 68%)",
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
User & Group Management
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
maxWidth: 620,
|
||||
}}
|
||||
>
|
||||
Create host accounts, create groups, and assign supplementary
|
||||
group access without opening a terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setManagementModalOpen(true)}
|
||||
style={{ minHeight: 44, justifyContent: "center" }}
|
||||
>
|
||||
<UserPlus size={14} /> Manage Users & Groups
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{managementModalOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="modal-overlay" style={{ zIndex: 1003 }}>
|
||||
<div className="modal-shell" style={{ maxWidth: 920 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={() => setManagementModalOpen(false)}
|
||||
aria-label="Close user and group management modal"
|
||||
autoFocus
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-group-management-title"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 920,
|
||||
maxHeight: "90vh",
|
||||
padding: 24,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
overflowY: "auto",
|
||||
borderColor: "rgba(59,130,246,0.24)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -469,7 +464,10 @@ export default function ServerConfigUsersPanel({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
<strong
|
||||
id="user-group-management-title"
|
||||
style={{ color: "var(--text-primary)", fontSize: 14 }}
|
||||
>
|
||||
User & Group Management
|
||||
</strong>
|
||||
<p
|
||||
@@ -480,8 +478,8 @@ export default function ServerConfigUsersPanel({
|
||||
maxWidth: 620,
|
||||
}}
|
||||
>
|
||||
Create passwordless host accounts, create groups, and assign
|
||||
supplementary group access without opening a terminal.
|
||||
Create host accounts, create groups, and assign supplementary
|
||||
group access without opening a terminal.
|
||||
</p>
|
||||
</div>
|
||||
<UserBadge label="Audited action" tone="success" />
|
||||
@@ -490,7 +488,8 @@ export default function ServerConfigUsersPanel({
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fit, minmax(min(260px, 100%), 1fr))",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
@@ -501,7 +500,7 @@ export default function ServerConfigUsersPanel({
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
height: 40,
|
||||
minHeight: 44,
|
||||
alignSelf: "start",
|
||||
}}
|
||||
>
|
||||
@@ -518,8 +517,7 @@ export default function ServerConfigUsersPanel({
|
||||
className="btn"
|
||||
onClick={() => setPreviewMode(item.key as "user" | "group")}
|
||||
style={{
|
||||
height: 40,
|
||||
minHeight: 40,
|
||||
minHeight: 44,
|
||||
padding: "0 12px",
|
||||
justifyContent: "center",
|
||||
borderColor: active ? "var(--accent)" : "var(--border)",
|
||||
@@ -1035,6 +1033,7 @@ export default function ServerConfigUsersPanel({
|
||||
if (previewMode === "user") {
|
||||
stageGuidedUserSetup();
|
||||
} else {
|
||||
setManagementModalOpen(false);
|
||||
onRequestCreateGroupConfirm(normalizedGroupName);
|
||||
}
|
||||
}}
|
||||
@@ -1068,170 +1067,12 @@ export default function ServerConfigUsersPanel({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
borderColor: "rgba(239,68,68,0.18)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
Existing Groups
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 5,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Inspect group usage before opening the protected delete flow.
|
||||
</p>
|
||||
</div>
|
||||
<UserBadge label="Audited action" tone="success" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 14,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
<SearchableSelect
|
||||
value={managedGroup}
|
||||
options={snapshot.systemGroupDetails.map((group) => ({
|
||||
value: group.name,
|
||||
label: group.name,
|
||||
description: protectedGroupNames.has(group.name) ||
|
||||
(snapshot.gidMin != null && group.gid < snapshot.gidMin)
|
||||
? "Protected group"
|
||||
: group.members.length > 0 || group.primaryUsers.length > 0
|
||||
? "In use"
|
||||
: `GID ${group.gid} · eligible for review`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setManagedGroup(value);
|
||||
setDeleteGroupConfirmation("");
|
||||
}}
|
||||
placeholder="Select an existing group..."
|
||||
searchPlaceholder="Search group..."
|
||||
emptyText="No matching group found"
|
||||
/>
|
||||
{managedGroup ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 7,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UserBadge
|
||||
label={managedGroupBlockReason ? "Deletion locked" : "Candidate"}
|
||||
tone={managedGroupBlockReason ? "warning" : "success"}
|
||||
/>
|
||||
<UserBadge
|
||||
label={`${managedGroupUsers.length} detected member${managedGroupUsers.length === 1 ? "" : "s"}`}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
borderRadius: 9,
|
||||
border: managedGroupBlockReason
|
||||
? "1px solid rgba(245,158,11,0.24)"
|
||||
: "1px solid rgba(239,68,68,0.24)",
|
||||
background: managedGroupBlockReason
|
||||
? "rgba(245,158,11,0.055)"
|
||||
: "rgba(239,68,68,0.035)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{managedGroupBlockReason ? (
|
||||
<LockKeyhole size={15} color="#f59e0b" />
|
||||
) : (
|
||||
<Trash2 size={15} color="#ef4444" />
|
||||
)}
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
|
||||
Delete group
|
||||
</strong>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: managedGroupBlockReason ? "#b45309" : "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{managedGroupBlockReason ??
|
||||
"No members were detected. The live feature will re-check GID_MIN, primary-group usage, and membership on the host immediately before deletion."}
|
||||
</p>
|
||||
{!managedGroupBlockReason && managedGroup ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
value={deleteGroupConfirmation}
|
||||
onChange={(event) =>
|
||||
setDeleteGroupConfirmation(event.target.value)
|
||||
}
|
||||
placeholder={`Type ${managedGroup} to confirm`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={
|
||||
deleteGroupConfirmation !== managedGroup ||
|
||||
!canManageSystemAccounts ||
|
||||
!managedGroupDetails ||
|
||||
isActionRunning(`system-group:delete:${managedGroup}`)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!managedGroupDetails) return;
|
||||
onRequestDeleteGroupConfirm({
|
||||
groupName: managedGroupDetails.name,
|
||||
gid: managedGroupDetails.gid,
|
||||
members: managedGroupDetails.members,
|
||||
primaryUsers: managedGroupDetails.primaryUsers,
|
||||
confirmation: deleteGroupConfirmation,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
color: "#ef4444",
|
||||
borderColor: "rgba(239,68,68,0.32)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} /> Review Group Deletion
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{users.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import GuardedPage from "@/components/GuardedPage";
|
||||
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
|
||||
import SearchField from "@/components/SearchField";
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { useCurrentUser } from "@/lib/auth-state";
|
||||
import { auth, type UserSessionRecord } from "@/lib/api";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type StatusFilter = "all" | "active" | "expired" | "revoked";
|
||||
type SortKey = "user" | "ip" | "device" | "status" | "createdAt" | "expiresAt";
|
||||
|
||||
function describeDevice(userAgent: string | null) {
|
||||
if (!userAgent) return { browser: "Unknown browser", platform: "Unknown device" };
|
||||
const browser = userAgent.includes("Edg/")
|
||||
? "Edge"
|
||||
: userAgent.includes("Firefox/")
|
||||
? "Firefox"
|
||||
: userAgent.includes("Chrome/")
|
||||
? "Chrome"
|
||||
: userAgent.includes("Safari/")
|
||||
? "Safari"
|
||||
: "Other browser";
|
||||
const platform = userAgent.includes("Windows")
|
||||
? "Windows"
|
||||
: userAgent.includes("Mac OS")
|
||||
? "macOS"
|
||||
: userAgent.includes("Android")
|
||||
? "Android"
|
||||
: userAgent.includes("iPhone") || userAgent.includes("iPad")
|
||||
? "iOS"
|
||||
: userAgent.includes("Linux")
|
||||
? "Linux"
|
||||
: "Unknown device";
|
||||
return { browser, platform };
|
||||
}
|
||||
|
||||
function getSessionStatus(session: UserSessionRecord): Exclude<StatusFilter, "all"> {
|
||||
return session.status;
|
||||
}
|
||||
|
||||
function getEffectiveExpiry(session: UserSessionRecord) {
|
||||
return new Date(
|
||||
Math.min(
|
||||
new Date(session.idleExpiresAt).getTime(),
|
||||
new Date(session.absoluteExpiresAt).getTime(),
|
||||
),
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
label,
|
||||
column,
|
||||
sortKey,
|
||||
direction,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
column: SortKey;
|
||||
sortKey: SortKey;
|
||||
direction: "asc" | "desc";
|
||||
onSort: (column: SortKey) => void;
|
||||
}) {
|
||||
const active = sortKey === column;
|
||||
const Icon = !active ? ArrowUpDown : direction === "asc" ? ArrowUp : ArrowDown;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
aria-label={`Sort by ${label}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "inherit",
|
||||
background: "none",
|
||||
border: 0,
|
||||
padding: 0,
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<Icon size={11} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const currentUser = useCurrentUser();
|
||||
const [sessions, setSessions] = useState<UserSessionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [userFilter, setUserFilter] = useState("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("createdAt");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [pendingSession, setPendingSession] = useState<UserSessionRecord | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void auth
|
||||
.sessions()
|
||||
.then((response) => {
|
||||
if (!cancelled) setSessions(response.data);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load sessions");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const unique = new Map<string, UserSessionRecord["user"]>();
|
||||
for (const session of sessions) unique.set(session.user.id, session.user);
|
||||
return [...unique.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
}, [sessions]);
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = sessions.filter((session) => {
|
||||
const device = describeDevice(session.userAgent);
|
||||
const status = getSessionStatus(session);
|
||||
const matchesSearch =
|
||||
!query ||
|
||||
[
|
||||
session.user.name,
|
||||
session.user.email,
|
||||
session.lastIp,
|
||||
session.createdIp,
|
||||
device.browser,
|
||||
device.platform,
|
||||
].some((value) => value?.toLowerCase().includes(query));
|
||||
return (
|
||||
matchesSearch &&
|
||||
(statusFilter === "all" || status === statusFilter) &&
|
||||
(userFilter === "all" || session.user.id === userFilter)
|
||||
);
|
||||
});
|
||||
|
||||
const direction = sortDirection === "asc" ? 1 : -1;
|
||||
return filtered.sort((left, right) => {
|
||||
const leftDevice = describeDevice(left.userAgent);
|
||||
const rightDevice = describeDevice(right.userAgent);
|
||||
const values: Record<SortKey, [string | number, string | number]> = {
|
||||
user: [left.user.name.toLowerCase(), right.user.name.toLowerCase()],
|
||||
ip: [left.lastIp ?? left.createdIp ?? "", right.lastIp ?? right.createdIp ?? ""],
|
||||
device: [leftDevice.browser, rightDevice.browser],
|
||||
status: [getSessionStatus(left), getSessionStatus(right)],
|
||||
createdAt: [new Date(left.createdAt).getTime(), new Date(right.createdAt).getTime()],
|
||||
expiresAt: [
|
||||
new Date(getEffectiveExpiry(left)).getTime(),
|
||||
new Date(getEffectiveExpiry(right)).getTime(),
|
||||
],
|
||||
};
|
||||
const [leftValue, rightValue] = values[sortKey];
|
||||
return typeof leftValue === "number" && typeof rightValue === "number"
|
||||
? (leftValue - rightValue) * direction
|
||||
: String(leftValue).localeCompare(String(rightValue)) * direction;
|
||||
});
|
||||
}, [search, sessions, sortDirection, sortKey, statusFilter, userFilter]);
|
||||
|
||||
const pagination = useTablePagination({
|
||||
items: filteredSessions,
|
||||
pageSize: 10,
|
||||
resetKey: `${search}|${statusFilter}|${userFilter}|${sortKey}|${sortDirection}`,
|
||||
});
|
||||
|
||||
const handleSort = (column: SortKey) => {
|
||||
if (column === sortKey) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(column);
|
||||
setSortDirection(column === "createdAt" || column === "expiresAt" ? "desc" : "asc");
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await auth.sessions();
|
||||
setSessions(response.data);
|
||||
} catch (refreshError) {
|
||||
setError(refreshError instanceof Error ? refreshError.message : "Failed to refresh sessions");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
if (!pendingSession || revoking) return;
|
||||
setRevoking(true);
|
||||
setError("");
|
||||
try {
|
||||
await auth.revokeSession(pendingSession.id);
|
||||
setPendingSession(null);
|
||||
await refresh();
|
||||
} catch (revokeError) {
|
||||
setError(revokeError instanceof Error ? revokeError.message : "Failed to revoke session");
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GuardedPage
|
||||
route="/sessions"
|
||||
title="Sessions"
|
||||
subtitle="Review and revoke login sessions"
|
||||
currentUser={currentUser}
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={pendingSession !== null}
|
||||
title="Revoke login session?"
|
||||
description={`Revoke access for ${pendingSession?.user.name ?? "this user"} on ${describeDevice(pendingSession?.userAgent ?? null).browser}?`}
|
||||
confirmLabel={revoking ? "Revoking…" : "Revoke session"}
|
||||
note="The affected browser must log in again. API keys are not changed."
|
||||
onClose={() => !revoking && setPendingSession(null)}
|
||||
onConfirm={() => void revoke()}
|
||||
/>
|
||||
|
||||
<div className="animate-slide-in" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{error ? (
|
||||
<IssueDetailsSummary
|
||||
label="Sessions"
|
||||
message={error}
|
||||
description="Login session data could not be loaded or updated."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="card ui-responsive-toolbar users-toolbar"
|
||||
style={{ padding: "12px 16px" }}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Search users, IP addresses, browsers, or devices…"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
|
||||
/>
|
||||
<select
|
||||
className="input users-toolbar-filter"
|
||||
aria-label="Filter sessions by status"
|
||||
value={statusFilter}
|
||||
onChange={(event) =>
|
||||
setStatusFilter(event.target.value as StatusFilter)
|
||||
}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
</select>
|
||||
{currentUser?.role === "SUPER_ADMIN" ? (
|
||||
<select
|
||||
className="input users-toolbar-filter"
|
||||
aria-label="Filter sessions by user"
|
||||
value={userFilter}
|
||||
onChange={(event) => setUserFilter(event.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<option value="all">All users</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<div className="ui-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ padding: "18px 20px", borderBottom: "1px solid var(--border)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<MonitorSmartphone size={18} aria-hidden="true" />
|
||||
<h2 style={{ fontSize: 16, fontWeight: 650 }}>Login Sessions</h2>
|
||||
</div>
|
||||
<p style={{ marginTop: 5, fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Review active and historical sign-ins. Revoke a session to force logout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 40, display: "flex", justifyContent: "center", gap: 9, color: "var(--text-muted)" }}>
|
||||
<Loader2 size={16} className="animate-spin" /> Loading sessions…
|
||||
</div>
|
||||
) : pagination.totalItems === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: "center", color: "var(--text-muted)", fontSize: 13 }}>
|
||||
{sessions.length === 0 ? "No login session history is available yet." : "No sessions match these filters."}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table" style={{ minWidth: 1000 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><SortButton label="User" column="user" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th><SortButton label="IP Address" column="ip" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th><SortButton label="Device" column="device" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th><SortButton label="Status" column="status" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th><SortButton label="Active Since" column="createdAt" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th><SortButton label="Expires" column="expiresAt" sortKey={sortKey} direction={sortDirection} onSort={handleSort} /></th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagination.paginatedItems.map((session) => {
|
||||
const device = describeDevice(session.userAgent);
|
||||
const status = getSessionStatus(session);
|
||||
return (
|
||||
<tr key={session.id}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: "var(--text-primary)" }}>{session.user.name}</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: "var(--text-muted)" }}>{session.user.email}</div>
|
||||
</div>
|
||||
{session.current ? (
|
||||
<span className="ui-badge badge-info">Current</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontFamily: "JetBrains Mono, monospace", color: "var(--text-primary)" }}>
|
||||
{session.lastIp ?? session.createdIp ?? "Unknown"}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ color: "var(--text-primary)" }}>{device.browser}</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: "var(--text-muted)" }}>{device.platform}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ui-badge ${status === "active" ? "badge-online" : "badge-danger"}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDate(session.createdAt)}</td>
|
||||
<td>{formatDate(getEffectiveExpiry(session))}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{session.current ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "6px 8px", color: session.canRevoke ? "#ef4444" : "var(--text-muted)" }}
|
||||
onClick={() => setPendingSession(session)}
|
||||
disabled={!session.canRevoke || revoking}
|
||||
title={
|
||||
session.canRevoke
|
||||
? "Revoke session"
|
||||
: status !== "active"
|
||||
? "Session is no longer active"
|
||||
: "Only a Super Admin can revoke another user's session"
|
||||
}
|
||||
aria-label={`Revoke session for ${session.user.name}`}
|
||||
>
|
||||
<LogOut size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.totalItems}
|
||||
startItem={pagination.startItem}
|
||||
endItem={pagination.endItem}
|
||||
itemLabel="sessions"
|
||||
onPageChange={pagination.setCurrentPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</GuardedPage>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,10 @@ export default function GeneralSettingsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ marginTop: 6, fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Activity extends the inactivity window. Every login still follows
|
||||
the server'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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
type ApiExampleUrlOptions = {
|
||||
panelUrl?: string | null;
|
||||
browserOrigin?: string | null;
|
||||
apiBaseUrl?: string | null;
|
||||
};
|
||||
|
||||
function normalizeHttpUrl(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string) {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveApiExampleUrl({
|
||||
panelUrl,
|
||||
browserOrigin,
|
||||
apiBaseUrl,
|
||||
}: ApiExampleUrlOptions) {
|
||||
const storedPanelUrl = normalizeHttpUrl(panelUrl);
|
||||
const runtimeOrigin = normalizeHttpUrl(browserOrigin);
|
||||
const storedPanelIsStaleLocalDefault =
|
||||
storedPanelUrl &&
|
||||
runtimeOrigin &&
|
||||
isLoopbackHost(storedPanelUrl.hostname) &&
|
||||
!isLoopbackHost(runtimeOrigin.hostname);
|
||||
|
||||
const publicOrigin = storedPanelIsStaleLocalDefault
|
||||
? runtimeOrigin
|
||||
: storedPanelUrl || runtimeOrigin;
|
||||
|
||||
if (publicOrigin) {
|
||||
return new URL("/api/v1/servers", publicOrigin.origin).toString();
|
||||
}
|
||||
|
||||
const configuredApiBase = normalizeHttpUrl(apiBaseUrl);
|
||||
if (configuredApiBase) {
|
||||
return new URL(
|
||||
`${configuredApiBase.pathname.replace(/\/+$/, "")}/servers`,
|
||||
configuredApiBase.origin,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return "http://localhost:3000/api/v1/servers";
|
||||
}
|
||||
+98
-44
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* API Client — DOKTAINER
|
||||
* Centralized HTTP client with auth token injection
|
||||
* Centralized HTTP client with cookie-based browser authentication
|
||||
*/
|
||||
|
||||
import { emitAuthStateChanged } from "@/lib/auth-events";
|
||||
import {
|
||||
getSensitiveStorageItem,
|
||||
removeSensitiveStorageItem,
|
||||
sensitiveStorageKeys,
|
||||
setSensitiveStorageItem,
|
||||
} from "@/lib/browser-storage";
|
||||
import { clearLegacyAuthStorage } from "@/lib/browser-storage";
|
||||
import {
|
||||
buildTerminalWebSocketUrl,
|
||||
resolveWebSocketBaseUrl,
|
||||
@@ -59,6 +54,7 @@ const PUBLIC_AUTH_PATHS = [
|
||||
"/auth/register",
|
||||
"/auth/invitations",
|
||||
"/auth/registration-status",
|
||||
"/auth/me",
|
||||
] as const;
|
||||
const READ_ONLY_ALLOWED_MUTATION_PATHS = new Set(["/auth/logout"]);
|
||||
|
||||
@@ -124,52 +120,80 @@ export function redirectToLogin(reason?: string): void {
|
||||
|
||||
function handleUnauthorizedSession(
|
||||
path: string,
|
||||
hadToken: boolean,
|
||||
): never | void {
|
||||
if (!hadToken || isPublicAuthPath(path)) {
|
||||
if (isPublicAuthPath(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearToken();
|
||||
clearSessionState();
|
||||
redirectToLogin("session-expired");
|
||||
throw new Error("Unauthorized - session expired");
|
||||
}
|
||||
|
||||
// ─── Token helpers ────────────────────────────────────────────────────────────
|
||||
// ─── Browser session state ────────────────────────────────────────────────────
|
||||
|
||||
export function getToken(): string | null {
|
||||
return getSensitiveStorageItem(sensitiveStorageKeys.token);
|
||||
}
|
||||
let currentUser: UserInfo | null = null;
|
||||
let sessionRequest: Promise<UserInfo | null> | null = null;
|
||||
let sessionLoaded = false;
|
||||
let sessionGeneration = 0;
|
||||
|
||||
export function setToken(token: string): void {
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.token, token);
|
||||
emitAuthStateChanged();
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
removeSensitiveStorageItem(sensitiveStorageKeys.token);
|
||||
removeSensitiveStorageItem(sensitiveStorageKeys.user);
|
||||
export function clearSessionState(): void {
|
||||
currentUser = null;
|
||||
sessionRequest = null;
|
||||
sessionLoaded = false;
|
||||
sessionGeneration += 1;
|
||||
clearLegacyAuthStorage();
|
||||
clearStoredOrganizationId();
|
||||
emitAuthStateChanged();
|
||||
emitAuthStateChanged(true);
|
||||
}
|
||||
|
||||
export function getUser(): UserInfo | null {
|
||||
try {
|
||||
const raw = getSensitiveStorageItem(sensitiveStorageKeys.user);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
export function setUser(user: UserInfo): void {
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.user, JSON.stringify(user));
|
||||
currentUser = user;
|
||||
sessionLoaded = true;
|
||||
sessionGeneration += 1;
|
||||
if (user.activeOrganizationId) {
|
||||
setStoredOrganizationId(user.activeOrganizationId);
|
||||
}
|
||||
emitAuthStateChanged();
|
||||
}
|
||||
|
||||
export function loadCurrentSession(force = false): Promise<UserInfo | null> {
|
||||
if (sessionRequest) return sessionRequest;
|
||||
if (!force && sessionLoaded) return Promise.resolve(currentUser);
|
||||
|
||||
clearLegacyAuthStorage();
|
||||
const requestGeneration = sessionGeneration;
|
||||
const request = auth
|
||||
.me()
|
||||
.then((response) => {
|
||||
if (requestGeneration !== sessionGeneration) {
|
||||
return currentUser;
|
||||
}
|
||||
setUser(response.user);
|
||||
return response.user;
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestGeneration !== sessionGeneration) {
|
||||
return currentUser;
|
||||
}
|
||||
currentUser = null;
|
||||
sessionLoaded = true;
|
||||
emitAuthStateChanged();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (sessionRequest === request) {
|
||||
sessionRequest = null;
|
||||
}
|
||||
});
|
||||
sessionRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -180,6 +204,28 @@ export interface UserInfo {
|
||||
activeOrganizationId?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSessionRecord {
|
||||
id: string;
|
||||
createdIp: string | null;
|
||||
lastIp: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
idleExpiresAt: string;
|
||||
absoluteExpiresAt: string;
|
||||
revokedAt: string | null;
|
||||
revokeReason: string | null;
|
||||
status: "active" | "expired" | "revoked";
|
||||
current: boolean;
|
||||
canRevoke: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrganizationRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -706,8 +752,6 @@ async function requestRaw(
|
||||
): Promise<Response> {
|
||||
enforceViewerReadOnlyClient(path, options.method);
|
||||
|
||||
const token = getToken();
|
||||
const hadToken = Boolean(token);
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
@@ -729,8 +773,8 @@ async function requestRaw(
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
if (methodRequiresWriteAccess(options.method)) {
|
||||
headers["x-doktainer-request"] = "1";
|
||||
}
|
||||
|
||||
const organizationId = getStoredOrganizationId();
|
||||
@@ -744,6 +788,7 @@ async function requestRaw(
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: options.signal ?? controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -761,7 +806,7 @@ async function requestRaw(
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
handleUnauthorizedSession(path, hadToken);
|
||||
handleUnauthorizedSession(path);
|
||||
}
|
||||
|
||||
return res;
|
||||
@@ -813,11 +858,7 @@ function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
|
||||
// ─── WebSocket helper ─────────────────────────────────────────────────────────
|
||||
|
||||
export function createWs(path: string): WebSocket {
|
||||
const token = getToken();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return new WebSocket(
|
||||
`${getWebSocketBaseUrl()}${path}${token ? `${sep}token=${encodeURIComponent(token)}` : ""}`,
|
||||
);
|
||||
return new WebSocket(`${getWebSocketBaseUrl()}${path}`);
|
||||
}
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
@@ -830,7 +871,6 @@ export const auth = {
|
||||
post<
|
||||
| {
|
||||
success: boolean;
|
||||
token: string;
|
||||
user: UserInfo;
|
||||
requiresTwoFactor?: false;
|
||||
}
|
||||
@@ -842,18 +882,32 @@ export const auth = {
|
||||
}),
|
||||
|
||||
register: (name: string, email: string, password: string) =>
|
||||
post<{ success: boolean; token: string; user: UserInfo }>(
|
||||
post<{ success: boolean; user: UserInfo }>(
|
||||
"/auth/register",
|
||||
{ name, email, password },
|
||||
),
|
||||
|
||||
me: () => get<{ success: boolean; data: UserInfo }>("/auth/me"),
|
||||
me: () => get<{ success: boolean; user: UserInfo }>("/auth/me"),
|
||||
|
||||
logout: () =>
|
||||
request<{ success: boolean; message: string }>("/auth/logout", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
sessions: () =>
|
||||
get<{ success: boolean; data: UserSessionRecord[] }>("/auth/sessions"),
|
||||
|
||||
revokeSession: (sessionId: string) =>
|
||||
del<{ success: boolean; data: { revokedCurrent: boolean } }>(
|
||||
`/auth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
|
||||
revokeOtherSessions: () =>
|
||||
post<{ success: boolean; data: { revokedCount: number } }>(
|
||||
"/auth/sessions/revoke-others",
|
||||
{},
|
||||
),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
patch<{ success: boolean; message: string }>("/auth/password", {
|
||||
currentPassword,
|
||||
@@ -908,7 +962,7 @@ export const auth = {
|
||||
token: string,
|
||||
body: { name?: string; password: string },
|
||||
) =>
|
||||
post<{ success: boolean; token: string; user: UserInfo }>(
|
||||
post<{ success: boolean; user: UserInfo }>(
|
||||
`/auth/invitations/${token}/accept`,
|
||||
body,
|
||||
),
|
||||
|
||||
+30
-4
@@ -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: "VIEWER",
|
||||
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": "VIEWER",
|
||||
"/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();
|
||||
};
|
||||
}
|
||||
+4
-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";
|
||||
@@ -30,23 +30,11 @@ import { gitProviderRoutes } from "./routes/git-providers";
|
||||
import { storageDestinationRoutes } from "./routes/storage-destinations";
|
||||
import { commitHistoryRoutes } from "./routes/commit-history";
|
||||
import { startS3StorageRetentionScheduler } from "./services/s3-storage-retention.service";
|
||||
import { startAuthSessionRetentionScheduler } from "./services/auth-session-retention.service";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4000");
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
|
||||
export function getJwtSecretOrThrow(env = process.env): string {
|
||||
const secret = env.JWT_SECRET?.trim();
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"JWT_SECRET must be configured before starting the backend",
|
||||
);
|
||||
}
|
||||
if (secret.length < 32) {
|
||||
throw new Error("JWT_SECRET must be at least 32 characters long");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function getRateLimitMax(env = process.env): number {
|
||||
const raw = Number.parseInt(env.RATE_LIMIT_MAX || "300", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 300;
|
||||
@@ -120,7 +108,6 @@ async function ensureDatabaseConnection() {
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const jwtSecret = getJwtSecretOrThrow();
|
||||
validateEncryptionConfiguration();
|
||||
await ensureDatabaseConnection();
|
||||
setDefaultSecurityHeaders();
|
||||
@@ -149,11 +136,7 @@ async function start() {
|
||||
}),
|
||||
});
|
||||
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
sign: { expiresIn: process.env.JWT_EXPIRES_IN || "7d" },
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(websocket);
|
||||
await app.register(multipart, { limits: { fileSize: 512 * 1024 * 1024, files: 1 } });
|
||||
|
||||
@@ -211,6 +194,7 @@ async function start() {
|
||||
// ── Start ─────────────────────────────────────────────
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
startAuthSessionRetentionScheduler();
|
||||
startS3StorageRetentionScheduler();
|
||||
console.log(
|
||||
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Transform } from "node:stream";
|
||||
|
||||
export const CUSTOM_RESTORE_SCRIPT_ARGS = [
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--file=-",
|
||||
] as const;
|
||||
|
||||
export const ATOMIC_PSQL_RESTORE_ARGS = [
|
||||
"--single-transaction",
|
||||
"--set",
|
||||
"ON_ERROR_STOP=1",
|
||||
] as const;
|
||||
|
||||
export function isUnsupportedRestoreSetting(line: string) {
|
||||
return line.trim() === "SET transaction_timeout = 0;";
|
||||
}
|
||||
|
||||
export function createRestoreSqlSanitizer() {
|
||||
let pending = "";
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
pending += chunk.toString();
|
||||
const lines = pending.split(/\r?\n/);
|
||||
pending = lines.pop() ?? "";
|
||||
const output = lines
|
||||
.filter((line) => !isUnsupportedRestoreSetting(line))
|
||||
.map((line) => `${line}\n`)
|
||||
.join("");
|
||||
callback(null, output);
|
||||
},
|
||||
flush(callback) {
|
||||
callback(null, isUnsupportedRestoreSetting(pending) ? "" : pending);
|
||||
},
|
||||
});
|
||||
}
|
||||
+201
-95
@@ -2,6 +2,14 @@ import crypto from "crypto";
|
||||
import { isIP } from "node:net";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import prisma from "../lib/prisma";
|
||||
import {
|
||||
clearSessionCookie,
|
||||
expireUserSession,
|
||||
getRequestUserAgent,
|
||||
getSessionToken,
|
||||
hashSessionToken,
|
||||
touchUserSession,
|
||||
} from "../services/auth-session.service";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
@@ -10,7 +18,8 @@ declare module "fastify" {
|
||||
organizationId?: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyPermissions?: string[];
|
||||
authMethod?: "jwt" | "api-key";
|
||||
sessionId?: string;
|
||||
authMethod?: "session" | "api-key";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +33,110 @@ function getHeaderValue(value: string | string[] | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const BROWSER_MUTATION_HEADER = "x-doktainer-request";
|
||||
|
||||
function normalizeHttpOrigin(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function firstForwardedValue(value: string | null): string | null {
|
||||
return value?.split(",")[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getEffectiveRequestOrigin(
|
||||
req: FastifyRequest,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | null {
|
||||
const trustProxy = env.TRUST_PROXY === "true";
|
||||
const forwardedHost = trustProxy
|
||||
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-host"]))
|
||||
: null;
|
||||
const forwardedProtocol = trustProxy
|
||||
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-proto"]))
|
||||
: null;
|
||||
const host = forwardedHost || getHeaderValue(req.headers.host);
|
||||
const protocol = forwardedProtocol || req.protocol;
|
||||
|
||||
if (!host || !protocol) return null;
|
||||
return normalizeHttpOrigin(`${protocol.replace(/:$/, "")}://${host}`);
|
||||
}
|
||||
|
||||
function getAllowedBrowserOrigins(
|
||||
req: FastifyRequest,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Set<string> {
|
||||
const origins = [
|
||||
env.NEXT_PUBLIC_PANEL_URL,
|
||||
env.FRONTEND_URL,
|
||||
...(env.CORS_ORIGINS ?? "").split(","),
|
||||
]
|
||||
.map(normalizeHttpOrigin)
|
||||
.filter((origin): origin is string => Boolean(origin));
|
||||
const requestOrigin = getEffectiveRequestOrigin(req, env);
|
||||
if (requestOrigin) origins.push(requestOrigin);
|
||||
return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]);
|
||||
}
|
||||
|
||||
export function enforceBrowserMutationProtection(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
|
||||
if (getHeaderValue(req.headers[BROWSER_MUTATION_HEADER]) !== "1") {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — missing browser request verification",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const fetchSite = getHeaderValue(req.headers["sec-fetch-site"]);
|
||||
if (fetchSite === "cross-site") {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — cross-site request rejected",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawOrigin = getHeaderValue(req.headers.origin);
|
||||
const origin = normalizeHttpOrigin(rawOrigin);
|
||||
const browserConfirmedSameOrigin = fetchSite === "same-origin";
|
||||
if (
|
||||
rawOrigin &&
|
||||
(!origin ||
|
||||
(!browserConfirmedSameOrigin &&
|
||||
!getAllowedBrowserOrigins(req, env).has(origin)))
|
||||
) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — request origin is not allowed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requireBrowserMutationVerification(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
enforceBrowserMutationProtection(req, reply);
|
||||
}
|
||||
|
||||
function extractApiKey(req: FastifyRequest): string | null {
|
||||
const headerKey = getHeaderValue(req.headers["x-api-key"]);
|
||||
if (headerKey) return headerKey;
|
||||
@@ -281,7 +394,6 @@ function enforceUserSecurityPolicies(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
settings: UserSecuritySettings,
|
||||
tokenIssuedAt?: number,
|
||||
): boolean {
|
||||
if (!settings) {
|
||||
return true;
|
||||
@@ -298,21 +410,6 @@ function enforceUserSecurityPolicies(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof tokenIssuedAt === "number" &&
|
||||
Number.isFinite(tokenIssuedAt) &&
|
||||
settings.sessionTimeoutMinutes > 0
|
||||
) {
|
||||
const maxSessionAgeMs = settings.sessionTimeoutMinutes * 60 * 1000;
|
||||
if (Date.now() - tokenIssuedAt * 1000 > maxSessionAgeMs) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — session expired",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -376,104 +473,113 @@ async function authenticateApiKey(
|
||||
enforceUserSecurityPolicies(req, reply, apiKey.user.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: verify JWT token from Authorization header
|
||||
* Attaches userId and userRole to request
|
||||
*/
|
||||
/** Authenticate an API key or an opaque database-backed browser session. */
|
||||
export async function authenticate(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const query = req.query as { token?: string } | undefined;
|
||||
const queryToken = query?.token;
|
||||
|
||||
if (queryToken && !req.headers.authorization) {
|
||||
req.headers.authorization = `Bearer ${queryToken}`;
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(req);
|
||||
if (apiKey) {
|
||||
await authenticateApiKey(apiKey, req, reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await req.jwtVerify<{
|
||||
sub: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
}>();
|
||||
const rawToken = getSessionToken(req);
|
||||
if (!rawToken) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — login session required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
activeOrganizationId: true,
|
||||
organizationMemberships: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
isDefault: true,
|
||||
const session = await prisma.userSession.findUnique({
|
||||
where: { tokenHash: hashSessionToken(rawToken) },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
activeOrganizationId: true,
|
||||
organizationMemberships: {
|
||||
select: { organizationId: true, isDefault: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
select: {
|
||||
sessionTimeoutMinutes: true,
|
||||
ipWhitelistEnabled: true,
|
||||
ipWhitelist: true,
|
||||
settings: {
|
||||
select: {
|
||||
sessionTimeoutMinutes: true,
|
||||
ipWhitelistEnabled: true,
|
||||
ipWhitelist: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — invalid or expired token",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const availableOrganizationIds = user.organizationMemberships.map(
|
||||
(membership) => membership.organizationId,
|
||||
);
|
||||
const requestedOrganizationId = extractOrganizationId(req);
|
||||
|
||||
if (
|
||||
requestedOrganizationId &&
|
||||
!availableOrganizationIds.includes(requestedOrganizationId)
|
||||
) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to that organization",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackOrganizationId =
|
||||
(user.activeOrganizationId &&
|
||||
availableOrganizationIds.includes(user.activeOrganizationId)
|
||||
? user.activeOrganizationId
|
||||
: null) ??
|
||||
user.organizationMemberships.find((membership) => membership.isDefault)
|
||||
?.organizationId ??
|
||||
availableOrganizationIds[0];
|
||||
|
||||
req.userId = user.id;
|
||||
req.userRole = user.role;
|
||||
req.organizationId = requestedOrganizationId ?? fallbackOrganizationId;
|
||||
req.authMethod = "jwt";
|
||||
|
||||
if (!enforceUserSecurityPolicies(req, reply, user.settings, payload.iat)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!session || session.revokedAt || !session.user.isActive) {
|
||||
clearSessionCookie(reply);
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — invalid or expired token",
|
||||
error: "Unauthorized — invalid or revoked login session",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const ipAddress = getClientIp(req) || null;
|
||||
const userAgent = getRequestUserAgent(req);
|
||||
if (session.idleExpiresAt <= now || session.absoluteExpiresAt <= now) {
|
||||
await expireUserSession(session.id, ipAddress, userAgent);
|
||||
clearSessionCookie(reply);
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — login session expired",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enforceBrowserMutationProtection(req, reply)) return;
|
||||
if (!enforceUserSecurityPolicies(req, reply, session.user.settings)) return;
|
||||
|
||||
const availableOrganizationIds = session.user.organizationMemberships.map(
|
||||
(membership) => membership.organizationId,
|
||||
);
|
||||
const requestedOrganizationId = extractOrganizationId(req);
|
||||
if (
|
||||
requestedOrganizationId &&
|
||||
!availableOrganizationIds.includes(requestedOrganizationId)
|
||||
) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to that organization",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackOrganizationId =
|
||||
(session.user.activeOrganizationId &&
|
||||
availableOrganizationIds.includes(session.user.activeOrganizationId)
|
||||
? session.user.activeOrganizationId
|
||||
: null) ??
|
||||
session.user.organizationMemberships.find(
|
||||
(membership) => membership.isDefault,
|
||||
)?.organizationId ??
|
||||
availableOrganizationIds[0];
|
||||
|
||||
req.userId = session.user.id;
|
||||
req.userRole = session.user.role;
|
||||
req.organizationId = requestedOrganizationId ?? fallbackOrganizationId;
|
||||
req.sessionId = session.id;
|
||||
req.authMethod = "session";
|
||||
|
||||
await touchUserSession({
|
||||
sessionId: session.id,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
sessionTimeoutMinutes: session.user.settings?.sessionTimeoutMinutes ?? 30,
|
||||
absoluteExpiresAt: session.absoluteExpiresAt,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+296
-27
@@ -1,10 +1,16 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import { authenticate } from "../middleware/auth";
|
||||
import {
|
||||
authenticate,
|
||||
getClientIp,
|
||||
ipMatchesWhitelist,
|
||||
requireBrowserMutationVerification,
|
||||
requireRole,
|
||||
} from "../middleware/auth";
|
||||
import { dispatchRuntimeNotification } from "../services/notification.service";
|
||||
import {
|
||||
createTwoFactorSetup,
|
||||
@@ -12,6 +18,18 @@ import {
|
||||
verifyEncryptedTwoFactorToken,
|
||||
} from "../services/two-factor.service";
|
||||
import { createOrganizationForUser } from "../lib/organizations";
|
||||
import {
|
||||
clearSessionCookie,
|
||||
canRevokeManagedSession,
|
||||
changePasswordAndRevokeOtherSessions,
|
||||
createUserSession,
|
||||
getManagedSessionStatus,
|
||||
getRequestUserAgent,
|
||||
revokeOtherUserSessions,
|
||||
revokeUserSession,
|
||||
setSessionCookie,
|
||||
} from "../services/auth-session.service";
|
||||
import { SessionEventType } from "@prisma/client";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -69,7 +87,6 @@ async function ensureUserSettings(userId: string) {
|
||||
}
|
||||
|
||||
function buildAuthResponse(
|
||||
app: FastifyInstance,
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
@@ -78,15 +95,7 @@ function buildAuthResponse(
|
||||
activeOrganizationId?: string | null;
|
||||
},
|
||||
) {
|
||||
const token = app.jwt.sign({
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -97,6 +106,31 @@ function buildAuthResponse(
|
||||
};
|
||||
}
|
||||
|
||||
async function establishLoginSession(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
user: Parameters<typeof buildAuthResponse>[0],
|
||||
) {
|
||||
const settings = await ensureUserSettings(user.id);
|
||||
const ipAddress = getClientIp(req) || null;
|
||||
const userAgent = getRequestUserAgent(req);
|
||||
const created = await createUserSession({
|
||||
userId: user.id,
|
||||
sessionTimeoutMinutes: settings.sessionTimeoutMinutes,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
setSessionCookie(
|
||||
reply,
|
||||
created.rawToken,
|
||||
created.session.absoluteExpiresAt,
|
||||
);
|
||||
return {
|
||||
...buildAuthResponse(user),
|
||||
sessionId: created.session.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPublicRegistrationStatus() {
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
@@ -118,7 +152,11 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post(
|
||||
"/login",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: authRateLimit } },
|
||||
{
|
||||
preHandler: [requireBrowserMutationVerification],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: authRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = LoginSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
@@ -135,6 +173,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
select: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecretEnc: true,
|
||||
ipWhitelistEnabled: true,
|
||||
ipWhitelist: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,7 +189,11 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Failed login attempt for \"${user.email}\"`,
|
||||
meta: { reason: user.isActive ? "unknown" : "inactive_user" },
|
||||
meta: {
|
||||
reason: user.isActive ? "unknown" : "inactive_user",
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
},
|
||||
});
|
||||
}
|
||||
return reply
|
||||
@@ -166,7 +210,11 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Failed login attempt for \"${user.email}\"`,
|
||||
meta: { reason: "invalid_password" },
|
||||
meta: {
|
||||
reason: "invalid_password",
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
},
|
||||
});
|
||||
|
||||
if (user.activeOrganizationId) {
|
||||
@@ -217,7 +265,11 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Invalid two-factor code for \"${user.email}\"`,
|
||||
meta: { reason: "invalid_totp" },
|
||||
meta: {
|
||||
reason: "invalid_totp",
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
},
|
||||
});
|
||||
|
||||
if (user.activeOrganizationId) {
|
||||
@@ -241,12 +293,35 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
.send({ success: false, error: "Invalid authentication code" });
|
||||
}
|
||||
|
||||
if (
|
||||
user.settings?.ipWhitelistEnabled &&
|
||||
user.settings.ipWhitelist.length > 0 &&
|
||||
!ipMatchesWhitelist(getClientIp(req), user.settings.ipWhitelist)
|
||||
) {
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
organizationId: user.activeOrganizationId ?? undefined,
|
||||
action: "LOGIN_IP_BLOCKED",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Login blocked by IP policy for "${user.email}"`,
|
||||
meta: {
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
},
|
||||
});
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Login is not permitted from this IP address",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
const auth = await establishLoginSession(req, reply, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
@@ -254,16 +329,20 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `User "${user.email}" logged in`,
|
||||
meta: { twoFactorEnabled },
|
||||
meta: { twoFactorEnabled, sessionId: auth.sessionId },
|
||||
});
|
||||
|
||||
return reply.send({ success: true, ...auth });
|
||||
return reply.send({ success: true, user: auth.user });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/register",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
|
||||
{
|
||||
preHandler: [requireBrowserMutationVerification],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: registerRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = RegisterSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
@@ -314,7 +393,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
const auth = await establishLoginSession(req, reply, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
@@ -324,7 +403,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
message: `New user registered: "${email}"`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ success: true, ...auth });
|
||||
return reply.status(201).send({ success: true, user: auth.user });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -396,7 +475,11 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post(
|
||||
"/invitations/:token/accept",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
|
||||
{
|
||||
preHandler: [requireBrowserMutationVerification],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: registerRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
const body = InvitationAcceptSchema.safeParse(req.body);
|
||||
@@ -504,7 +587,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return createdUser;
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
const auth = await establishLoginSession(req, reply, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
@@ -515,7 +598,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
message: `Invitation accepted for "${user.email}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, ...auth });
|
||||
return reply.send({ success: true, user: auth.user });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -541,6 +624,17 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post("/logout", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
if (req.sessionId && req.userId) {
|
||||
await revokeUserSession({
|
||||
sessionId: req.sessionId,
|
||||
userId: req.userId,
|
||||
reason: "USER_LOGOUT",
|
||||
eventType: SessionEventType.LOGOUT,
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
});
|
||||
}
|
||||
clearSessionCookie(reply);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "LOGOUT",
|
||||
@@ -551,6 +645,171 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return reply.send({ success: true, message: "Logged out successfully" });
|
||||
});
|
||||
|
||||
app.get("/sessions", { preHandler: [requireRole("VIEWER")] }, async (req, reply) => {
|
||||
if (!req.sessionId) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "A browser login session is required",
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
const sessions = await prisma.userSession.findMany({
|
||||
where: req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
createdIp: true,
|
||||
lastIp: true,
|
||||
userAgent: true,
|
||||
createdAt: true,
|
||||
lastSeenAt: true,
|
||||
idleExpiresAt: true,
|
||||
absoluteExpiresAt: true,
|
||||
revokedAt: true,
|
||||
revokeReason: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: sessions.map((session) => {
|
||||
const status = getManagedSessionStatus(session, now);
|
||||
return {
|
||||
...session,
|
||||
status,
|
||||
current: session.id === req.sessionId,
|
||||
canRevoke:
|
||||
status === "active" &&
|
||||
canRevokeManagedSession({
|
||||
actorUserId: req.userId!,
|
||||
actorRole: req.userRole!,
|
||||
targetUserId: session.user.id,
|
||||
currentSessionId: req.sessionId!,
|
||||
targetSessionId: session.id,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
app.delete(
|
||||
"/sessions/:sessionId",
|
||||
{ preHandler: [requireRole("VIEWER")] },
|
||||
async (req, reply) => {
|
||||
if (!req.sessionId) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "A browser login session is required",
|
||||
});
|
||||
}
|
||||
const params = z
|
||||
.object({ sessionId: z.string().trim().min(1).max(64) })
|
||||
.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: params.error.flatten() });
|
||||
}
|
||||
if (params.data.sessionId === req.sessionId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "The current session cannot be revoked; use logout instead",
|
||||
});
|
||||
}
|
||||
|
||||
const targetSession = await prisma.userSession.findFirst({
|
||||
where: {
|
||||
id: params.data.sessionId,
|
||||
...(req.userRole === "SUPER_ADMIN" ? {} : { userId: req.userId! }),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
revokedAt: true,
|
||||
revokeReason: true,
|
||||
idleExpiresAt: true,
|
||||
absoluteExpiresAt: true,
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
if (!targetSession) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Session not found" });
|
||||
}
|
||||
if (getManagedSessionStatus(targetSession) !== "active") {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Active session not found" });
|
||||
}
|
||||
|
||||
const revoked = await revokeUserSession({
|
||||
sessionId: params.data.sessionId,
|
||||
userId: targetSession.userId,
|
||||
reason: "USER_REVOKED",
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
});
|
||||
if (!revoked) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Active session not found" });
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "SESSION_REVOKE",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Login session revoked for "${targetSession.user.email}"`,
|
||||
meta: {
|
||||
sessionId: params.data.sessionId,
|
||||
targetUserId: targetSession.userId,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: { revokedCurrent: false } });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/sessions/revoke-others",
|
||||
{ preHandler: [authenticate] },
|
||||
async (req, reply) => {
|
||||
if (!req.sessionId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "A browser login session is required",
|
||||
});
|
||||
}
|
||||
const revokedCount = await revokeOtherUserSessions({
|
||||
userId: req.userId!,
|
||||
currentSessionId: req.sessionId,
|
||||
reason: "USER_REVOKED_OTHER_SESSIONS",
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
});
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "SESSION_REVOKE_OTHERS",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `${revokedCount} other login session${revokedCount === 1 ? "" : "s"} revoked`,
|
||||
meta: { revokedCount },
|
||||
});
|
||||
return reply.send({ success: true, data: { revokedCount } });
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/password",
|
||||
{
|
||||
@@ -586,9 +845,18 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
if (!req.sessionId) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "A browser login session is required to change a password",
|
||||
});
|
||||
}
|
||||
const revokedCount = await changePasswordAndRevokeOtherSessions({
|
||||
userId: user.id,
|
||||
currentSessionId: req.sessionId,
|
||||
passwordHash,
|
||||
ipAddress: getClientIp(req) || null,
|
||||
userAgent: getRequestUserAgent(req),
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
@@ -597,6 +865,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: "Password changed",
|
||||
meta: { revokedSessionCount: revokedCount },
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,11 @@ import { promisify } from "node:util";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { z } from "zod";
|
||||
import { resolvePublicAppOrigin } from "../lib/public-url";
|
||||
import {
|
||||
ATOMIC_PSQL_RESTORE_ARGS,
|
||||
createRestoreSqlSanitizer,
|
||||
CUSTOM_RESTORE_SCRIPT_ARGS,
|
||||
} from "../lib/database-restore";
|
||||
import prisma from "../lib/prisma";
|
||||
import { decrypt, encrypt } from "../lib/crypto";
|
||||
import { authenticate, requireRole } from "../middleware/auth";
|
||||
@@ -714,7 +719,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
options: {
|
||||
inputPath?: string;
|
||||
outputPath?: string;
|
||||
allowTransactionTimeoutCompatibilityError?: boolean;
|
||||
connectToDatabase?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||
@@ -771,26 +776,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
? rejectedResult.reason.message
|
||||
: "";
|
||||
|
||||
const onlyTransactionTimeoutCompatibilityError =
|
||||
options.allowTransactionTimeoutCompatibilityError &&
|
||||
stderr.includes(
|
||||
'unrecognized configuration parameter "transaction_timeout"',
|
||||
) &&
|
||||
stderr
|
||||
.replace(
|
||||
/pg_restore: error: could not execute query: ERROR:\s+unrecognized configuration parameter "transaction_timeout"[\r\n]+Command was:\s*SET transaction_timeout = 0;\s*/g,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
/pg_restore: warning: errors ignored on restore: 1\s*/g,
|
||||
"",
|
||||
)
|
||||
.trim().length === 0;
|
||||
|
||||
if (
|
||||
!rejectedResult &&
|
||||
(exitCode === 0 || onlyTransactionTimeoutCompatibilityError)
|
||||
) {
|
||||
if (!rejectedResult && exitCode === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
@@ -806,20 +792,29 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const databaseContainer =
|
||||
process.env.DATABASE_BACKUP_CONTAINER?.trim() ||
|
||||
`${process.env.COMPOSE_PROJECT_NAME?.trim() || "doktainer"}-postgres`;
|
||||
const databaseArgs =
|
||||
options.connectToDatabase === false
|
||||
? []
|
||||
: ["--dbname", databaseCliUrl];
|
||||
const dockerArgs = [
|
||||
"exec",
|
||||
"-i",
|
||||
databaseContainer,
|
||||
command,
|
||||
"--dbname",
|
||||
databaseCliUrl,
|
||||
...databaseArgs,
|
||||
...args,
|
||||
];
|
||||
const localBinary = await findLocalPostgresBinary(command);
|
||||
const runLocal = async () => {
|
||||
if (!localBinary) return false;
|
||||
const localArgs = ["--dbname", databaseCliUrl, ...args];
|
||||
if (options.inputPath) localArgs.push(options.inputPath);
|
||||
const localArgs = [...databaseArgs, ...args];
|
||||
if (options.inputPath) {
|
||||
if (command === "psql") {
|
||||
localArgs.push("--file", options.inputPath);
|
||||
} else {
|
||||
localArgs.push(options.inputPath);
|
||||
}
|
||||
}
|
||||
await run(
|
||||
localBinary,
|
||||
localArgs,
|
||||
@@ -898,6 +893,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
.status(400)
|
||||
.send({ success: false, error: "Select a PostgreSQL dump file" });
|
||||
const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`);
|
||||
const restoredSqlPath = `${filePath}.sql`;
|
||||
const sanitizedSqlPath = `${filePath}.sanitized.sql`;
|
||||
try {
|
||||
await pipeline(upload.file, createWriteStream(filePath));
|
||||
const header = Buffer.alloc(5);
|
||||
@@ -909,24 +906,43 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const isCustomArchive = header.toString("ascii") === "PGDMP";
|
||||
if (isCustomArchive) {
|
||||
await runDatabaseCommand(
|
||||
"pg_restore",
|
||||
[...CUSTOM_RESTORE_SCRIPT_ARGS],
|
||||
{
|
||||
inputPath: filePath,
|
||||
outputPath: restoredSqlPath,
|
||||
connectToDatabase: false,
|
||||
},
|
||||
);
|
||||
await pipeline(
|
||||
createReadStream(restoredSqlPath),
|
||||
createRestoreSqlSanitizer(),
|
||||
createWriteStream(sanitizedSqlPath),
|
||||
);
|
||||
}
|
||||
|
||||
await runDatabaseCommand(
|
||||
isCustomArchive ? "pg_restore" : "psql",
|
||||
isCustomArchive
|
||||
? ["--clean", "--if-exists", "--no-owner"]
|
||||
: ["--set", "ON_ERROR_STOP=1"],
|
||||
{
|
||||
inputPath: filePath,
|
||||
allowTransactionTimeoutCompatibilityError: isCustomArchive,
|
||||
},
|
||||
"psql",
|
||||
[...ATOMIC_PSQL_RESTORE_ARGS],
|
||||
{ inputPath: isCustomArchive ? sanitizedSqlPath : filePath },
|
||||
);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "DATABASE_RESTORE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: "Doktainer database restored from uploaded dump",
|
||||
});
|
||||
try {
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "DATABASE_RESTORE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: "Doktainer database restored from uploaded dump",
|
||||
});
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ err: error },
|
||||
"Database restore completed, but its audit event could not be recorded",
|
||||
);
|
||||
}
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Database restore completed",
|
||||
@@ -942,7 +958,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
: "Database restore failed",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(filePath, { force: true });
|
||||
await Promise.all([
|
||||
fs.rm(filePath, { force: true }),
|
||||
fs.rm(restoredSqlPath, { force: true }),
|
||||
fs.rm(sanitizedSqlPath, { force: true }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const SESSION_HISTORY_RETENTION_DAYS = 180;
|
||||
export const SESSION_RETENTION_BATCH_SIZE = 1000;
|
||||
const SESSION_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let cleanupInProgress = false;
|
||||
|
||||
export function getSessionHistoryCutoff(now = new Date()): Date {
|
||||
return new Date(
|
||||
now.getTime() - SESSION_HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionHistoryRetentionFilter(now = new Date()) {
|
||||
const cutoff = getSessionHistoryCutoff(now);
|
||||
return {
|
||||
OR: [
|
||||
{ revokedAt: { lte: cutoff } },
|
||||
{
|
||||
revokedAt: null,
|
||||
OR: [
|
||||
{ idleExpiresAt: { lte: cutoff } },
|
||||
{ absoluteExpiresAt: { lte: cutoff } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAuthSessionRetentionCleanup(now = new Date()) {
|
||||
if (cleanupInProgress) return { skipped: true, deletedSessions: 0 };
|
||||
cleanupInProgress = true;
|
||||
|
||||
try {
|
||||
const sessions = await prisma.userSession.findMany({
|
||||
where: getSessionHistoryRetentionFilter(now),
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: SESSION_RETENTION_BATCH_SIZE,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return { skipped: false, deletedSessions: 0 };
|
||||
}
|
||||
|
||||
const deleted = await prisma.userSession.deleteMany({
|
||||
where: { id: { in: sessions.map((session) => session.id) } },
|
||||
});
|
||||
return { skipped: false, deletedSessions: deleted.count };
|
||||
} finally {
|
||||
cleanupInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startAuthSessionRetentionScheduler() {
|
||||
const runCleanup = async () => {
|
||||
try {
|
||||
await runAuthSessionRetentionCleanup();
|
||||
} catch (error) {
|
||||
console.error("[Session retention] Cleanup failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(() => void runCleanup(), SESSION_RETENTION_INTERVAL_MS);
|
||||
timer.unref?.();
|
||||
void runCleanup();
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { SessionEventType } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const DEVELOPMENT_SESSION_COOKIE = "doktainer_session";
|
||||
const PRODUCTION_SESSION_COOKIE = "__Host-doktainer_session";
|
||||
const DEFAULT_ABSOLUTE_SESSION_HOURS = 7 * 24;
|
||||
const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const MAX_USER_AGENT_LENGTH = 512;
|
||||
const MAX_ACTIVE_SESSIONS_PER_USER = 20;
|
||||
|
||||
export function canRevokeManagedSession(input: {
|
||||
actorUserId: string;
|
||||
actorRole: string;
|
||||
targetUserId: string;
|
||||
currentSessionId: string;
|
||||
targetSessionId: string;
|
||||
}): boolean {
|
||||
if (input.currentSessionId === input.targetSessionId) return false;
|
||||
return (
|
||||
input.actorUserId === input.targetUserId ||
|
||||
input.actorRole === "SUPER_ADMIN"
|
||||
);
|
||||
}
|
||||
|
||||
export type ManagedSessionStatus = "active" | "expired" | "revoked";
|
||||
|
||||
export function getManagedSessionStatus(
|
||||
session: {
|
||||
revokedAt: Date | null;
|
||||
revokeReason: string | null;
|
||||
idleExpiresAt: Date;
|
||||
absoluteExpiresAt: Date;
|
||||
},
|
||||
now = new Date(),
|
||||
): ManagedSessionStatus {
|
||||
if (session.revokedAt) {
|
||||
return session.revokeReason === "EXPIRED" ? "expired" : "revoked";
|
||||
}
|
||||
return session.idleExpiresAt <= now || session.absoluteExpiresAt <= now
|
||||
? "expired"
|
||||
: "active";
|
||||
}
|
||||
|
||||
export function getSessionCookieName(env = process.env): string {
|
||||
return env.NODE_ENV === "production"
|
||||
? PRODUCTION_SESSION_COOKIE
|
||||
: DEVELOPMENT_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
function getAbsoluteSessionHours(env = process.env): number {
|
||||
const parsed = Number.parseInt(env.SESSION_ABSOLUTE_TIMEOUT_HOURS ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_ABSOLUTE_SESSION_HOURS;
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function normalizeSessionUserAgent(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
||||
return normalized ? normalized.slice(0, MAX_USER_AGENT_LENGTH) : null;
|
||||
}
|
||||
|
||||
export function getRequestUserAgent(req: FastifyRequest): string | null {
|
||||
return normalizeSessionUserAgent(req.headers["user-agent"]);
|
||||
}
|
||||
|
||||
function sessionCookieOptions(expires?: Date) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
priority: "high" as const,
|
||||
...(expires ? { expires } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionCookie(
|
||||
reply: FastifyReply,
|
||||
token: string,
|
||||
expiresAt: Date,
|
||||
): void {
|
||||
reply.setCookie(getSessionCookieName(), token, sessionCookieOptions(expiresAt));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(reply: FastifyReply): void {
|
||||
for (const cookieName of [
|
||||
DEVELOPMENT_SESSION_COOKIE,
|
||||
PRODUCTION_SESSION_COOKIE,
|
||||
]) {
|
||||
reply.clearCookie(cookieName, sessionCookieOptions());
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionToken(req: FastifyRequest): string | null {
|
||||
return req.cookies?.[getSessionCookieName()]?.trim() || null;
|
||||
}
|
||||
|
||||
export async function createUserSession(input: {
|
||||
userId: string;
|
||||
sessionTimeoutMinutes: number;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const absoluteExpiresAt = new Date(
|
||||
now.getTime() + getAbsoluteSessionHours() * 60 * 60 * 1000,
|
||||
);
|
||||
const idleExpiresAt =
|
||||
input.sessionTimeoutMinutes > 0
|
||||
? new Date(now.getTime() + input.sessionTimeoutMinutes * 60 * 1000)
|
||||
: absoluteExpiresAt;
|
||||
const rawToken = crypto.randomBytes(32).toString("base64url");
|
||||
|
||||
const session = await prisma.userSession.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
tokenHash: hashSessionToken(rawToken),
|
||||
createdIp: input.ipAddress,
|
||||
lastIp: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
idleExpiresAt,
|
||||
absoluteExpiresAt,
|
||||
events: {
|
||||
create: {
|
||||
type: SessionEventType.LOGIN,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
absoluteExpiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const overflowSessions = await prisma.userSession.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
revokedAt: null,
|
||||
idleExpiresAt: { gt: now },
|
||||
absoluteExpiresAt: { gt: now },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: MAX_ACTIVE_SESSIONS_PER_USER,
|
||||
select: { id: true },
|
||||
});
|
||||
await Promise.all(
|
||||
overflowSessions.map((overflowSession) =>
|
||||
revokeUserSession({
|
||||
sessionId: overflowSession.id,
|
||||
userId: input.userId,
|
||||
reason: "ACTIVE_SESSION_LIMIT",
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { rawToken, session };
|
||||
}
|
||||
|
||||
export async function expireUserSession(
|
||||
sessionId: string,
|
||||
ipAddress: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const updated = await tx.userSession.updateMany({
|
||||
where: { id: sessionId, revokedAt: null },
|
||||
data: { revokedAt: now, revokeReason: "EXPIRED" },
|
||||
});
|
||||
if (updated.count > 0) {
|
||||
await tx.userSessionEvent.create({
|
||||
data: {
|
||||
sessionId,
|
||||
type: SessionEventType.EXPIRED,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeUserSession(input: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
reason: string;
|
||||
eventType?: SessionEventType;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const updated = await tx.userSession.updateMany({
|
||||
where: { id: input.sessionId, userId: input.userId, revokedAt: null },
|
||||
data: { revokedAt: now, revokeReason: input.reason },
|
||||
});
|
||||
if (updated.count === 0) return false;
|
||||
|
||||
await tx.userSessionEvent.create({
|
||||
data: {
|
||||
sessionId: input.sessionId,
|
||||
type: input.eventType ?? SessionEventType.REVOKED,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
details: { reason: input.reason },
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeOtherUserSessions(input: {
|
||||
userId: string;
|
||||
currentSessionId: string;
|
||||
reason: string;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const sessions = await tx.userSession.updateManyAndReturn({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
id: { not: input.currentSessionId },
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now, revokeReason: input.reason },
|
||||
select: { id: true },
|
||||
});
|
||||
if (sessions.length === 0) return 0;
|
||||
|
||||
await tx.userSessionEvent.createMany({
|
||||
data: sessions.map((session) => ({
|
||||
sessionId: session.id,
|
||||
type: SessionEventType.REVOKED,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
details: { reason: input.reason },
|
||||
})),
|
||||
});
|
||||
return sessions.length;
|
||||
});
|
||||
}
|
||||
|
||||
export async function changePasswordAndRevokeOtherSessions(input: {
|
||||
userId: string;
|
||||
currentSessionId: string;
|
||||
passwordHash: string;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { passwordHash: input.passwordHash },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const sessions = await tx.userSession.updateManyAndReturn({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
id: { not: input.currentSessionId },
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now, revokeReason: "PASSWORD_CHANGED" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (sessions.length > 0) {
|
||||
await tx.userSessionEvent.createMany({
|
||||
data: sessions.map((session) => ({
|
||||
sessionId: session.id,
|
||||
type: SessionEventType.REVOKED,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
details: { reason: "PASSWORD_CHANGED" },
|
||||
})),
|
||||
});
|
||||
}
|
||||
return sessions.length;
|
||||
});
|
||||
}
|
||||
|
||||
export async function touchUserSession(input: {
|
||||
sessionId: string;
|
||||
lastSeenAt: Date;
|
||||
sessionTimeoutMinutes: number;
|
||||
absoluteExpiresAt: Date;
|
||||
ipAddress: string | null;
|
||||
}): Promise<void> {
|
||||
const now = new Date();
|
||||
if (now.getTime() - input.lastSeenAt.getTime() < SESSION_TOUCH_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.userSession.updateMany({
|
||||
where: { id: input.sessionId, revokedAt: null },
|
||||
data: {
|
||||
lastSeenAt: now,
|
||||
lastIp: input.ipAddress,
|
||||
idleExpiresAt:
|
||||
input.sessionTimeoutMinutes > 0
|
||||
? new Date(
|
||||
now.getTime() + input.sessionTimeoutMinutes * 60 * 1000,
|
||||
)
|
||||
: input.absoluteExpiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
@@ -99,19 +100,81 @@ export async function deleteS3Objects(
|
||||
if (keys.length === 0) return;
|
||||
|
||||
const client = createS3Client(destination);
|
||||
await deleteS3ObjectsWithClient(client, destination.bucket, keys);
|
||||
}
|
||||
|
||||
type S3DeleteClient = Pick<S3Client, "send">;
|
||||
|
||||
export function isMissingContentMd5Error(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
|
||||
const details = error as {
|
||||
Code?: unknown;
|
||||
code?: unknown;
|
||||
name?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const code = String(details.Code ?? details.code ?? details.name ?? "");
|
||||
const message = String(details.message ?? "");
|
||||
|
||||
return (
|
||||
(code === "InvalidRequest" || code === "MissingContentMD5") &&
|
||||
/content-?md5/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteS3ObjectsIndividually(
|
||||
client: S3DeleteClient,
|
||||
bucket: string,
|
||||
keys: string[],
|
||||
) {
|
||||
const failedKeys: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
} catch {
|
||||
failedKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to delete ${failedKeys.length} S3 object(s): ${failedKeys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteS3ObjectsWithClient(
|
||||
client: S3DeleteClient,
|
||||
bucket: string,
|
||||
keys: string[],
|
||||
) {
|
||||
for (let offset = 0; offset < keys.length; offset += 1000) {
|
||||
const identifiers: ObjectIdentifier[] = keys
|
||||
.slice(offset, offset + 1000)
|
||||
.map((key) => ({ Key: key }));
|
||||
|
||||
const response = await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: destination.bucket,
|
||||
Delete: { Objects: identifiers, Quiet: true },
|
||||
}),
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: identifiers, Quiet: true },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMissingContentMd5Error(error)) throw error;
|
||||
|
||||
if (response.Errors && response.Errors.length > 0) {
|
||||
await deleteS3ObjectsIndividually(
|
||||
client,
|
||||
bucket,
|
||||
identifiers.map(({ Key }) => Key).filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("Errors" in response && response.Errors && response.Errors.length > 0) {
|
||||
const failedKeys = response.Errors.map((error) => error.Key).filter(
|
||||
(key): key is string => Boolean(key),
|
||||
);
|
||||
@@ -121,4 +184,3 @@ export async function deleteS3Objects(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { resolveApiExampleUrl } from "../src/lib/api-example-url";
|
||||
|
||||
test("uses the Panel Access URL stored in settings", () => {
|
||||
assert.equal(
|
||||
resolveApiExampleUrl({
|
||||
panelUrl: "https://panel.example.com/",
|
||||
browserOrigin: "http://192.168.1.20:3000",
|
||||
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||
}),
|
||||
"https://panel.example.com/api/v1/servers",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the active browser origin when the stored panel URL is a stale loopback default", () => {
|
||||
assert.equal(
|
||||
resolveApiExampleUrl({
|
||||
panelUrl: "http://localhost:3000",
|
||||
browserOrigin: "http://192.168.1.20:3000",
|
||||
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||
}),
|
||||
"http://192.168.1.20:3000/api/v1/servers",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the stored loopback URL during local access", () => {
|
||||
assert.equal(
|
||||
resolveApiExampleUrl({
|
||||
panelUrl: "http://localhost:3000",
|
||||
browserOrigin: "http://127.0.0.1:3000",
|
||||
apiBaseUrl: "http://localhost:4000/api/v1",
|
||||
}),
|
||||
"http://localhost:3000/api/v1/servers",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to the configured API base when no public panel origin is available", () => {
|
||||
assert.equal(
|
||||
resolveApiExampleUrl({
|
||||
panelUrl: "not-a-url",
|
||||
browserOrigin: null,
|
||||
apiBaseUrl: "https://api.example.com/api/v1/",
|
||||
}),
|
||||
"https://api.example.com/api/v1/servers",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getSessionHistoryCutoff,
|
||||
getSessionHistoryRetentionFilter,
|
||||
SESSION_HISTORY_RETENTION_DAYS,
|
||||
SESSION_RETENTION_BATCH_SIZE,
|
||||
} from "../../src/server/services/auth-session-retention.service";
|
||||
|
||||
test("session history retention keeps 180 days and deletes in bounded batches", () => {
|
||||
assert.equal(SESSION_HISTORY_RETENTION_DAYS, 180);
|
||||
assert.equal(SESSION_RETENTION_BATCH_SIZE, 1000);
|
||||
assert.equal(
|
||||
getSessionHistoryCutoff(new Date("2026-09-12T00:00:00.000Z")).toISOString(),
|
||||
"2026-03-16T00:00:00.000Z",
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSessionHistoryRetentionFilter(new Date("2026-09-12T00:00:00.000Z")),
|
||||
{
|
||||
OR: [
|
||||
{ revokedAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
|
||||
{
|
||||
revokedAt: null,
|
||||
OR: [
|
||||
{ idleExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
|
||||
{ absoluteExpiresAt: { lte: new Date("2026-03-16T00:00:00.000Z") } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,16 @@ test("redeploy endpoints require container write authorization", () => {
|
||||
assert.match(routes.slice(previewStart, previewStart + 180), /containerReadAccess/);
|
||||
});
|
||||
|
||||
test("internal deployment jobs retain browser session authentication", () => {
|
||||
const helperStart = routes.indexOf("function buildInternalJobHeaders");
|
||||
assert.ok(helperStart >= 0, "internal job header builder should exist");
|
||||
|
||||
const helper = routes.slice(helperStart, helperStart + 900);
|
||||
assert.match(helper, /"x-doktainer-request": "1"/);
|
||||
assert.match(helper, /const cookie = headers\.cookie/);
|
||||
assert.match(helper, /nextHeaders\.cookie = cookie/);
|
||||
});
|
||||
|
||||
test("redeploy restores the stored current revision without invoking source rebuild", () => {
|
||||
assert.match(redeployService, /redeployContainerCurrentRevision/);
|
||||
assert.match(redeployService, /getRollbackSnapshot\(input\)/);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ATOMIC_PSQL_RESTORE_ARGS,
|
||||
createRestoreSqlSanitizer,
|
||||
CUSTOM_RESTORE_SCRIPT_ARGS,
|
||||
isUnsupportedRestoreSetting,
|
||||
} from "../../src/server/lib/database-restore";
|
||||
|
||||
async function sanitizeChunks(chunks: string[]) {
|
||||
const output: Buffer[] = [];
|
||||
const sanitizer = createRestoreSqlSanitizer();
|
||||
sanitizer.on("data", (chunk: Buffer) => output.push(chunk));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
Readable.from(chunks).pipe(sanitizer).once("finish", resolve).once("error", reject);
|
||||
});
|
||||
return Buffer.concat(output).toString("utf8");
|
||||
}
|
||||
|
||||
test("database restores execute atomically and stop on SQL errors", () => {
|
||||
assert.deepEqual(ATOMIC_PSQL_RESTORE_ARGS, [
|
||||
"--single-transaction",
|
||||
"--set",
|
||||
"ON_ERROR_STOP=1",
|
||||
]);
|
||||
assert.deepEqual(CUSTOM_RESTORE_SCRIPT_ARGS, [
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--file=-",
|
||||
]);
|
||||
});
|
||||
|
||||
test("restore sanitizer removes only the unsupported transaction timeout setting", async () => {
|
||||
assert.equal(isUnsupportedRestoreSetting("SET transaction_timeout = 0;"), true);
|
||||
assert.equal(isUnsupportedRestoreSetting("SET statement_timeout = 0;"), false);
|
||||
assert.equal(
|
||||
await sanitizeChunks([
|
||||
"SET statement_timeout = 0;\nSET trans",
|
||||
"action_timeout = 0;\nCREATE TABLE example (id text);\n",
|
||||
]),
|
||||
"SET statement_timeout = 0;\nCREATE TABLE example (id text);\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("container startup validates id invariants before any pending migration", () => {
|
||||
const entrypoint = readFileSync("docker-entrypoint.sh", "utf8");
|
||||
|
||||
assert.doesNotMatch(entrypoint, /20260909_add_database_sessions/);
|
||||
assert.match(entrypoint, /pendingMigrationNames\.length > 0/);
|
||||
assert.match(entrypoint, /table_class\.relname <> '_prisma_migrations'/);
|
||||
assert.match(entrypoint, /i\.indisunique/);
|
||||
assert.match(entrypoint, /attribute\.attnotnull/);
|
||||
assert.match(entrypoint, /Database integrity check failed/);
|
||||
assert.match(entrypoint, /resolve duplicate IDs before applying migrations/);
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
enforceBrowserMutationProtection,
|
||||
} from "../../src/server/middleware/auth";
|
||||
import {
|
||||
canRevokeManagedSession,
|
||||
getManagedSessionStatus,
|
||||
getSessionCookieName,
|
||||
hashSessionToken,
|
||||
normalizeSessionUserAgent,
|
||||
} from "../../src/server/services/auth-session.service";
|
||||
|
||||
function createReplyRecorder() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
send(payload: unknown) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("session tokens are stored as deterministic hashes", () => {
|
||||
const rawToken = "raw-session-token";
|
||||
const tokenHash = hashSessionToken(rawToken);
|
||||
|
||||
assert.equal(tokenHash.length, 64);
|
||||
assert.equal(tokenHash, hashSessionToken(rawToken));
|
||||
assert.notEqual(tokenHash, rawToken);
|
||||
});
|
||||
|
||||
test("production uses a host-bound session cookie name", () => {
|
||||
assert.equal(
|
||||
getSessionCookieName({ NODE_ENV: "production" } as NodeJS.ProcessEnv),
|
||||
"__Host-doktainer_session",
|
||||
);
|
||||
assert.equal(
|
||||
getSessionCookieName({ NODE_ENV: "development" } as NodeJS.ProcessEnv),
|
||||
"doktainer_session",
|
||||
);
|
||||
});
|
||||
|
||||
test("session user agents drop control characters and remain bounded", () => {
|
||||
const normalized = normalizeSessionUserAgent(`Browser\n${"x".repeat(600)}`);
|
||||
|
||||
assert.equal(normalized?.includes("\n"), false);
|
||||
assert.equal(normalized?.length, 512);
|
||||
});
|
||||
|
||||
test("browser mutation protection requires the application header", () => {
|
||||
const reply = createReplyRecorder();
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{ method: "POST", headers: {} } as never,
|
||||
reply as never,
|
||||
);
|
||||
|
||||
assert.equal(allowed, false);
|
||||
assert.equal(reply.statusCode, 403);
|
||||
});
|
||||
|
||||
test("browser mutation protection rejects cross-site requests", () => {
|
||||
const reply = createReplyRecorder();
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-doktainer-request": "1",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
} as never,
|
||||
reply as never,
|
||||
);
|
||||
|
||||
assert.equal(allowed, false);
|
||||
assert.equal(reply.statusCode, 403);
|
||||
});
|
||||
|
||||
test("safe methods and verified same-origin mutations are accepted", () => {
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
{ method: "GET", headers: {} } as never,
|
||||
createReplyRecorder() as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-doktainer-request": "1",
|
||||
"sec-fetch-site": "same-origin",
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
} as never,
|
||||
createReplyRecorder() as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("browser-confirmed same-origin mutations use the active origin without env configuration", () => {
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "POST",
|
||||
protocol: "http",
|
||||
headers: {
|
||||
host: "127.0.0.1:4000",
|
||||
origin: "https://panel.example.com",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-doktainer-request": "1",
|
||||
},
|
||||
} as never,
|
||||
createReplyRecorder() as never,
|
||||
{} as NodeJS.ProcessEnv,
|
||||
);
|
||||
|
||||
assert.equal(allowed, true);
|
||||
});
|
||||
|
||||
test("verified mutations accept the effective custom-domain request origin", () => {
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "POST",
|
||||
protocol: "https",
|
||||
headers: {
|
||||
host: "panel.example.com",
|
||||
origin: "https://panel.example.com",
|
||||
"sec-fetch-site": "same-site",
|
||||
"x-doktainer-request": "1",
|
||||
},
|
||||
} as never,
|
||||
createReplyRecorder() as never,
|
||||
{
|
||||
FRONTEND_URL: "http://localhost:3000",
|
||||
TRUST_PROXY: "false",
|
||||
} as NodeJS.ProcessEnv,
|
||||
);
|
||||
|
||||
assert.equal(allowed, true);
|
||||
});
|
||||
|
||||
test("forwarded custom-domain origin is trusted only behind an enabled proxy", () => {
|
||||
const request = {
|
||||
method: "POST",
|
||||
protocol: "http",
|
||||
headers: {
|
||||
host: "127.0.0.1:4000",
|
||||
origin: "https://panel.example.com",
|
||||
"sec-fetch-site": "same-site",
|
||||
"x-doktainer-request": "1",
|
||||
"x-forwarded-host": "panel.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
} as never;
|
||||
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
request,
|
||||
createReplyRecorder() as never,
|
||||
{ TRUST_PROXY: "true" } as NodeJS.ProcessEnv,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const untrustedReply = createReplyRecorder();
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
request,
|
||||
untrustedReply as never,
|
||||
{ TRUST_PROXY: "false" } as NodeJS.ProcessEnv,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(untrustedReply.statusCode, 403);
|
||||
});
|
||||
|
||||
test("only owners and super admins can revoke a managed login session", () => {
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "operator-1",
|
||||
actorRole: "OPERATOR",
|
||||
targetUserId: "user-2",
|
||||
currentSessionId: "session-1",
|
||||
targetSessionId: "session-2",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "operator-1",
|
||||
actorRole: "OPERATOR",
|
||||
targetUserId: "operator-1",
|
||||
currentSessionId: "session-1",
|
||||
targetSessionId: "session-2",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "admin-1",
|
||||
actorRole: "SUPER_ADMIN",
|
||||
targetUserId: "user-2",
|
||||
currentSessionId: "session-1",
|
||||
targetSessionId: "session-2",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "admin-1",
|
||||
actorRole: "SUPER_ADMIN",
|
||||
targetUserId: "admin-1",
|
||||
currentSessionId: "session-1",
|
||||
targetSessionId: "session-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("session status is derived without persisting expiry from list requests", () => {
|
||||
const now = new Date("2026-09-12T12:00:00.000Z");
|
||||
const activeSession = {
|
||||
revokedAt: null,
|
||||
revokeReason: null,
|
||||
idleExpiresAt: new Date("2026-09-12T13:00:00.000Z"),
|
||||
absoluteExpiresAt: new Date("2026-09-13T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
assert.equal(getManagedSessionStatus(activeSession, now), "active");
|
||||
assert.equal(
|
||||
getManagedSessionStatus(
|
||||
{ ...activeSession, idleExpiresAt: new Date("2026-09-12T11:59:59.000Z") },
|
||||
now,
|
||||
),
|
||||
"expired",
|
||||
);
|
||||
assert.equal(
|
||||
getManagedSessionStatus(
|
||||
{
|
||||
...activeSession,
|
||||
revokedAt: new Date("2026-09-12T10:00:00.000Z"),
|
||||
revokeReason: "USER_REVOKED",
|
||||
},
|
||||
now,
|
||||
),
|
||||
"revoked",
|
||||
);
|
||||
});
|
||||
|
||||
test("session management is a dedicated protected page before Users and RBAC", () => {
|
||||
const navigation = readFileSync("src/lib/navigation.ts", "utf8");
|
||||
const permissions = readFileSync("src/lib/permissions.ts", "utf8");
|
||||
const dashboard = readFileSync("src/app/page.tsx", "utf8");
|
||||
const page = readFileSync("src/app/sessions/page.tsx", "utf8");
|
||||
const routes = readFileSync("src/server/routes/auth.ts", "utf8");
|
||||
|
||||
assert.ok(navigation.indexOf('href: "/sessions"') < navigation.indexOf('href: "/users"'));
|
||||
assert.match(permissions, /"\/sessions": "VIEWER"/);
|
||||
assert.doesNotMatch(dashboard, /LoginSessionsPanel/);
|
||||
assert.match(page, /route="\/sessions"/);
|
||||
assert.match(page, /All statuses/);
|
||||
assert.match(page, /All users/);
|
||||
assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("VIEWER"\)\]/);
|
||||
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("VIEWER"\)\] \}/);
|
||||
assert.match(routes, /A browser login session is required/);
|
||||
assert.doesNotMatch(routes, /expiredSessions/);
|
||||
assert.doesNotMatch(routes, /events:\s*\{/);
|
||||
assert.match(routes, /The current session cannot be revoked/);
|
||||
assert.match(page, /session\.current \? null/);
|
||||
});
|
||||
@@ -25,7 +25,7 @@ test("viewer role is blocked from write permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "VIEWER",
|
||||
} as never,
|
||||
reply as never,
|
||||
@@ -45,7 +45,7 @@ test("viewer role keeps read permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "VIEWER",
|
||||
} as never,
|
||||
reply as never,
|
||||
@@ -61,7 +61,7 @@ test("developer role keeps write permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "DEVELOPER",
|
||||
} as never,
|
||||
reply as never,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { DeleteObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
|
||||
import { getExpiredObjectKeys } from "../../src/server/services/s3-storage-retention.service";
|
||||
import {
|
||||
deleteS3ObjectsWithClient,
|
||||
isMissingContentMd5Error,
|
||||
} from "../../src/server/services/s3-storage.service";
|
||||
|
||||
const now = Date.parse("2026-07-15T00:00:00.000Z");
|
||||
|
||||
@@ -70,3 +75,45 @@ test("maximum backup count keeps the newest objects", () => {
|
||||
assert.deepEqual(keys, ["oldest.tar.gz"]);
|
||||
});
|
||||
|
||||
test("S3 deletion falls back to individual requests when batch MD5 is rejected", async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = {
|
||||
async send(command: unknown) {
|
||||
commands.push(command);
|
||||
if (command instanceof DeleteObjectsCommand) {
|
||||
throw Object.assign(new Error("Missing required header for this request: Content-MD5"), {
|
||||
Code: "InvalidRequest",
|
||||
});
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
await deleteS3ObjectsWithClient(
|
||||
client as never,
|
||||
"bucket",
|
||||
["backups/one.tar.gz", "backups/two.tar.gz"],
|
||||
);
|
||||
|
||||
assert.equal(commands.length, 3);
|
||||
assert.ok(commands[0] instanceof DeleteObjectsCommand);
|
||||
assert.ok(commands[1] instanceof DeleteObjectCommand);
|
||||
assert.ok(commands[2] instanceof DeleteObjectCommand);
|
||||
});
|
||||
|
||||
test("S3 deletion does not hide unrelated batch failures", async () => {
|
||||
const failure = Object.assign(new Error("Access denied"), {
|
||||
Code: "AccessDenied",
|
||||
});
|
||||
const client = {
|
||||
async send() {
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isMissingContentMd5Error(failure), false);
|
||||
await assert.rejects(
|
||||
deleteS3ObjectsWithClient(client as never, "bucket", ["backup.tar.gz"]),
|
||||
failure,
|
||||
);
|
||||
});
|
||||
@@ -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