multi-tenant relay und card-push to user
This commit is contained in:
1 parent
7219bae655
commit
5b18c46ac2
22 files changed
+7453
-519
No files matched your search
+449
@@ -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 => ({
|
||||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||
}[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(); }
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in new issue
Block a user