v1.0.25
This commit is contained in:
1 parent
c210ef0a8e
commit
afee09866d
55 files changed
+7767
-131
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user