Add CLI flags for setup and server configuration

- Add --setup flag to create admin user
- Add --admin-user and --admin-password for setup
- Generate cryptographically random passwords for --setup
- Add --bind and --port flags for server binding
- Add BIND env var support
- Add SMTP email service for password reset
- Add password reset token storage
- Add auth header to queue/logs frontend pages
- Fix journalctl timestamp format
- Fix logs to return empty array instead of error when no entries
- Fix queue handler to return proper error message
- Hide Users/Aliases nav links when no domain selected
This commit is contained in:
Christoph Haas 2026-03-22 01:17:43 +01:00
parent 2c3a4c3daa
commit 53ce3b06ae
14 changed files with 435 additions and 69 deletions

View file

@ -7,25 +7,35 @@
let user: { username?: string; email?: string; role?: string } | null = $state(null);
let selectedDomain = $state<string | null>(null);
let loading = $state(true);
let showDropdown = $state(false);
let dropdownRef = $state<HTMLDivElement | null>(null);
let usersLink = $derived(selectedDomain ? `/domains/${selectedDomain}/users` : '/domains');
let aliasesLink = $derived(selectedDomain ? `/domains/${selectedDomain}/aliases` : '/domains');
let isInitialized = $state(false);
onMount(() => {
selectedDomain = localStorage.getItem('selectedDomain');
updateSelectedDomainFromPath($page.url.pathname);
isInitialized = true;
function handleClickOutside(event: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
showDropdown = false;
}
}
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
function updateSelectedDomainFromPath(path: string) {
$effect(() => {
if (!isInitialized) return;
const path = $page.url.pathname;
const match = path.match(/^\/domains\/([^/]+)/);
if (match) {
selectedDomain = match[1];
localStorage.setItem('selectedDomain', selectedDomain);
}
}
$effect(() => {
updateSelectedDomainFromPath($page.url.pathname);
});
$effect(() => {
@ -73,8 +83,14 @@
localStorage.removeItem('selectedDomain');
user = null;
selectedDomain = null;
showDropdown = false;
goto('/auth/login');
}
function toggleDropdown(event: MouseEvent) {
event.stopPropagation();
showDropdown = !showDropdown;
}
</script>
{#if loading}
@ -86,17 +102,24 @@
<nav>
<a href="/">Dashboard</a>
<a href="/domains">Domains</a>
<a href={usersLink}>Users</a>
<a href={aliasesLink}>Aliases</a>
{#if selectedDomain}
<a href={usersLink}>Users</a>
<a href={aliasesLink}>Aliases</a>
{/if}
<a href="/queue">Queue</a>
<a href="/logs">Logs</a>
</nav>
<div class="user-menu">
{#if user}
<span>{user.username || user.email}</span>
<div class="user-menu" bind:this={dropdownRef}>
<button class="dropdown-trigger" onclick={toggleDropdown}>
<span class="username">{user.username || user.email}</span>
<span class="arrow" class:open={showDropdown}>▼</span>
</button>
{#if showDropdown}
<div class="dropdown">
<a href="/auth/change-password" onclick={() => showDropdown = false}>Change Password</a>
<button class="logout-btn" onclick={logout}>Logout</button>
</div>
{/if}
<a href="/auth/change-password">Change Password</a>
<button onclick={logout}>Logout</button>
</div>
</header>
@ -169,40 +192,80 @@
}
.user-menu {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
}
.user-menu span {
color: #ecf0f1;
}
.user-menu a {
color: #ecf0f1;
text-decoration: none;
padding: 0.375rem 0.75rem;
border: 1px solid #7f8c8d;
border-radius: 4px;
font-size: 0.85rem;
}
.user-menu a:hover {
background: #34495e;
}
.user-menu button {
gap: 0.5rem;
background: transparent;
border: 1px solid #7f8c8d;
color: #ecf0f1;
padding: 0.375rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.9rem;
transition: background 0.2s;
}
.user-menu button:hover {
.dropdown-trigger:hover {
background: #34495e;
}
.username {
font-weight: 500;
}
.arrow {
font-size: 0.7rem;
transition: transform 0.2s;
}
.arrow.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
overflow: hidden;
z-index: 1000;
}
.dropdown a {
display: block;
padding: 0.75rem 1rem;
color: #2c3e50;
text-decoration: none;
font-size: 0.9rem;
transition: background 0.2s;
}
.dropdown a:hover {
background: #f5f5f5;
}
.dropdown .logout-btn {
display: block;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e74c3c;
text-align: left;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
.dropdown .logout-btn:hover {
background: #f5f5f5;
}
</style>

View file

@ -15,7 +15,10 @@
loading = true;
error = '';
try {
const res = await fetch(`/api/logs?hours=${hours}&filter=${encodeURIComponent(filter)}`);
const token = localStorage.getItem('token');
const res = await fetch(`/api/logs?hours=${hours}&filter=${encodeURIComponent(filter)}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
logs = data.data || [];

View file

@ -16,7 +16,10 @@
loading = true;
error = '';
try {
const res = await fetch('/api/queue');
const token = localStorage.getItem('token');
const res = await fetch('/api/queue', {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
queue = data.data || [];
@ -33,7 +36,11 @@
async function requeue(id: string) {
try {
await fetch(`/api/queue/${id}/requeue`, { method: 'POST' });
const token = localStorage.getItem('token');
await fetch(`/api/queue/${id}/requeue`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
await loadQueue();
} catch (e) {
console.error('Failed to requeue:', e);
@ -43,7 +50,11 @@
async function deleteFromQueue(id: string) {
if (!confirm('Delete this message from queue?')) return;
try {
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
const token = localStorage.getItem('token');
await fetch(`/api/queue/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
await loadQueue();
} catch (e) {
console.error('Failed to delete from queue:', e);