This commit is contained in:
jarek committed 2026-04-18 08:34:20 +02:00
1 parent c210ef0a8e
commit afee09866d
55 files changed
+7767 -131

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
import { getUser } from '$lib/server/db';
import { audit } from '$lib/server/audit';
import { getRequestContext } from '$lib/server/request-context';
// Password confirmation rate limiting (per userId)
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
const pwCooldowns = new Map<number, number>();
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
const PW_FAIL_MAX = 5; // max failures per window
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
const MAX_TOKENS_PER_USER = 25;
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [id, until] of pwCooldowns) {
if (now > until) pwCooldowns.delete(id);
}
for (const [id, entry] of pwFailCounts) {
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
}
}, PW_COOLDOWN).unref?.();
function isPwRateLimited(userId: number): boolean {
const until = pwCooldowns.get(userId);
if (!until) return false;
if (Date.now() > until) {
pwCooldowns.delete(userId);
return false;
}
return true;
}
function recordPwFailure(userId: number): void {
const now = Date.now();
const entry = pwFailCounts.get(userId);
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
pwFailCounts.set(userId, { count: 1, firstFail: now });
return;
}
entry.count++;
if (entry.count >= PW_FAIL_MAX) {
pwCooldowns.set(userId, now + PW_COOLDOWN);
pwFailCounts.delete(userId);
}
}
/**
* GET /api/auth/tokens - List the current user's API tokens
*/
export const GET: RequestHandler = async ({ cookies }) => {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokens = await listUserTokens(auth.user.id);
return json(tokens);
};
/**
* POST /api/auth/tokens - Create a new API token
*/
export const POST: RequestHandler = async (event) => {
const { cookies, request } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
// Token creation requires a cookie session — a Bearer token cannot create new tokens
const reqCtx = getRequestContext();
if (reqCtx?.authMethod === 'bearer') {
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
}
let body: any;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid request body' }, { status: 400 });
}
const { name, expiresAt, password } = body;
// Local users must confirm their password to create tokens
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
if (auth.user.provider === 'local') {
if (isPwRateLimited(auth.user.id)) {
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
}
if (!password || typeof password !== 'string') {
return json({ error: 'Password is required to create an API token' }, { status: 400 });
}
const dbUser = await getUser(auth.user.id);
if (!dbUser) {
return json({ error: 'User not found' }, { status: 404 });
}
const valid = await verifyPassword(password, dbUser.passwordHash);
if (!valid) {
recordPwFailure(auth.user.id);
return json({ error: 'Invalid password' }, { status: 403 });
}
}
// L2: Per-user token count limit
const existingTokens = await listUserTokens(auth.user.id);
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
}
// Validate name
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return json({ error: 'Token name is required' }, { status: 400 });
}
if (name.length > 255) {
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
}
// Validate expiration
if (expiresAt) {
const expiryDate = new Date(expiresAt);
if (isNaN(expiryDate.getTime())) {
return json({ error: 'Invalid expiration date' }, { status: 400 });
}
if (expiryDate <= new Date()) {
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
}
}
const result = await generateApiToken(
auth.user.id,
name.trim(),
expiresAt || null
);
await audit(event, 'create', 'api_token', {
entityId: String(result.id),
entityName: name.trim(),
description: `API token "${name.trim()}" created`
});
return json({
id: result.id,
token: result.token,
tokenPrefix: result.tokenPrefix
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
};
@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { revokeApiToken } from '$lib/server/api-tokens';
import { isAuthEnabled } from '$lib/server/auth';
import { audit } from '$lib/server/audit';
/**
* DELETE /api/auth/tokens/[id] - Revoke an API token
*/
export const DELETE: RequestHandler = async (event) => {
const { cookies, params } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokenId = parseInt(params.id);
if (isNaN(tokenId)) {
return json({ error: 'Invalid token ID' }, { status: 400 });
}
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
if (!success) {
return json({ error: 'Token not found or access denied' }, { status: 404 });
}
await audit(event, 'delete', 'api_token', {
entityId: params.id,
description: `API token revoked`
});
return json({ success: true });
};
@@ -7,6 +7,7 @@ import {
deleteAutoUpdateSchedule
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url }) => {
try {
@@ -38,7 +39,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
}
};
export const POST: RequestHandler = async ({ params, url, request }) => {
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
@@ -101,7 +107,12 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
}
};
export const DELETE: RequestHandler = async ({ params, url }) => {
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
+19 -1
View File
@@ -4,7 +4,7 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -33,6 +33,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const details = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(details.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
if (secretKeys.size > 0) {
details.Config.Env = details.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(details);
} catch (error: any) {
if (error?.statusCode === 404) {
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { getSecretKeyNames } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -20,6 +21,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerData = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(containerData.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
if (secretKeys.size > 0) {
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
@@ -25,6 +25,29 @@ export const POST: RequestHandler = async (event) => {
const body = await request.json();
const { startAfterUpdate, repullImage, ...options } = body;
// Resolve masked secret values (***) back to real values from the current container.
// The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets.
if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) {
const currentData = await inspectContainer(params.id, envIdNum);
const currentEnvMap = new Map<string, string>();
for (const entry of currentData.Config?.Env || []) {
const eqIdx = entry.indexOf('=');
if (eqIdx !== -1) {
currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1));
}
}
options.env = options.env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
const value = entry.substring(eqIdx + 1);
if (value === '***' && currentEnvMap.has(key)) {
return `${key}=${currentEnvMap.get(key)}`;
}
return entry;
});
}
if (repullImage) {
console.log(`Pulling image...`);
try {
+2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
@@ -130,6 +131,7 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
invalidateTokenCacheForUser(user.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
+6
View File
@@ -7,6 +7,7 @@ import {
getHostname
} from '$lib/server/license';
import { authorize } from '$lib/server/authorize';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/license - Get current license status
// Any authenticated user can view license status (needed to determine if RBAC applies)
@@ -59,6 +60,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
);
}
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({
success: true,
license: result.license
@@ -81,6 +85,8 @@ export const DELETE: RequestHandler = async ({ cookies }) => {
try {
await deactivateLicense();
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({ success: true });
} catch (error) {
console.error('Error deactivating license:', error);
+4 -2
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db';
import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/profile - Get current user's profile
export const GET: RequestHandler = async ({ cookies }) => {
@@ -85,8 +86,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
}
updateData.passwordHash = await hashPassword(data.newPassword);
// Invalidate other sessions on password change
deleteUserSessions(currentUser.id);
// Invalidate other sessions and token cache on password change
await deleteUserSessions(currentUser.id);
invalidateTokenCacheForUser(currentUser.id);
}
const user = await dbUpdateUser(currentUser.id, updateData);
+7
View File
@@ -8,6 +8,7 @@ import {
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/roles/[id] - Get a specific role
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -75,6 +76,9 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Failed to update role' }, { status: 500 });
}
// Clear token cache — any cached user with this role has stale permissions
clearTokenCache();
// Compute diff for audit
const diff = computeAuditDiff(existingRole, role);
@@ -128,6 +132,9 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Failed to delete role' }, { status: 500 });
}
// Clear token cache — users with this role may have stale cached permissions
clearTokenCache();
// Audit log
await auditRole(event, 'delete', id, role.name);
@@ -13,8 +13,14 @@ import {
deleteImagePruneSettings
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -11,8 +11,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -7,8 +7,14 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -8,6 +8,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params }) => {
try {
@@ -28,7 +29,12 @@ export const GET: RequestHandler = async ({ params }) => {
}
};
export const DELETE: RequestHandler = async ({ params }) => {
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id, 10);
if (isNaN(id)) {
+4 -1
View File
@@ -144,10 +144,13 @@ export const POST: RequestHandler = async (event) => {
}
// ALWAYS save compose file first - deployStack expects it to exist
await saveStackComposeFile(name, compose, true, envIdNum, {
const saveResult = await saveStackComposeFile(name, compose, true, envIdNum, {
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 400 });
}
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
+2
View File
@@ -11,6 +11,7 @@ import {
} from '$lib/server/db';
import { hashPassword, createUserSession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { auditUser } from '$lib/server/audit';
// GET /api/users - List all users
@@ -108,6 +109,7 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, null);
invalidateTokenCacheForUser(user.id);
}
}
+12 -3
View File
@@ -16,6 +16,7 @@ import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
@@ -125,6 +126,7 @@ export const PUT: RequestHandler = async (event) => {
if (isDeactivating) {
updateData.isActive = false;
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
// Disable authentication
@@ -142,6 +144,7 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
await removeUserRole(userId, adminRole.id, null);
}
invalidateTokenCacheForUser(userId);
}
return json({
@@ -170,9 +173,10 @@ export const PUT: RequestHandler = async (event) => {
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
// If deactivating, invalidate all sessions
// If deactivating, invalidate all sessions and token cache
if (!data.isActive) {
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
}
}
@@ -183,8 +187,9 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
updateData.passwordHash = await hashPassword(data.password);
// Invalidate all sessions on password change (except current)
// Invalidate all sessions and token cache on password change
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
const user = await dbUpdateUser(userId, updateData);
@@ -198,8 +203,10 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
if (shouldPromote) {
await assignUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
} else if (shouldDemote) {
await removeUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
}
}
@@ -284,6 +291,7 @@ export const DELETE: RequestHandler = async (event) => {
// User confirmed - proceed with deletion and disable auth
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
return json({ error: 'Failed to delete user' }, { status: 500 });
@@ -299,8 +307,9 @@ export const DELETE: RequestHandler = async (event) => {
}
}
// Delete all sessions first
// Delete all sessions and invalidate token cache
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
@@ -10,6 +10,7 @@ import {
getRole
} from '$lib/server/db';
import { auditUser } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -69,6 +70,7 @@ export const POST: RequestHandler = async (event) => {
}
const userRole = await assignUserRole(userId, roleId, environmentId);
invalidateTokenCacheForUser(userId);
// Audit log - role assigned
const role = await getRole(roleId);
@@ -117,6 +119,7 @@ export const DELETE: RequestHandler = async (event) => {
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
invalidateTokenCacheForUser(userId);
// Audit log - role removed
if (user) {
@@ -864,6 +864,8 @@
retries: healthcheckRetries,
startPeriod: healthcheckStartPeriod * 1e9
};
} else if (!healthcheckEnabled) {
healthcheck = null;
}
const devices = deviceMappings
@@ -902,8 +904,8 @@
const payload = {
name: name.trim(),
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : undefined,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
cmd,
@@ -1081,8 +1083,8 @@
bind:restartPolicy
bind:restartMaxRetries
bind:networkMode
startAfterCreate={startAfterUpdate}
{repullImage}
bind:startAfterCreate={startAfterUpdate}
bind:repullImage
bind:portMappings
bind:volumeMappings
bind:envVars
+2 -2
View File
@@ -241,8 +241,8 @@
{vuln.severity}
</Badge>
</td>
<td class="py-1 px-2">
<code class="text-xs">{vuln.package}</code>
<td class="py-1 px-2 max-w-[300px]">
<code class="text-xs block truncate" title={vuln.package}>{vuln.package}</code>
</td>
<td class="py-1 px-2">
<code class="text-xs text-muted-foreground">{vuln.version}</code>
+3 -1
View File
@@ -9,6 +9,7 @@
import { Loader2, LogIn, Shield, AlertCircle, Network, User, KeyRound, TriangleAlert } from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import { environments } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert';
import { themeStore, applyTheme } from '$lib/stores/theme';
@@ -114,7 +115,8 @@
return;
}
// Success - refresh environments (they were cleared during pre-login fetch) then redirect
// Success - refresh settings and environments (they were fetched before auth) then redirect
await appSettings.refresh();
await environments.refresh();
goto(redirectUrl);
} catch (e) {
+147 -6
View File
@@ -11,6 +11,7 @@
Shield,
ShieldCheck,
Key,
KeyRound,
RefreshCw,
Check,
Smartphone,
@@ -22,7 +23,8 @@
Camera,
Trash2,
TriangleAlert,
Palette
Palette,
Plus
} from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import { formatDateTime } from '$lib/stores/settings';
@@ -32,6 +34,9 @@
import ChangePasswordModal from './ChangePasswordModal.svelte';
import MfaSetupModal from './MfaSetupModal.svelte';
import DisableMfaModal from './DisableMfaModal.svelte';
import ApiTokenModal from './ApiTokenModal.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Table from '$lib/components/ui/table';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import { themeStore } from '$lib/stores/theme';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -73,6 +78,49 @@
let mfaError = $state('');
let showDisableMfaModal = $state(false);
// API tokens state
interface ApiToken {
id: number;
name: string;
tokenPrefix: string;
lastUsed: string | null;
expiresAt: string | null;
createdAt: string;
}
let apiTokens = $state<ApiToken[]>([]);
let showApiTokenModal = $state(false);
let tokensLoading = $state(false);
async function fetchApiTokens() {
tokensLoading = true;
try {
const response = await fetch('/api/auth/tokens');
if (response.ok) {
apiTokens = await response.json();
}
} catch {
// Silently fail - tokens section will show empty
} finally {
tokensLoading = false;
}
}
async function revokeToken(tokenId: number) {
try {
const response = await fetch(`/api/auth/tokens/${tokenId}`, { method: 'DELETE' });
if (response.ok) {
apiTokens = apiTokens.filter(t => t.id !== tokenId);
}
} catch {
// Ignore
}
}
function isTokenExpired(expiresAt: string | null): boolean {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
}
// Avatar state
let showAvatarCropper = $state(false);
let avatarSaving = $state(false);
@@ -291,6 +339,7 @@
if (!profileFetched) {
profileFetched = true;
fetchProfile();
fetchApiTokens();
}
}
});
@@ -300,7 +349,7 @@
<title>Profile - Dockhand</title>
</svelte:head>
<div class="container mx-auto p-6 max-w-4xl">
<div class="container mx-auto p-6">
<div class="flex items-center gap-3 mb-6">
<PageHeader icon={User} title="Profile" showConnection={false}>
<p class="text-muted-foreground text-sm">Manage your account settings</p>
@@ -330,6 +379,9 @@
</div>
{/if}
<!-- Row 1: Account info + Profile details -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Account info card -->
<Card.Root>
<Card.Header>
@@ -431,14 +483,14 @@
</Card.Root>
<!-- Profile details card -->
<Card.Root>
<Card.Root class="flex flex-col">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Mail class="w-5 h-5" />
Profile details
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<Card.Content class="flex-1 flex flex-col space-y-4">
{#if formError}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
@@ -446,7 +498,7 @@
</Alert.Root>
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-4 flex-1">
<div class="space-y-2">
<Label>Display name</Label>
<Input
@@ -477,8 +529,13 @@
</Card.Content>
</Card.Root>
</div>
<!-- Row 2: Security + API tokens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Security card -->
<Card.Root>
<Card.Root class="flex flex-col">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Shield class="w-5 h-5" />
@@ -582,6 +639,81 @@
</Card.Content>
</Card.Root>
<!-- API tokens card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 justify-between">
<span class="flex items-center gap-2">
<KeyRound class="w-5 h-5" />
API tokens
</span>
<Button variant="outline" size="sm" onclick={() => showApiTokenModal = true}>
<Plus class="w-4 h-4 mr-1" />
Generate token
</Button>
</Card.Title>
<Card.Description>Create tokens for CI/CD pipelines and scripts</Card.Description>
</Card.Header>
<Card.Content>
{#if tokensLoading}
<p class="text-sm text-muted-foreground">Loading tokens...</p>
{:else if apiTokens.length === 0}
<p class="text-sm text-muted-foreground">No API tokens created yet.</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Prefix</Table.Head>
<Table.Head>Last used</Table.Head>
<Table.Head>Expires</Table.Head>
<Table.Head class="w-[80px]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each apiTokens as token (token.id)}
<Table.Row class={isTokenExpired(token.expiresAt) ? 'opacity-50' : ''}>
<Table.Cell class="font-medium">{token.name}</Table.Cell>
<Table.Cell>
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">dh_{token.tokenPrefix}...</code>
</Table.Cell>
<Table.Cell class="text-sm text-muted-foreground">
{token.lastUsed ? formatDateTime(token.lastUsed) : 'Never'}
</Table.Cell>
<Table.Cell class="text-sm">
{#if isTokenExpired(token.expiresAt)}
<Badge variant="destructive">Expired</Badge>
{:else if token.expiresAt}
{formatDateTime(token.expiresAt)}
{:else}
<span class="text-muted-foreground">Never</span>
{/if}
</Table.Cell>
<Table.Cell>
<ConfirmPopover
title="Revoke token"
description="This token will stop working immediately."
confirmText="Revoke"
onConfirm={() => revokeToken(token.id)}
>
<Button variant="ghost" size="sm" class="text-destructive hover:text-destructive">
<Trash2 class="w-4 h-4" />
</Button>
</ConfirmPopover>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Row 3: Appearance (left-aligned with Security) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Appearance card -->
<Card.Root>
<Card.Header>
@@ -595,6 +727,8 @@
<ThemeSelector userId={profile.id} />
</Card.Content>
</Card.Root>
</div>
</div>
{/if}
</div>
@@ -625,6 +759,13 @@
/>
{/if}
<!-- API Token Modal -->
<ApiTokenModal
bind:open={showApiTokenModal}
onCreated={fetchApiTokens}
provider={profile?.provider ?? 'local'}
/>
<!-- Avatar Cropper Modal -->
<AvatarCropper
show={showAvatarCropper}
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import * as Alert from '$lib/components/ui/alert';
import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-svelte';
let {
open = $bindable(false),
onCreated,
provider = 'local'
}: {
open: boolean;
onCreated: () => void;
provider?: string;
} = $props();
let name = $state('');
let password = $state('');
let expirationOption = $state('none');
let customDate = $state('');
let creating = $state(false);
let error = $state('');
const isLocalUser = $derived(provider === 'local');
// After creation
let createdToken = $state('');
let copied = $state(false);
function reset() {
name = '';
password = '';
expirationOption = 'none';
customDate = '';
creating = false;
error = '';
createdToken = '';
copied = false;
}
function getExpiresAt(): string | null {
const now = new Date();
switch (expirationOption) {
case '30d':
return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString();
case '90d':
return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString();
case '1y':
return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
case 'custom':
return customDate ? new Date(customDate + 'T23:59:59').toISOString() : null;
default:
return null;
}
}
async function createToken() {
if (!name.trim()) {
error = 'Token name is required';
return;
}
if (isLocalUser && !password) {
error = 'Password is required';
return;
}
creating = true;
error = '';
try {
const payload: Record<string, any> = {
name: name.trim(),
expiresAt: getExpiresAt()
};
if (isLocalUser) {
payload.password = password;
}
const response = await fetch('/api/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
createdToken = data.token;
} else {
const data = await response.json();
error = data.error || 'Failed to create token';
}
} catch {
error = 'Failed to create token';
} finally {
creating = false;
}
}
async function copyToken() {
try {
await navigator.clipboard.writeText(createdToken);
copied = true;
setTimeout(() => copied = false, 2000);
} catch {
// Fallback: select the input text
}
}
function handleClose() {
if (createdToken) {
onCreated();
}
open = false;
// Reset after animation
setTimeout(reset, 200);
}
// Get minimum date for custom picker (tomorrow)
const minDate = $derived(() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().split('T')[0];
});
</script>
<Dialog.Root bind:open onOpenChange={(v) => { if (!v) handleClose(); }}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<KeyRound class="w-5 h-5" />
{createdToken ? 'Token created' : 'Generate API token'}
</Dialog.Title>
</Dialog.Header>
{#if createdToken}
<!-- Token created - show it once -->
<div class="space-y-4">
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>
Copy this token now. It will not be shown again.
</Alert.Description>
</Alert.Root>
<div class="flex gap-2">
<Input
value={createdToken}
readonly
class="font-mono text-xs"
/>
<Button variant="outline" size="icon" onclick={copyToken}>
{#if copied}
<Check class="w-4 h-4 text-green-500" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</Button>
</div>
<div class="flex justify-end">
<Button onclick={handleClose}>Done</Button>
</div>
</div>
{:else}
<!-- Token creation form -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="token-name">Name</Label>
<Input
id="token-name"
bind:value={name}
placeholder="e.g., CI/CD pipeline"
maxlength={255}
/>
</div>
{#if isLocalUser}
<div class="space-y-2">
<Label for="token-password">Password</Label>
<Input
id="token-password"
type="password"
bind:value={password}
placeholder="Confirm your password"
/>
</div>
{/if}
<div class="space-y-2">
<Label>Expiration</Label>
<Select.Root type="single" bind:value={expirationOption}>
<Select.Trigger class="w-full">
{#if expirationOption === 'none'}No expiration
{:else if expirationOption === '30d'}30 days
{:else if expirationOption === '90d'}90 days
{:else if expirationOption === '1y'}1 year
{:else if expirationOption === 'custom'}Custom date
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="none">No expiration</Select.Item>
<Select.Item value="30d">30 days</Select.Item>
<Select.Item value="90d">90 days</Select.Item>
<Select.Item value="1y">1 year</Select.Item>
<Select.Item value="custom">Custom date</Select.Item>
</Select.Content>
</Select.Root>
{#if expirationOption === 'custom'}
<Input
type="date"
bind:value={customDate}
min={minDate()}
/>
{/if}
</div>
{#if error}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{error}</Alert.Description>
</Alert.Root>
{/if}
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button onclick={createToken} disabled={creating || !name.trim() || (isLocalUser && !password)}>
{creating ? 'Creating...' : 'Generate token'}
</Button>
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
+63 -35
View File
@@ -41,13 +41,17 @@
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridRowState } from '$lib/components/data-grid';
import { toast } from 'svelte-sonner';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { formatDateTime, getTimeFormat, appSettings } from '$lib/stores/settings';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
import ExecutionLogViewer from '$lib/components/ExecutionLogViewer.svelte';
import { canAccess } from '$lib/stores/auth';
const canEditSchedules = $derived($canAccess('schedules', 'edit'));
const canRunSchedules = $derived($canAccess('schedules', 'run'));
import { vulnerabilityCriteriaIcons, vulnerabilityCriteriaLabels } from '$lib/utils/update-steps';
import type { VulnerabilityCriteria } from '$lib/server/db';
import cronstrue from 'cronstrue';
@@ -183,7 +187,7 @@
// State
let schedules = $state<Schedule[]>([]);
let environments = $state<{ id: number; name: string; icon: string }[]>([]);
let environments = $state<{ id: number; name: string; icon: string; timezone?: string }[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let searchQuery = $state('');
@@ -204,6 +208,11 @@
let selectedExecution = $state<ScheduleExecution | null>(null);
let loadingExecutionDetail = $state(false);
let logDarkMode = $state(true);
let selectedExecutionTimezone = $derived(
selectedExecution?.environmentId
? environments.find(e => e.id === selectedExecution!.environmentId)?.timezone
: undefined
);
function toggleLogTheme() {
logDarkMode = !logDarkMode;
@@ -736,9 +745,21 @@
}
}
function formatTimestamp(iso: string | null): string {
function formatTimestamp(iso: string | null, tz?: string): string {
if (!iso) return '-';
return formatDateTime(iso, true);
if (!tz) return formatDateTime(iso, true);
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: getTimeFormat() === '12h'
}).format(d);
}
function formatDuration(ms: number | null): string {
@@ -1287,27 +1308,31 @@
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleScheduleEnabled(schedule); }}
title={schedule.enabled ? 'Pause schedule' : 'Resume schedule'}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
{#if schedule.enabled}
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
{:else}
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
{/if}
</button>
<button
type="button"
onclick={(e) => { e.stopPropagation(); triggerSchedule(schedule); }}
title="Run now"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{#if !schedule.isSystem}
{#if canEditSchedules}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleScheduleEnabled(schedule); }}
title={schedule.enabled ? 'Pause schedule' : 'Resume schedule'}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
{#if schedule.enabled}
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
{:else}
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
{/if}
</button>
{/if}
{#if canRunSchedules}
<button
type="button"
onclick={(e) => { e.stopPropagation(); triggerSchedule(schedule); }}
title="Run now"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{/if}
{#if canEditSchedules && !schedule.isSystem}
{@const scheduleKey = getScheduleKey(schedule)}
<ConfirmPopover
open={confirmDeleteId === scheduleKey}
@@ -1335,7 +1360,7 @@
<div class="p-4 pl-12 shadow-inner bg-muted isolate sticky left-0 max-w-[calc(100vw-18rem)]">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-medium">Execution history</h4>
{#if executions.length > 0}
{#if executions.length > 0 && canEditSchedules}
<button
type="button"
onclick={() => deleteAllExecutions(schedule)}
@@ -1412,14 +1437,16 @@
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
<button
type="button"
onclick={() => deleteExecution(schedule, exec.id)}
title="Delete execution"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-500" />
</button>
{#if canEditSchedules}
<button
type="button"
onclick={() => deleteExecution(schedule, exec.id)}
title="Delete execution"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-500" />
</button>
{/if}
</div>
</td>
</tr>
@@ -1491,7 +1518,7 @@
</Dialog.Title>
{#if selectedExecution}
<span class="text-xs text-muted-foreground shrink-0 pr-6 whitespace-nowrap inline-flex items-center gap-1">
{formatTimestamp(selectedExecution.triggeredAt)} · <Timer class="w-3 h-3 -mt-px" /> {formatDuration(selectedExecution.duration)}
{formatTimestamp(selectedExecution.triggeredAt, selectedExecutionTimezone)} · <Timer class="w-3 h-3 -mt-px" /> {formatDuration(selectedExecution.duration)}
</span>
{/if}
</Dialog.Header>
@@ -1628,6 +1655,7 @@
<ExecutionLogViewer
logs={selectedExecution.logs}
darkMode={logDarkMode}
timezone={selectedExecutionTimezone}
onToggleTheme={toggleLogTheme}
/>
</div>
@@ -135,7 +135,9 @@
{ key: 'view', label: 'View audit logs' }
],
schedules: [
{ key: 'view', label: 'View schedules' }
{ key: 'view', label: 'View schedules' },
{ key: 'edit', label: 'Edit schedules' },
{ key: 'run', label: 'Run schedules' }
]
};
@@ -242,6 +244,7 @@
disconnect: Unplug,
edit: Pencil,
test: Play,
run: Play,
manage: Settings
};
+21 -19
View File
@@ -94,13 +94,15 @@
}
function handleScheduleCleanupEnabledChange() {
appSettings.setScheduleCleanupEnabled(!scheduleCleanupEnabled);
toast.success(scheduleCleanupEnabled ? 'Schedule cleanup disabled' : 'Schedule cleanup enabled');
const newState = !scheduleCleanupEnabled;
appSettings.setScheduleCleanupEnabled(newState);
toast.success(newState ? 'Schedule cleanup enabled' : 'Schedule cleanup disabled');
}
function handleEventCleanupEnabledChange() {
appSettings.setEventCleanupEnabled(!eventCleanupEnabled);
toast.success(eventCleanupEnabled ? 'Event cleanup disabled' : 'Event cleanup enabled');
const newState = !eventCleanupEnabled;
appSettings.setEventCleanupEnabled(newState);
toast.success(newState ? 'Event cleanup enabled' : 'Event cleanup disabled');
}
function handleGrypeImageBlur(e: Event) {
@@ -199,9 +201,9 @@
<Label>Show stopped containers</Label>
<TogglePill
checked={showStoppedContainers}
onchange={() => {
appSettings.setShowStoppedContainers(!showStoppedContainers);
toast.success(showStoppedContainers ? 'Stopped containers hidden' : 'Stopped containers shown');
onchange={(checked) => {
appSettings.setShowStoppedContainers(checked);
toast.success(checked ? 'Stopped containers shown' : 'Stopped containers hidden');
}}
disabled={!$canAccess('settings', 'edit')}
/>
@@ -213,9 +215,9 @@
<Label>Highlight available updates</Label>
<TogglePill
checked={highlightUpdates}
onchange={() => {
appSettings.setHighlightUpdates(!highlightUpdates);
toast.success(highlightUpdates ? 'Update highlighting disabled' : 'Update highlighting enabled');
onchange={(checked) => {
appSettings.setHighlightUpdates(checked);
toast.success(checked ? 'Update highlighting enabled' : 'Update highlighting disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
@@ -227,9 +229,9 @@
<Label>Compact port display</Label>
<TogglePill
checked={compactPorts}
onchange={() => {
appSettings.setCompactPorts(!compactPorts);
toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled');
onchange={(checked) => {
appSettings.setCompactPorts(checked);
toast.success(checked ? 'Compact port display enabled' : 'Showing all ports');
}}
disabled={!$canAccess('settings', 'edit')}
/>
@@ -337,9 +339,9 @@
<Label>Confirm destructive actions</Label>
<TogglePill
checked={confirmDestructive}
onchange={() => {
appSettings.setConfirmDestructive(!confirmDestructive);
toast.success(confirmDestructive ? 'Confirmations disabled' : 'Confirmations enabled');
onchange={(checked) => {
appSettings.setConfirmDestructive(checked);
toast.success(checked ? 'Confirmations enabled' : 'Confirmations disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
@@ -405,9 +407,9 @@
<Label>Format log timestamps</Label>
<TogglePill
checked={formatLogTimestamps}
onchange={() => {
appSettings.setFormatLogTimestamps(!formatLogTimestamps);
toast.success(formatLogTimestamps ? 'Log timestamp formatting disabled' : 'Log timestamp formatting enabled');
onchange={(checked) => {
appSettings.setFormatLogTimestamps(checked);
toast.success(checked ? 'Log timestamp formatting enabled' : 'Log timestamp formatting disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
+1 -1
View File
@@ -260,7 +260,7 @@
if (stats) {
cpuPercent += stats.cpuPercent;
memoryUsage += stats.memoryUsage;
memoryLimit += stats.memoryLimit;
memoryLimit = Math.max(memoryLimit, stats.memoryLimit);
networkRx += stats.networkRx;
networkTx += stats.networkTx;
blockRead += stats.blockRead;
+9 -4
View File
@@ -867,6 +867,9 @@ services:
if (!newStackName.trim()) {
errors.stackName = 'Stack name is required';
hasErrors = true;
} else if (!/^[a-z0-9][a-z0-9_-]*$/.test(newStackName.trim())) {
errors.stackName = 'Must be lowercase, start with a letter or number, and only contain letters, numbers, hyphens, and underscores';
hasErrors = true;
}
const content = composeContent || defaultCompose;
@@ -903,6 +906,7 @@ services:
// Prepare env vars for creating - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
let response: Response | undefined;
try {
// Build request body
const requestBody: Record<string, unknown> = {
@@ -930,7 +934,7 @@ services:
}
// Create the stack
const response = await fetch(appendEnvParam('/api/stacks', envId), {
response = await fetch(appendEnvParam('/api/stacks', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -955,9 +959,10 @@ services:
message: e.message || 'An error occurred while creating the stack',
details: e.details
};
// If start=true, files were saved and stack is in DB — transition to edit mode
// so the user can fix and redeploy without leaving the modal
if (start) {
// Only transition to edit mode if the stack was actually persisted (response was ok
// but deploy failed). A 400 from validation means nothing was saved — stay in create
// mode so the name field remains visible and the user can fix the error.
if (start && response?.ok) {
mode = 'edit';
stackName = newStackName.trim();
onSuccess(); // refresh stack list so the new stack appears