basic panel with env based password

This commit is contained in:
samantha42
2026-05-31 22:40:46 +02:00
parent 2c1997ed6e
commit 6df2b96dd9
7 changed files with 621 additions and 20 deletions
+76 -1
View File
@@ -1,12 +1,16 @@
package main package main
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"sync"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -37,7 +41,7 @@ func saveConfig(c *Config) error {
func main() { func main() {
err := godotenv.Load() err := godotenv.Load(".env")
if err != nil { if err != nil {
log.Fatal("Error loading .env file") log.Fatal("Error loading .env file")
} }
@@ -58,6 +62,10 @@ func main() {
http.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/icon.png") }) http.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/icon.png") })
http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/login.html") }) http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/login.html") })
http.HandleFunc("GET /admin", requireAuth(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/admin.html")
}))
http.HandleFunc("POST /newsletter/subscribe", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("POST /newsletter/subscribe", func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
@@ -101,6 +109,40 @@ func main() {
} }
}) })
http.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
password := r.Form.Get("password")
auth := r.Form.Get("totp")
fmt.Println(os.Getenv("password"), os.Getenv("auth"))
if password == os.Getenv("password") && auth == os.Getenv("auth") {
fmt.Println("logged in")
token := generateSession()
sessionMu.Lock()
sessionStore[token] = time.Now().Add(12 * time.Hour)
sessionMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(12 * time.Hour),
})
// htmx reads this header and does the redirect client-side
w.Header().Set("HX-Redirect", "/admin")
w.WriteHeader(http.StatusOK)
} else {
// failure
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div class="error-banner">// incorrect password or 2FA code</div>`)
}
})
//http.HandleFunc("/portfolio", Portfolio) //http.HandleFunc("/portfolio", Portfolio)
http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound) http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound)
@@ -111,3 +153,36 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
} }
var sessionStore = map[string]time.Time{}
var sessionMu sync.RWMutex
func generateSession() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
sessionMu.RLock()
expiry, ok := sessionStore[cookie.Value]
sessionMu.RUnlock()
if !ok || time.Now().After(expiry) {
sessionMu.Lock()
delete(sessionStore, cookie.Value)
sessionMu.Unlock()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
BIN
View File
Binary file not shown.
+516
View File
@@ -0,0 +1,516 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin · samantha42.xyz</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #fafaf8;
--bg-2: #f1efe8;
--text: #1a1a18;
--muted: #888780;
--border: rgba(0,0,0,0.12);
--accent: #185FA5;
--accent-dim: #E6F1FB;
--success: #0F6E56;
--success-dim: #E1F5EE;
--danger: #A32D2D;
--danger-dim: #FCEBEB;
--warn: #854F0B;
--warn-dim: #FAEEDA;
--ff-mono: 'IBM Plex Mono', monospace;
--ff-sans: 'IBM Plex Sans', sans-serif;
}
body.night {
--bg: #111110;
--bg-2: #1a1a18;
--text: #e8e6df;
--muted: #5F5E5A;
--border: rgba(255,255,255,0.1);
--accent-dim: #042C53;
}
body {
font-family: var(--ff-mono);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 13px;
}
nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 48px;
border-bottom: 0.5px solid var(--border);
}
.nav-logo { font-size: 13px; color: var(--text); text-decoration: none; }
.nav-right { display: flex; align-items: center; gap: 1.5rem; }
.nav-right a { font-size: 12px; color: var(--muted); text-decoration: none; }
.nav-right a:hover { color: var(--text); }
.shell { display: grid; grid-template-columns: 200px 1fr; min-height: calc(100vh - 48px); }
aside {
border-right: 0.5px solid var(--border);
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebar-label {
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 8px;
}
.sidebar-links { display: flex; flex-direction: column; gap: 2px; }
.sidebar-links a {
font-size: 12px;
color: var(--muted);
text-decoration: none;
padding: 5px 8px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.sidebar-links a:hover { background: var(--bg-2); color: var(--text); }
.sidebar-links a.active { background: var(--bg-2); color: var(--text); }
main { padding: 2rem; max-width: 900px; }
.page-header { margin-bottom: 2rem; }
.page-title { font-size: 20px; font-weight: 500; color: var(--text); }
.page-sub { font-size: 11px; color: var(--muted); letter-spacing: 1px; }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-2);
border-radius: 6px;
padding: 1rem;
}
.stat-label { font-size: 10px; color: var(--muted); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 500; color: var(--text); }
.stat-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 8px;
border-bottom: 0.5px solid var(--border);
}
.section-head h2 { font-size: 13px; font-weight: 500; color: var(--text); }
.job-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 2rem; }
.job-row {
display: grid;
grid-template-columns: 1fr 120px 100px 80px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 0.5px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.job-name { font-size: 12px; color: var(--text); }
.job-time { font-size: 11px; color: var(--muted); }
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
font-family: var(--ff-mono);
letter-spacing: 0.5px;
}
.badge-ok { background: var(--success-dim); color: var(--success); }
.badge-fail { background: var(--danger-dim); color: var(--danger); }
.badge-run { background: var(--warn-dim); color: var(--warn); }
.badge-idle { background: var(--bg-2); color: var(--muted); }
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.run-btn {
font-family: var(--ff-mono);
font-size: 11px;
padding: 4px 10px;
border: 0.5px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.run-btn:hover { background: var(--bg-2); color: var(--text); }
.compose-card {
border: 0.5px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
margin-bottom: 2rem;
}
.compose-head {
background: #0C447C;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.compose-head-title { font-size: 12px; color: #B5D4F4; letter-spacing: 1px; text-transform: uppercase; }
.compose-head-sub { font-size: 11px; color: #378ADD; }
.compose-body { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.field-label {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
text-transform: uppercase;
display: block;
margin-bottom: 5px;
}
.field-input, .field-select, .field-textarea {
width: 100%;
font-family: var(--ff-mono);
font-size: 13px;
padding: 9px 12px;
border: 0.5px solid var(--border);
border-radius: 5px;
background: var(--bg-2);
color: var(--text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.field-input:focus, .field-select:focus, .field-textarea:focus {
border-color: #378ADD;
box-shadow: 0 0 0 3px rgba(55,138,221,0.12);
}
.field-textarea { resize: vertical; min-height: 200px; line-height: 1.6; }
.compose-footer {
padding: 1rem 1.25rem;
border-top: 0.5px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.sub-count { font-size: 11px; color: var(--muted); }
.btn-send {
font-family: var(--ff-mono);
font-size: 12px;
padding: 8px 20px;
background: #185FA5;
color: #E6F1FB;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
letter-spacing: 0.5px;
}
.btn-send:hover { background: #0C447C; }
.btn-send:active { transform: scale(0.98); }
.btn-preview {
font-family: var(--ff-mono);
font-size: 12px;
padding: 8px 16px;
background: transparent;
color: var(--muted);
border: 0.5px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-preview:hover { background: var(--bg-2); color: var(--text); }
.send-result {
font-size: 11px;
color: var(--success);
display: none;
margin-top: 4px;
}
.htmx-indicator {
opacity: 0;
font-size: 11px;
color: var(--muted);
transition: opacity 0.2s;
}
.htmx-request .htmx-indicator { opacity: 1; }
.section-gap { margin-bottom: 2.5rem; }
.ticker-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0.5px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.ticker-symbol { font-weight: 500; min-width: 80px; }
.ticker-rating-buy { color: var(--success); }
.ticker-rating-sell { color: var(--danger); }
.ticker-muted { color: var(--muted); flex: 1; }
@media (max-width: 640px) {
.shell { grid-template-columns: 1fr; }
aside { display: none; }
.job-row { grid-template-columns: 1fr 80px; }
.job-time, .run-btn { display: none; }
}
</style>
</head>
<body id="themeRoot">
<nav>
<a class="nav-logo" href="/">samantha_vero_friis</a>
<div class="nav-right">
<span style="font-size:11px;color:var(--muted)">admin portal</span>
<a href="/logout">logout →</a>
</div>
</nav>
<div class="shell">
<aside>
<div>
<p class="sidebar-label">admin</p>
<div class="sidebar-links">
<a href="#overview" class="active">overview</a>
<a href="#jobs">job status</a>
<a href="#newsletter">newsletter</a>
<a href="#research">eq research</a>
</div>
</div>
<div>
<p class="sidebar-label">site</p>
<div class="sidebar-links">
<a href="/" target="_blank">view site ↗</a>
<a href="/git" target="_blank">git repo ↗</a>
</div>
</div>
</aside>
<main>
<div class="page-header" id="overview">
<div class="page-title">admin</div>
<div class="page-sub">samantha42.xyz · control panel</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">subscribers</div>
<div class="stat-value" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">newsletter list</div>
</div>
<div class="stat-card">
<div class="stat-label">emails sent</div>
<div class="stat-value" hx-get="/admin/api/stats/sent" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">all time</div>
</div>
<div class="stat-card">
<div class="stat-label">analyses</div>
<div class="stat-value">2</div>
<div class="stat-sub">published</div>
</div>
<div class="stat-card">
<div class="stat-label">jobs ok</div>
<div class="stat-value" hx-get="/admin/api/stats/jobs" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="stat-sub">last 24h</div>
</div>
</div>
<div class="section-gap" id="jobs">
<div class="section-head">
<h2>// job status</h2>
<button class="run-btn" hx-get="/admin/api/jobs" hx-target="#job-list" hx-swap="innerHTML">refresh</button>
</div>
<div class="job-list" id="job-list"
hx-get="/admin/api/jobs"
hx-trigger="load"
hx-swap="innerHTML">
<div class="job-row">
<span class="job-name">portfolio-engine</span>
<span class="job-time">checking...</span>
<span class="badge badge-idle"><span class="dot"></span> loading</span>
<span></span>
</div>
</div>
</div>
<div class="section-gap" id="newsletter">
<div class="section-head">
<h2>// send newsletter</h2>
<span class="htmx-indicator" id="send-spinner">sending...</span>
</div>
<div class="compose-card">
<div class="compose-head">
<span class="compose-head-title">equity research · newsletter</span>
<span class="compose-head-sub">to all subscribers</span>
</div>
<div class="compose-body">
<div>
<label class="field-label" for="nl-subject">subject</label>
<input class="field-input" type="text" id="nl-subject" name="subject"
placeholder="MSTR: why the premium collapses without a bid" />
</div>
<div>
<label class="field-label" for="nl-ticker">ticker (optional)</label>
<input class="field-input" type="text" id="nl-ticker" name="ticker"
placeholder="MSTR · sell" style="width:200px;" />
</div>
<div>
<label class="field-label" for="nl-body">body</label>
<textarea class="field-textarea" id="nl-body" name="body"
placeholder="// thesis&#10;&#10;The truth I keep coming back to..."></textarea>
</div>
<div>
<label class="field-label" for="nl-pdf">attach pdf (path on server)</label>
<input class="field-input" type="text" id="nl-pdf" name="pdf_path"
placeholder="/static/pdf/mstr.pdf" style="width:300px;" />
</div>
</div>
<div class="compose-footer">
<span class="sub-count" hx-get="/admin/api/stats/subscribers" hx-trigger="load" hx-swap="innerHTML"
style="font-size:11px;color:var(--muted)">— subscribers</span>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-preview" onclick="previewEmail()">preview</button>
<button class="btn-send"
hx-post="/admin/api/newsletter/send"
hx-include="#nl-subject,#nl-ticker,#nl-body,#nl-pdf"
hx-target="#send-result"
hx-swap="innerHTML"
hx-indicator="#send-spinner">
send to all →
</button>
</div>
</div>
<div id="send-result" style="padding:0 1.25rem 1rem;font-size:11px;"></div>
</div>
</div>
<div class="section-gap" id="research">
<div class="section-head">
<h2>// equity research</h2>
<a href="#newsletter" style="font-size:11px;color:var(--muted);text-decoration:none;">send as newsletter ↑</a>
</div>
<div class="ticker-row">
<span class="ticker-symbol">MSTR</span>
<span class="ticker-rating-sell">sell</span>
<span class="ticker-muted">Strategy · BTC proxy via debt issuance</span>
<a href="/static/pdf/mstr.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
</div>
<div class="ticker-row">
<span class="ticker-symbol">NOVO.B / NVO</span>
<span class="ticker-rating-buy">buy</span>
<span class="ticker-muted">Novo Nordisk · pipeline undervalued</span>
<a href="/static/pdf/novo.pdf" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;">pdf ↗</a>
</div>
<div style="margin-top:1.5rem;">
<div class="section-head">
<h2>// add new analysis</h2>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
<div>
<label class="field-label">ticker</label>
<input class="field-input" type="text" name="new_ticker" placeholder="AAPL" />
</div>
<div>
<label class="field-label">rating</label>
<select class="field-select" name="new_rating">
<option value="buy">buy</option>
<option value="sell">sell</option>
<option value="hold">hold</option>
</select>
</div>
</div>
<div style="margin-bottom:10px;">
<label class="field-label">summary</label>
<input class="field-input" type="text" name="new_summary" placeholder="one-line thesis" />
</div>
<div style="margin-bottom:10px;">
<label class="field-label">pdf path</label>
<input class="field-input" type="text" name="new_pdf" placeholder="/static/pdf/aapl.pdf" style="width:300px;" />
</div>
<button class="btn-send"
hx-post="/admin/api/research/add"
hx-include="[name='new_ticker'],[name='new_rating'],[name='new_summary'],[name='new_pdf']"
hx-target="#research-result"
hx-swap="innerHTML">
publish →
</button>
<div id="research-result" style="margin-top:8px;font-size:11px;"></div>
</div>
</div>
</main>
</div>
<script>
function previewEmail() {
const subject = document.getElementById('nl-subject').value;
const body = document.getElementById('nl-body').value;
const w = window.open('', '_blank');
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
body{font-family:'IBM Plex Mono',monospace;max-width:600px;margin:40px auto;padding:0 20px;background:#fafaf8;color:#1a1a18;font-size:13px;line-height:1.7;}
h1{font-size:18px;font-weight:500;margin-bottom:4px;}
.sub{font-size:11px;color:#888780;margin-bottom:2rem;}
pre{white-space:pre-wrap;font-family:inherit;}
.footer{margin-top:3rem;padding-top:1rem;border-top:0.5px solid #d3d1c7;font-size:10px;color:#888780;}
</style></head><body>
<h1>${subject || '(no subject)'}</h1>
<div class="sub">samantha42.xyz · equity research</div>
<pre>${body || '(no body)'}</pre>
<div class="footer">samantha42.xyz · me@samantha42.xyz · unsubscribe</div>
</body></html>`);
}
document.querySelectorAll('.sidebar-links a').forEach(a => {
a.addEventListener('click', function() {
document.querySelectorAll('.sidebar-links a').forEach(x => x.classList.remove('active'));
this.classList.add('active');
});
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+14 -2
View File
@@ -18,8 +18,7 @@
<nav> <nav>
<a class="nav-logo" href="#">samantha_vero_friis</a> <a class="nav-logo" href="#">samantha_vero_friis</a>
<div class="nav-links"> <div class="nav-links">
<a onclick="openOverlay()" >login</a> <a >login</a>
</div> </div>
</nav> </nav>
@@ -436,11 +435,24 @@
</div> </div>
<div class="footer-copy">© 2026 — all rights reserved</div> <div class="footer-copy">© 2026 — all rights reserved</div>
</footer> </footer>
<div id="login-overlay" style="min-width: 50%; position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); backdrop-filter: blur(12px);">
<div hx-get="login" hx-trigger="load" hx-swap="outerHTML">
<span class="spinner">Loading login page...</span>
</div>
</div>
<script> <script>
// just some fun. // just some fun.
console.log("%c👀 hi there!", "font-size:20px") console.log("%c👀 hi there!", "font-size:20px")
function closelogin() {
document.getElementById('login-overlay').style.display = 'none';
}
document.querySelector('.nav-links a').addEventListener('click', e => {
e.preventDefault();
document.getElementById('login-overlay').style.display = 'flex';
});
function makeday() { function makeday() {
themeRoot.classList.remove('night'); themeRoot.classList.remove('night');
+15 -17
View File
@@ -1,44 +1,45 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style> <style>
.login-wrap { min-height: 520px; display: flex; align-items: center; justify-content: center; padding: 2rem 0; } .login-wrap { min-width: 50%; min-height: 520px; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
.login-card { background: var(--bg); border: 0.5px solid var(--border); border-radius: 8px; width: 100%; max-width: 400px; overflow: hidden; } .login-card { background: white; border: 0.5px solid var(--color-border-tertiary); border-radius: 8px; width: 100%; max-width: 400px; overflow: hidden; }
.card-header { background: #0C447C; padding: 2rem 2rem 1.5rem; } .card-header { background: #0C447C; padding: 2rem 2rem 1.5rem; }
.brand { font-family: 'DM Serif Display', serif; font-size: 22px; color: #E6F1FB; letter-spacing: -0.5px; margin: 0 0 4px; } .brand { font-family: 'DM Serif Display', serif; font-size: 22px; color: #E6F1FB; letter-spacing: -0.5px; margin: 0 0 4px; }
.brand span { font-style: italic; color: #85B7EB; } .brand span { font-style: italic; color: #85B7EB; }
.brand-sub { font-family: 'DM Mono', monospace; font-size: 11px; color: #378ADD; letter-spacing: 2px; text-transform: uppercase; margin: 0; } .brand-sub { font-family: 'DM Mono', monospace; font-size: 11px; color: #378ADD; letter-spacing: 2px; text-transform: uppercase; margin: 0; }
.card-body { padding: 1.75rem 2rem; } .card-body { padding: 1.75rem 2rem; }
.field-group { margin-bottom: 1rem; } .field-group { margin-bottom: 1rem; }
.field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--muted); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 6px; } .field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 6px; }
.field-input { width: 100%; box-sizing: border-box; font-family: 'DM Mono', monospace; font-size: 14px; padding: 10px 12px; border-radius: 6px; border: 0.5px solid var(--border); background: var(--bg-2); color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; } .field-input { width: 100%; box-sizing: border-box; font-family: 'DM Mono', monospace; font-size: 14px; padding: 10px 12px; border-radius: 6px; border: 0.5px solid var(--color-border-tertiary); background: var(--color-background-secondary); color: var(--color-text-primary); transition: border-color 0.2s, box-shadow 0.2s; }
.field-input:focus { outline: none; border-color: #378ADD; box-shadow: 0 0 0 3px rgba(55,138,221,0.15); } .field-input:focus { outline: none; border-color: #378ADD; box-shadow: 0 0 0 3px rgba(55,138,221,0.15); }
.submit-btn { width: 100%; padding: 11px; background: #185FA5; color: #E6F1FB; border: none; border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 1px; cursor: pointer; transition: background 0.2s, transform 0.1s; margin-top: 0.5rem; } .submit-btn { width: 100%; padding: 11px; background: #185FA5; color: #E6F1FB; border: none; border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 1px; cursor: pointer; transition: background 0.2s, transform 0.1s; margin-top: 0.5rem; }
.submit-btn:hover { background: #0C447C; } .submit-btn:hover { background: #0C447C; }
.submit-btn:active { transform: scale(0.98); } .submit-btn:active { transform: scale(0.98); }
.field-input.htmx-error { border-color: #E24B4A; box-shadow: 0 0 0 3px rgba(226,75,74,0.1); } .htmx-indicator { opacity: 0; transition: opacity 0.2s; font-family: 'DM Mono', monospace; font-size: 11px; color: var(--color-text-secondary); text-align: center; margin-top: 8px; }
.error-banner { font-family: 'DM Mono', monospace; font-size: 11px; color: #A32D2D; background: #FCEBEB; border: 0.5px solid #F09595; border-radius: 6px; padding: 8px 12px; margin-bottom: 1rem; display: none; }
.htmx-indicator { opacity: 0; transition: opacity 0.2s; font-family: 'DM Mono', monospace; font-size: 11px; color: var(--muted); text-align: center; margin-top: 8px; }
.htmx-request .htmx-indicator { opacity: 1; } .htmx-request .htmx-indicator { opacity: 1; }
.htmx-request .submit-btn { background: #0C447C; cursor: not-allowed; } .htmx-request .submit-btn { background: #0C447C; cursor: not-allowed; pointer-events: none; }
.error-banner { font-family: 'DM Mono', monospace; font-size: 11px; color: #A32D2D; background: #FCEBEB; border: 0.5px solid #F09595; border-radius: 6px; padding: 8px 12px; margin-bottom: 1rem; }
</style> </style>
<div class="login-wrap"> <div class="login-wrap">
<div class="login-card"> <div class="login-card">
<div class="card-header"> <div class="card-header">
<p class="brand">samantha<span>42</span>.xyz</p> <a style="text-decoration: underline; font-size: 13px; color: #85B7EB; cursor: pointer; font-family: 'DM Mono', monospace;" onclick="closelogin()">← back</a>
<p class="brand" style="margin-top: 12px;">samantha<span>42</span>.xyz</p>
<p class="brand-sub">Secure Portal</p> <p class="brand-sub">Secure Portal</p>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- error slot — your server renders this partial on failure -->
<div id="login-error"></div> <div id="login-error"></div>
<form <form
hx-post="/login" hx-post="/login"
hx-target="#login-result" hx-target="#login-error"
hx-swap="outerHTML" hx-swap="innerHTML"
hx-indicator="#login-spinner" hx-indicator="#login-spinner"
hx-on::after-request="if(event.detail.successful && event.detail.xhr.status===200 && !event.detail.xhr.getResponseHeader('HX-Redirect')) {}"
> >
<div class="field-group"> <div class="field-group">
<label class="field-label" for="password">Password</label> <label class="field-label" for="password">Password</label>
@@ -69,12 +70,9 @@
</div> </div>
<button class="submit-btn" type="submit">Continue →</button> <button class="submit-btn" type="submit">Continue →</button>
<p class="htmx-indicator" id="login-spinner">verifying...</p> <p class="htmx-indicator" id="login-spinner">// verifying...</p>
</form> </form>
<!-- server swaps this element on response -->
<div id="login-result"></div>
</div> </div>
</div> </div>
</div> </div>