multi-tenant relay und card-push to user

This commit is contained in:
th committed 2026-04-10 18:23:07 +02:00
1 parent 7219bae655
commit 5b18c46ac2
22 files changed
+7453 -519

No files matched your search

+449
View File
@@ -0,0 +1,449 @@
// hyprcrd relay UI
let me = null;
let allTenants = [];
function $(id) { return document.getElementById(id); }
function toast(msg, isError = false) {
const t = $('toast');
t.textContent = msg;
t.classList.toggle('err', isError);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
method: opts.method || 'GET',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
body: opts.body ? JSON.stringify(opts.body) : undefined,
credentials: 'same-origin',
});
return res.json().catch(() => ({ error: 'invalid response' }));
}
function showView(name) {
// If the user hasn't changed their password yet, force them to that view
// unless they're currently in setup/login/change-password (admin/anon flows)
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
// Block admin view on mobile — even if someone tries programmatically
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
name = 'compose';
}
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
if (['compose', 'account', 'admin'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-buttons').style.display = 'flex';
$('nav-compose').classList.toggle('active', name === 'compose');
$('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin');
} else {
// Setup, login, and forced change-password hide all nav
$('nav-buttons').classList.add('hidden');
}
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
loadInbox();
$('compose-body')?.focus();
} else if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
loadAccountSecret();
} else if (name === 'change-password') {
$('cp-current')?.focus();
$('cp-err').textContent = '';
}
}
async function doChangePassword() {
const current = $('cp-current').value;
const newPw = $('cp-new').value;
const newPw2 = $('cp-new2').value;
const err = $('cp-err');
err.textContent = '';
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } });
if (res.ok) {
toast('password changed');
$('cp-current').value = '';
$('cp-new').value = '';
$('cp-new2').value = '';
// After password change, route based on whether they have a secret
const status = await refreshMe();
if (!status.has_secret) {
showView('account');
toast('now set your client secret');
} else {
showView('compose');
}
} else {
err.textContent = res.error || 'failed';
}
}
// ── Setup ──
async function doSetup() {
const username = $('setup-username').value.trim();
const password = $('setup-password').value;
const superSecret = $('setup-super-secret').value;
const err = $('setup-err');
err.textContent = '';
if (!username) { err.textContent = 'username required'; return; }
if (password.length < 4) { err.textContent = 'password too short'; return; }
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
if (res.ok) {
await refreshMe();
showView('account');
toast('admin created — now set your client secret');
} else {
err.textContent = res.error || 'failed';
}
}
// ── Login ──
async function doLogin() {
const username = $('login-username').value.trim();
const password = $('login-password').value;
const err = $('login-err');
err.textContent = '';
if (!username || !password) { err.textContent = 'username and password required'; return; }
const res = await api('api/login', { method: 'POST', body: { username, password } });
if (res.ok) {
const status = await refreshMe();
routePostLogin(status);
} else {
err.textContent = res.error || 'login failed';
}
}
// Decide which view to land on after a successful login / on initial load.
// Order: forced password change → set client secret → compose.
function routePostLogin(status) {
if (!status.password_changed) {
showView('change-password');
// Pre-fill the current-password input from what they just typed if
// we're coming from the login form (otherwise it's empty)
const lp = $('login-password');
if (lp && lp.value) {
$('cp-current').value = lp.value;
lp.value = '';
}
toast('change your password before continuing');
} else if (!status.has_secret) {
showView('account');
toast('set your client secret to start using the relay');
} else {
showView('compose');
}
}
async function doLogout() {
await api('api/logout', { method: 'POST' });
me = null;
$('nav-user').textContent = '';
$('nav-admin').classList.add('hidden');
showView('login');
}
async function refreshMe() {
const status = await api('api/status');
if (status.logged_in) {
me = {
username: status.username,
is_admin: status.is_admin,
has_secret: status.has_secret,
password_changed: status.password_changed,
};
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
$('nav-admin').classList.toggle('hidden', !me.is_admin);
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
}
return status;
}
// ── Compose ──
async function loadTenants() {
const res = await api('api/tenants');
if (res.tenants) allTenants = res.tenants;
}
function populateRecipientPicker() {
const sel = $('compose-recipient');
const field = $('compose-recipient-field');
sel.innerHTML = '';
// Always include "you" first as the default recipient (the value
// the form submits even when the picker is hidden)
if (me) {
const opt = document.createElement('option');
opt.value = me.username;
opt.textContent = `${me.username} (you)`;
sel.appendChild(opt);
}
for (const t of allTenants) {
if (me && t.username === me.username) continue;
const opt = document.createElement('option');
opt.value = t.username;
opt.textContent = t.username;
sel.appendChild(opt);
}
// Solo tenant → no choice to make. Hide the picker entirely so
// the compose form is just title + body.
if (field) {
field.classList.toggle('hidden', allTenants.length <= 1);
}
}
function clearCompose() {
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-body').focus();
setComposeStatus('');
}
function setComposeStatus(text, kind = '') {
const el = $('compose-status');
el.textContent = text;
el.className = 'status ' + kind;
}
async function sendCompose() {
const title = $('compose-title').value;
const body = $('compose-body').value;
const recipient = $('compose-recipient').value;
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
setComposeStatus('sending...');
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
if (res.ok) {
setComposeStatus(`sent to ${res.recipient}`, 'ok');
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-body').focus();
loadInbox();
refreshMe();
} else {
setComposeStatus(res.error || 'failed', 'err');
}
}
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
async function loadInbox() {
const status = await api('api/status');
$('inbox-count').textContent = status.inbox_count || 0;
const list = $('inbox-list');
if (!status.inbox_count) {
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
} else {
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
}
}
// ── Admin: users ──
async function loadUsers() {
const res = await api('api/users');
if (res.error) { toast(res.error, true); return; }
const users = res.users || [];
$('users-count').textContent = users.length;
const list = $('users-list');
if (!users.length) {
list.innerHTML = '<div class="empty">no users yet</div>';
return;
}
list.innerHTML = users.map(u => `
<div class="user-row" data-id="${u.id}">
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
<div class="info">
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
<div class="meta">created ${u.created_at}</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
</div>
</div>
`).join('');
}
async function createUser() {
const username = $('new-username').value.trim();
const password = $('new-password').value;
const isAdmin = $('new-is-admin').checked;
const status = $('create-user-status');
status.textContent = 'creating...';
status.className = 'status';
const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } });
if (res.ok) {
status.textContent = 'created — they set their own client secret on first login';
status.className = 'status ok';
$('new-username').value = '';
$('new-password').value = '';
$('new-is-admin').checked = false;
loadUsers();
setTimeout(() => { status.textContent = ''; }, 3000);
} else {
status.textContent = res.error || 'failed';
status.className = 'status err';
}
}
async function resetUserPassword(id, username) {
const newPw = prompt(`new password for ${username} (min 4 chars):`);
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
if (res.ok) toast('password updated');
else toast(res.error || 'failed', true);
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
const res = await api('api/users/' + id, { method: 'DELETE' });
if (res.ok) { toast('user deleted'); loadUsers(); }
else toast(res.error || 'failed', true);
}
// ── Account: client secret management ──
let currentAccountSecret = null;
async function loadAccountSecret() {
setAccountStatus('');
const res = await api('api/account/secret');
if (res.has_secret) {
currentAccountSecret = res.secret;
$('account-current').classList.remove('hidden');
$('account-no-secret').classList.add('hidden');
$('account-secret-display').type = 'password';
$('account-secret-display').value = currentAccountSecret;
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye"></i> show';
} else {
currentAccountSecret = null;
$('account-current').classList.add('hidden');
$('account-no-secret').classList.remove('hidden');
}
$('account-secret-input').value = '';
}
function toggleAccountSecret() {
const inp = $('account-secret-display');
const btn = $('account-secret-toggle');
if (inp.type === 'password') {
inp.type = 'text';
btn.innerHTML = '<i class="ph ph-eye-slash"></i> hide';
} else {
inp.type = 'password';
btn.innerHTML = '<i class="ph ph-eye"></i> show';
}
}
async function copyAccountSecret() {
if (!currentAccountSecret) return;
try {
await navigator.clipboard.writeText(currentAccountSecret);
toast('secret copied to clipboard');
} catch {
toast('copy failed — show and copy manually', true);
}
}
async function saveAccountSecret() {
const secret = $('account-secret-input').value;
if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; }
setAccountStatus('saving...');
const res = await api('api/account/secret', { method: 'POST', body: { secret } });
if (res.ok) {
setAccountStatus('saved', 'ok');
await loadAccountSecret();
await refreshMe();
setTimeout(() => setAccountStatus(''), 2000);
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
async function generateAccountSecret() {
if (currentAccountSecret) {
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
}
setAccountStatus('generating...');
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
if (res.ok) {
setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok');
await loadAccountSecret();
await refreshMe();
// Auto-show the new secret so user can copy
$('account-secret-display').type = 'text';
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye-slash"></i> hide';
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
async function clearAccountSecret() {
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
const res = await api('api/account/secret', { method: 'DELETE' });
if (res.ok) {
setAccountStatus('cleared', 'ok');
await loadAccountSecret();
await refreshMe();
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
function setAccountStatus(text, kind = '') {
const el = $('account-status');
if (!el) return;
el.textContent = text;
el.className = 'status ' + kind;
}
// ── Helpers ──
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
// ── Init ──
(async () => {
const status = await api('api/status');
if (status.setup) {
showView('setup');
} else if (!status.logged_in) {
showView('login');
} else {
await refreshMe();
routePostLogin(status);
}
})();
// Keyboard
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); }
if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); }
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
}
});
+186
View File
@@ -0,0 +1,186 @@
<?php
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title>
<link rel="stylesheet" href="fonts/phosphor.css">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<header>
<span class="dot"></span>
<h1>hyprcrd relay</h1>
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
<span class="spacer"></span>
<div id="nav-buttons" class="hidden" style="display:flex;gap:6px;align-items:center;">
<span id="nav-user" class="user"></span>
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
</div>
</header>
<main>
<!-- ── Setup view (first install) ── -->
<div id="view-setup" class="auth-form hidden">
<h2>welcome to relay</h2>
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
<div class="field">
<label>admin username</label>
<input type="text" id="setup-username" placeholder="alice" autocomplete="username">
</div>
<div class="field">
<label>admin password</label>
<input type="password" id="setup-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>super-shared secret</label>
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
<p style="font-size:9px;color:var(--dim);margin-top:4px;line-height:1.4;">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
</div>
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
<div id="setup-err" class="err"></div>
</div>
<!-- ── Login view ── -->
<div id="view-login" class="auth-form hidden">
<h2>login</h2>
<div class="field">
<label>username</label>
<input type="text" id="login-username" autocomplete="username">
</div>
<div class="field">
<label>password</label>
<input type="password" id="login-password" autocomplete="current-password">
</div>
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
<div id="login-err" class="err"></div>
</div>
<!-- ── Compose view ── -->
<div id="view-compose" class="hidden">
<div class="compose-card">
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
<div class="field" id="compose-recipient-field">
<label>recipient</label>
<select id="compose-recipient"></select>
</div>
<div class="field">
<label>title (optional)</label>
<input type="text" id="compose-title" autocomplete="off">
</div>
<div class="field">
<label>body</label>
<textarea id="compose-body" placeholder="write something..."></textarea>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
<span class="spacer"></span>
<span id="compose-status" class="status"></span>
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
</div>
</div>
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span></h2>
<div id="inbox-list"></div>
</div>
</div>
<!-- ── Forced password change (first login) ── -->
<div id="view-change-password" class="auth-form hidden">
<h2>change your password</h2>
<p>This is your first login. Please choose a new password before you can use the relay.</p>
<div class="field">
<label>current password</label>
<input type="password" id="cp-current" placeholder="the one the admin gave you" autocomplete="current-password">
</div>
<div class="field">
<label>new password</label>
<input type="password" id="cp-new" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>confirm new password</label>
<input type="password" id="cp-new2" placeholder="repeat" autocomplete="new-password">
</div>
<button class="btn btn-primary" onclick="doChangePassword()"><i class="ph ph-key"></i> change password</button>
<div id="cp-err" class="err"></div>
</div>
<!-- ── Account view ── -->
<div id="view-account" class="hidden">
<div class="section">
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
<p style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:14px;">
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
All your devices share the same secret. Other tenants never see it. The relay stores it
encrypted with the master key so it can route your messages.
</p>
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
</div>
<div id="account-current" class="hidden" style="margin-bottom:14px;">
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
<div style="display:flex;gap:8px;align-items:stretch;">
<input id="account-secret-display" type="password" readonly class="w-full" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
<div>
<label class="field-label">set your own secret</label>
<div style="display:flex;gap:8px;">
<input id="account-secret-input" type="text" placeholder="min 8 chars" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
</div>
</div>
<div style="text-align:center;color:var(--dim);font-family:var(--pixel);font-size:9px;">— or —</div>
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
</div>
<div id="account-status" class="status" style="margin-top:12px;"></div>
<div style="margin-top:24px;padding-top:14px;border-top:1px solid var(--border);">
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
</div>
</div>
</div>
<!-- ── Admin view ── -->
<div id="view-admin" class="hidden">
<div class="section">
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
<div id="users-list"></div>
<div class="create-user-form">
<div>
<label for="new-username" class="field-label">username</label>
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
</div>
<div>
<label for="new-password" class="field-label">password</label>
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<label class="checkbox">
<input type="checkbox" id="new-is-admin"> grant admin privileges
</label>
<p style="font-size:9px;color:var(--dim);line-height:1.4;margin-top:2px;">The new user sets their own client secret on first login.</p>
<div class="actions">
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
<span id="create-user-status" class="status"></span>
</div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script src="ui/app.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
@font-face { font-family:'Silkscreen'; font-weight:400; src:url('../fonts/silkscreen-400.ttf') format('truetype'); }
@font-face { font-family:'Silkscreen'; font-weight:700; src:url('../fonts/silkscreen-700.ttf') format('truetype'); }
@font-face { font-family:'JetBrains Mono'; font-weight:400; src:url('../fonts/jetbrains-mono-400.ttf') format('truetype'); }
:root {
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185;
--pixel:'Silkscreen', cursive;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family:'JetBrains Mono', monospace;
background:var(--bg); color:var(--text);
min-height:100dvh; display:flex; flex-direction:column;
}
.pixel { font-family:var(--pixel); }
.hidden { display:none !important; }
.shrink { flex-shrink:0; }
/* ── Header ── */
header {
padding:12px 20px; border-bottom:1px solid var(--border);
display:flex; align-items:center; gap:12px; flex-shrink:0;
background:var(--surface);
}
header h1 { font-family:var(--pixel); font-size:14px; color:var(--accent); }
header .dot {
width:8px; height:8px; border-radius:50%;
background:var(--accent); animation:pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
header .spacer { flex:1; }
header .user {
font-family:var(--pixel); font-size:10px; color:var(--muted);
display:flex; align-items:center; gap:6px;
}
header .user .badge {
background:var(--accent); color:var(--bg);
padding:1px 6px; font-size:8px; border-radius:2px;
}
header .nav-btn {
background:none; border:1px solid var(--border); color:var(--muted);
cursor:pointer; padding:5px 10px;
font-family:var(--pixel); font-size:9px; border-radius:3px;
transition:all .15s;
}
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
/* ── Layouts ── */
main {
flex:1; display:flex; flex-direction:column;
padding:20px; gap:16px; max-width:920px; width:100%;
margin:0 auto;
}
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
.auth-form {
display:flex; flex-direction:column; align-items:center;
padding:48px 24px; gap:14px; max-width:380px; margin:0 auto;
}
.auth-form h2 { font-family:var(--pixel); font-size:13px; color:var(--accent); margin-bottom:4px; }
.auth-form p { font-size:11px; color:var(--muted); text-align:center; line-height:1.5; }
.auth-form .field { width:100%; }
.auth-form .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.auth-form input {
width:100%; background:var(--surface); border:1px solid var(--border);
color:var(--bright); padding:9px 12px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.auth-form input:focus { border-color:var(--accent); outline:none; }
.auth-form .btn { width:100%; }
.auth-form .err {
font-family:var(--pixel); font-size:9px; color:var(--danger);
min-height:11px; text-align:center;
}
/* ── Buttons ── */
.btn {
padding:10px 18px; border:none; border-radius:3px;
font-family:var(--pixel); font-size:11px; cursor:pointer;
transition:all .15s;
}
.btn:active { opacity:.7; }
.btn-primary { background:var(--accent); color:var(--bg); }
.btn-primary:hover { filter:brightness(1.1); }
.btn-secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
.btn-secondary:hover { color:var(--accent); border-color:var(--accent); }
.btn-danger { background:transparent; border:1px solid var(--danger); color:var(--danger); }
.btn-danger:hover { background:var(--danger); color:var(--bg); }
/* ── Compose view ── */
#view-compose:not(.hidden) {
display: flex;
flex-direction: column;
gap: 16px;
}
.compose-card {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
display:flex; flex-direction:column; gap:10px;
}
.compose-card h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase;
}
.compose-card .field { display:flex; flex-direction:column; gap:4px; }
.compose-card .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
}
.compose-card input, .compose-card select, .compose-card textarea {
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 10px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.compose-card input:focus, .compose-card select:focus, .compose-card textarea:focus {
border-color:var(--accent); outline:none;
}
.compose-card textarea { min-height:140px; resize:vertical; line-height:1.55; }
.compose-card .actions { display:flex; gap:8px; align-items:center; }
.compose-card .actions .spacer { flex:1; }
.compose-card .status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
.compose-card .status.ok { color:var(--accent); }
.compose-card .status.err { color:var(--danger); }
/* ── Inbox + admin lists ── */
.section {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
}
.section h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase; margin-bottom:10px;
display:flex; align-items:center; gap:8px;
}
.section h2 .count {
background:var(--bg); padding:2px 6px; border-radius:2px;
font-size:9px; color:var(--muted);
}
.section .empty { font-size:11px; color:var(--dim); font-style:italic; padding:8px 0; }
.inbox-item {
display:flex; align-items:flex-start; gap:10px;
padding:10px 0; border-bottom:1px solid var(--border);
}
.inbox-item:last-child { border-bottom:none; }
.inbox-item .meta {
font-family:var(--pixel); font-size:9px; color:var(--dim);
white-space:nowrap; flex-shrink:0;
}
.inbox-item .content { flex:1; min-width:0; }
.inbox-item .content .title {
font-size:12px; color:var(--bright); font-weight:bold;
margin-bottom:2px; word-break:break-word;
}
.inbox-item .content .body {
font-size:11px; color:var(--text); white-space:pre-wrap;
word-break:break-word; max-height:120px; overflow:auto;
}
.inbox-item .from { font-family:var(--pixel); font-size:9px; color:var(--accent2); }
.inbox-item .del {
background:none; border:none; color:var(--dim); cursor:pointer;
padding:4px; font-size:14px; flex-shrink:0;
}
.inbox-item .del:hover { color:var(--danger); }
.user-row {
display:flex; align-items:center; gap:12px;
padding:10px 0; border-bottom:1px solid var(--border);
}
.user-row:last-child { border-bottom:none; }
.user-row .info { flex:1; }
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
.user-row .badge {
background:var(--accent); color:var(--bg);
padding:1px 6px; font-family:var(--pixel); font-size:8px;
text-transform:uppercase; border-radius:2px;
}
.user-row .actions { display:flex; gap:6px; }
.user-row .actions .btn { padding:4px 10px; font-size:9px; }
.create-user-form {
display:flex; flex-direction:column; gap:10px;
padding-top:14px; border-top:1px solid var(--border); margin-top:14px;
}
.create-user-form .field-label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.create-user-form input[type="text"],
.create-user-form input[type="password"] {
width:100%;
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 11px; font-size:12px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.create-user-form input:focus { border-color:var(--accent); outline:none; }
.create-user-form label.checkbox {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
display:flex; align-items:center; gap:6px; cursor:pointer;
}
.create-user-form label.checkbox input[type="checkbox"] {
accent-color:var(--accent); cursor:pointer;
}
.create-user-form .actions {
display:flex; gap:8px; align-items:center;
margin-top:4px;
}
.toast {
position:fixed; top:20px; right:20px;
background:var(--accent); color:var(--bg);
padding:10px 18px; border-radius:3px;
font-family:var(--pixel); font-size:11px;
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
}
.toast.show { opacity:1; }
.toast.err { background:var(--danger); }
.notice { padding:10px 14px; border-radius:3px; font-size:11px; margin-bottom:12px; }
.notice.info { background:rgba(56,189,248,0.1); border:1px solid var(--accent); color:var(--accent); }
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
/* ── Phosphor icons ── */
.ph { display:inline-block; vertical-align:-2px; }
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
header h1 + .ph { font-size:12px; }
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
.section h2 .ph, .compose-card h2 .ph { font-size:13px; vertical-align:-2px; }
/* ── Mobile: hide admin, identity, and the compose nav button ── */
@media (max-width: 700px) {
#nav-admin { display:none !important; }
#view-admin { display:none !important; }
#nav-user { display:none !important; }
#nav-compose { display:none !important; }
}
/* ── Desktop layout ── */
@media (min-width: 901px) {
/* Subtle background gradient gives the page some depth */
body {
background:
radial-gradient(ellipse at top left, rgba(56,189,248,0.04), transparent 50%),
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.04), transparent 50%),
var(--bg);
}
/* Wider canvas, more breathing room */
main {
max-width: 1200px;
padding: 32px 32px 48px;
gap: 24px;
}
/* Header: bigger title, more padding */
header {
padding: 14px 32px;
}
header h1 {
font-size: 15px;
letter-spacing: 1px;
}
header .nav-btn {
padding: 7px 12px;
}
header .user {
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
}
/* Compose view: 2-column grid (compose | inbox) */
#view-compose:not(.hidden) {
display: grid !important;
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
/* Cards: depth via shadow + larger padding */
.compose-card,
.section {
padding: 22px 24px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 8px 24px -8px rgba(0,0,0,0.4);
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
}
.compose-card h2,
.section h2 {
font-size: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
/* Big textarea for desktop typing */
.compose-card textarea {
min-height: 320px;
font-size: 14px;
padding: 12px 14px;
}
.compose-card input,
.compose-card select {
padding: 9px 12px;
font-size: 14px;
}
.compose-card .field label {
font-size: 10px;
margin-bottom: 6px;
}
.compose-card .actions {
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.compose-card .actions .btn {
padding: 11px 22px;
font-size: 12px;
}
/* Inbox card: stick to top of viewport, hover lifts it */
#inbox-section {
position: sticky;
top: 24px;
transition: transform .15s, box-shadow .15s;
}
#inbox-section:hover {
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 12px 32px -8px rgba(0,0,0,0.5),
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
}
/* Admin view: 2-column grid (user list | create form) */
#view-admin:not(.hidden) .section {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"header header"
"list form";
gap: 28px;
align-items: start;
}
#view-admin .section h2 { grid-area: header; }
#view-admin .section #users-list { grid-area: list; }
#view-admin .section .create-user-form {
grid-area: form;
margin-top: 0;
padding-top: 0;
border-top: none;
padding-left: 24px;
border-left: 1px solid var(--border);
}
/* User row polish: hover lift */
.user-row {
padding: 14px 12px;
margin: 0 -12px;
border-radius: 3px;
transition: background .12s;
}
.user-row:hover {
background: color-mix(in srgb, var(--raised) 70%, transparent);
}
.user-row .name {
font-size: 12px;
}
/* Auth forms (setup/login) — vertically center on viewport */
.auth-form {
margin-top: 64px;
padding: 40px 32px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 16px 48px -16px rgba(0,0,0,0.5);
max-width: 420px;
}
.auth-form h2 {
font-size: 14px;
margin-bottom: 8px;
}
/* Buttons: subtle press */
.btn { transition: all .12s; }
.btn-primary:hover {
filter: brightness(1.15);
transform: translateY(-1px);
box-shadow: 0 4px 12px -4px rgba(56,189,248,0.5);
}
.btn-secondary:hover { transform: translateY(-1px); }
.btn-danger:hover { transform: translateY(-1px); }
}