fix(docker-watcher): phase 8 security fixes
Remove webhook secret from logs and API response. Add auth-pending note to router. Fix decrypt fallback that would use ciphertext as auth token on decrypt failure.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
confirmVariant?: 'danger' | 'primary';
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
confirmVariant = 'primary',
|
||||
onconfirm,
|
||||
oncancel
|
||||
}: Props = $props();
|
||||
|
||||
const confirmClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 focus-visible:outline-red-600'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 focus-visible:outline-indigo-600'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-40 bg-black/30" role="presentation" onclick={oncancel}></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">{message}</p>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
onclick={oncancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-2 text-sm font-medium text-white {confirmClass} focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
onclick={onconfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
helpText?: string;
|
||||
oninput?: (e: Event) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
required = false,
|
||||
disabled = false,
|
||||
error = '',
|
||||
helpText = '',
|
||||
oninput
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for={name} class="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-500">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if type === 'textarea'}
|
||||
<textarea
|
||||
id={name}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{oninput}
|
||||
class="rounded-md border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
{error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'}
|
||||
{disabled ? 'bg-gray-100 text-gray-500' : 'bg-white'}"
|
||||
rows="3"
|
||||
></textarea>
|
||||
{:else}
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{oninput}
|
||||
class="rounded-md border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
{error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'}
|
||||
{disabled ? 'bg-gray-100 text-gray-500' : 'bg-white'}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if helpText && !error}
|
||||
<p class="text-xs text-gray-500">{helpText}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import type { Instance } from '$lib/types';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import * as api from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
instance: Instance;
|
||||
projectId: string;
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
const { instance, projectId, onchange }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
||||
|
||||
const subdomainUrl = $derived(
|
||||
instance.subdomain ? `https://${instance.subdomain}` : ''
|
||||
);
|
||||
|
||||
const timeSinceCreated = $derived(() => {
|
||||
const created = new Date(instance.created_at);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - created.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
});
|
||||
|
||||
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
|
||||
loading = true;
|
||||
error = '';
|
||||
confirmAction = null;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'stop':
|
||||
await api.stopInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'start':
|
||||
await api.startInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'restart':
|
||||
await api.restartInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'remove':
|
||||
await api.removeInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
}
|
||||
onchange?.();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Action failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
|
||||
confirmAction = action;
|
||||
}
|
||||
|
||||
const confirmMessages: Record<string, string> = {
|
||||
stop: 'This will stop the running container. The instance can be started again later.',
|
||||
restart: 'This will restart the container, causing brief downtime.',
|
||||
remove: 'This will permanently remove the container and its proxy configuration. This cannot be undone.'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-mono text-sm font-medium text-gray-900">
|
||||
{instance.image_tag}
|
||||
</span>
|
||||
<StatusBadge status={instance.status} size="sm" />
|
||||
</div>
|
||||
|
||||
{#if subdomainUrl}
|
||||
<a
|
||||
href={subdomainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1 block truncate text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
{instance.subdomain}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>Port {instance.port}</span>
|
||||
<span>{timeSinceCreated()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 flex items-center gap-1">
|
||||
{#if instance.status === 'running'}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-yellow-600 disabled:opacity-50"
|
||||
title="Stop"
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('stop')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 disabled:opacity-50"
|
||||
title="Restart"
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('restart')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else if instance.status === 'stopped'}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-green-600 disabled:opacity-50"
|
||||
title="Start"
|
||||
disabled={loading}
|
||||
onclick={() => handleAction('start')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600 disabled:opacity-50"
|
||||
title="Remove"
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('remove')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-red-600">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction !== null}
|
||||
title="{confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''} Instance"
|
||||
message={confirmAction ? confirmMessages[confirmAction] ?? '' : ''}
|
||||
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
|
||||
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
||||
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}
|
||||
oncancel={() => { confirmAction = null; }}
|
||||
/>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Project, Instance } from '$lib/types';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
instances?: Instance[];
|
||||
}
|
||||
|
||||
const { project, instances = [] }: Props = $props();
|
||||
|
||||
const runningCount = $derived(instances.filter((i) => i.status === 'running').length);
|
||||
const stoppedCount = $derived(instances.filter((i) => i.status === 'stopped').length);
|
||||
const failedCount = $derived(instances.filter((i) => i.status === 'failed').length);
|
||||
const totalCount = $derived(instances.length);
|
||||
|
||||
const overallStatus = $derived<string>(() => {
|
||||
if (failedCount > 0) return 'failed';
|
||||
if (runningCount > 0) return 'running';
|
||||
if (stoppedCount > 0) return 'stopped';
|
||||
return 'stopped';
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="block rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition hover:border-indigo-300 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-base font-semibold text-gray-900">{project.name}</h3>
|
||||
<p class="mt-1 truncate text-sm text-gray-500">{project.image}</p>
|
||||
</div>
|
||||
<StatusBadge status={overallStatus()} size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4 text-sm text-gray-600">
|
||||
{#if totalCount > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{runningCount} running
|
||||
</span>
|
||||
{#if stoppedCount > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
{stoppedCount} stopped
|
||||
</span>
|
||||
{/if}
|
||||
{#if failedCount > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{failedCount} failed
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-gray-400">No instances</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3 text-xs text-gray-400">
|
||||
{#if project.port}
|
||||
<span>Port {project.port}</span>
|
||||
{/if}
|
||||
{#if project.healthcheck}
|
||||
<span>HC: {project.healthcheck}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { InstanceStatus, DeployStatus } from '$lib/types';
|
||||
|
||||
type Status = InstanceStatus | DeployStatus | string;
|
||||
|
||||
interface Props {
|
||||
status: Status;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const { status, size = 'md' }: Props = $props();
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
stopped: 'bg-gray-100 text-gray-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
rolled_back: 'bg-red-100 text-red-800',
|
||||
removing: 'bg-yellow-100 text-yellow-800',
|
||||
pending: 'bg-blue-100 text-blue-800',
|
||||
pulling: 'bg-blue-100 text-blue-800',
|
||||
starting: 'bg-yellow-100 text-yellow-800',
|
||||
configuring_proxy: 'bg-yellow-100 text-yellow-800',
|
||||
health_checking: 'bg-yellow-100 text-yellow-800'
|
||||
};
|
||||
|
||||
const dotColorMap: Record<string, string> = {
|
||||
running: 'bg-green-500',
|
||||
success: 'bg-green-500',
|
||||
stopped: 'bg-gray-400',
|
||||
failed: 'bg-red-500',
|
||||
rolled_back: 'bg-red-500',
|
||||
removing: 'bg-yellow-500',
|
||||
pending: 'bg-blue-500',
|
||||
pulling: 'bg-blue-500',
|
||||
starting: 'bg-yellow-500',
|
||||
configuring_proxy: 'bg-yellow-500',
|
||||
health_checking: 'bg-yellow-500'
|
||||
};
|
||||
|
||||
const colorClass = $derived(colorMap[status] ?? 'bg-gray-100 text-gray-800');
|
||||
const dotClass = $derived(dotColorMap[status] ?? 'bg-gray-400');
|
||||
const sizeClass = $derived(size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-0.5');
|
||||
const dotSize = $derived(size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2');
|
||||
const label = $derived(status.replace(/_/g, ' '));
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full font-medium {colorClass} {sizeClass}">
|
||||
<span class="rounded-full {dotClass} {dotSize}"></span>
|
||||
{label}
|
||||
</span>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { toasts, type ToastType } from '$lib/stores/toast';
|
||||
|
||||
const colorMap: Record<ToastType, string> = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-600'
|
||||
};
|
||||
|
||||
const iconMap: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✗',
|
||||
warning: '⚠',
|
||||
info: 'ℹ'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-3 rounded-lg px-4 py-3 text-white shadow-lg transition-all duration-300 {colorMap[toast.type]}"
|
||||
role="alert"
|
||||
>
|
||||
<span class="text-lg" aria-hidden="true">{@html iconMap[toast.type]}</span>
|
||||
<span class="flex-1 text-sm font-medium">{toast.message}</span>
|
||||
<button
|
||||
class="ml-2 text-white/80 hover:text-white transition-colors"
|
||||
onclick={() => toasts.remove(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
Reference in New Issue
Block a user