Files
Bookra/apps/auth-service/internal/handlers/admin.go
T
Tomas Dvorak 48c3e15a38 cleanup
2026-05-05 09:48:07 +02:00

681 lines
25 KiB
Go

package handlers
import (
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AdminDashboard provides a visual management interface for the auth service
type AdminDashboard struct {
cfg *config.Config
db *db.DB
}
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
return &AdminDashboard{cfg: cfg, db: database}
}
// RegisterRoutes registers admin routes
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
admin := r.Group("/admin")
{
admin.GET("", a.RenderDashboard)
admin.GET("/api/config", a.GetConfig)
admin.GET("/api/prices", a.GetPrices)
admin.GET("/api/stats", a.GetStats)
}
}
// GetConfig returns current configuration (sanitized)
func (a *AdminDashboard) GetConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"appEnv": a.cfg.AppEnv,
"port": a.cfg.Port,
"frontendURL": a.cfg.FrontendURL,
"neonAuthURL": a.cfg.NeonAuthURL,
"smtpConfigured": gin.H{
"host": a.cfg.SMTPHost,
"port": a.cfg.SMTPPort,
"from": a.cfg.EmailFrom,
},
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
})
}
// GetPrices returns configured Stripe prices
func (a *AdminDashboard) GetPrices(c *gin.Context) {
prices := []gin.H{}
planNames := map[string]string{
"starter": "Starter Plan",
"pro": "Pro Plan",
"business": "Business Plan",
"monthly": "Monthly Plan",
"growth": "Growth Plan (Pro alias)",
"multi-location": "Multi-Location (Business alias)",
}
currencies := []string{"czk", "usd"}
for planCode, priceID := range a.cfg.StripePriceIDs {
if strings.TrimSpace(priceID) == "" {
continue
}
// Parse plan:currency format
parts := strings.Split(planCode, ":")
displayName := planNames[planCode]
currency := ""
if len(parts) == 2 {
planCode = parts[0]
currency = parts[1]
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
}
if displayName == "" {
displayName = planCode
}
prices = append(prices, gin.H{
"planCode": planCode,
"currency": currency,
"priceID": priceID,
"displayName": displayName,
"configured": true,
})
}
c.JSON(http.StatusOK, gin.H{
"prices": prices,
"currencies": currencies,
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"secretConfigured": a.cfg.StripeSecretConfigured(),
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
"pricesConfigured": len(prices) > 0,
})
}
// GetStats returns database statistics
func (a *AdminDashboard) GetStats(c *gin.Context) {
if a.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
stats, err := a.db.GetStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// RenderDashboard renders the HTML admin dashboard
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminHTML)
}
const adminHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookra Auth Service Admin</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
:root {
--canvas: 40 25% 97%;
--canvas-subtle: 40 20% 94%;
--canvas-muted: 40 15% 89%;
--ink: 25 15% 12%;
--ink-muted: 25 10% 42%;
--ink-subtle: 25 8% 58%;
--accent: 17 55% 42%;
--accent-hover: 17 60% 37%;
--accent-subtle: 17 45% 94%;
--success: 145 45% 38%;
--success-subtle: 145 35% 94%;
--error: 0 60% 52%;
--error-subtle: 0 50% 96%;
--border: 30 12% 86%;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Newsreader", Georgia, ui-serif, serif;
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
color: hsl(var(--ink));
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
@media (min-width: 640px) {
.container { padding: 2rem; }
}
@media (min-width: 1024px) {
.container { padding: 3rem; }
}
header {
margin-bottom: 3rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.logo svg {
width: 32px;
height: 32px;
color: hsl(var(--accent));
}
header h1 {
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
font-weight: 600;
letter-spacing: -0.02em;
color: hsl(var(--ink));
}
header p {
font-size: 1.125rem;
color: hsl(var(--ink-muted));
max-width: 600px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-card .icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--accent-subtle));
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.stat-card .icon svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
.stat-card.success .icon svg { color: hsl(var(--success)); }
.stat-value {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1.875rem;
font-weight: 600;
color: hsl(var(--ink));
line-height: 1;
margin-bottom: 0.375rem;
}
.stat-label {
font-size: 0.875rem;
color: hsl(var(--ink-muted));
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 1.5rem;
}
.card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.card-header svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.card-header h2 {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--ink));
}
.card-body {
padding: 1.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
}
.status.active {
background: hsl(var(--success-subtle));
color: hsl(var(--success));
}
.status.inactive {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
text-align: left;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
th {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-weight: 500;
color: hsl(var(--ink-muted));
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tr:last-child td { border-bottom: none; }
.env-value {
font-family: "JetBrains Mono", ui-monospace, monospace;
background: hsl(var(--canvas-muted));
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--ink));
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
background: hsl(var(--accent-subtle));
color: hsl(var(--accent));
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
.info-row:last-child { border-bottom: none; }
.info-label {
color: hsl(var(--ink-muted));
font-size: 0.9375rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: hsl(var(--ink-subtle));
}
.loading svg {
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
padding: 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: hsl(var(--ink-muted));
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 0.75rem;
color: hsl(var(--ink-subtle));
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>Auth Service Admin</h1>
</div>
<p>Monitor users, configure billing plans, and manage service health.</p>
</header>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Active (7d)</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Magic Links Sent</div>
</div>
<div class="stat-card success">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">New This Week</div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<h2>Service Configuration</h2>
</div>
<div class="card-body" id="config-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading configuration...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<h2>Billing Plans</h2>
</div>
<div class="card-body" id="prices-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading plans...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<h2>API Endpoints</h2>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody>
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<h2>Service Overview</h2>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Authentication</span>
<span>Magic links, JWT, OAuth</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span>Stripe subscriptions</span>
</div>
<div class="info-row">
<span class="info-label">Database</span>
<span>Neon PostgreSQL</span>
</div>
<div class="info-row">
<span class="info-label">Email</span>
<span>SMTP transactional</span>
</div>
</div>
</div>
</div>
</div>
<script>
// Load stats
fetch('/admin/api/stats')
.then(r => r.json())
.then(data => {
const cards = document.querySelectorAll('.stat-card');
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
})
.catch(err => {
document.getElementById('stats-grid').innerHTML =
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
});
// Load configuration
fetch('/admin/api/config')
.then(r => r.json())
.then(data => {
let html = '<div class="info-row">' +
'<span class="info-label">Environment</span>' +
'<span class="env-value">' + data.appEnv + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Port</span>' +
'<span class="env-value">' + data.port + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Neon Auth</span>' +
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">SMTP</span>' +
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Google OAuth</span>' +
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Stripe</span>' +
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>';
document.getElementById('config-content').innerHTML = html;
})
.catch(err => {
document.getElementById('config-content').innerHTML =
'<div class="error">Failed to load configuration</div>';
});
// Load prices
fetch('/admin/api/prices')
.then(r => r.json())
.then(data => {
if (!data.prices || data.prices.length === 0) {
document.getElementById('prices-content').innerHTML =
'<div class="empty-state">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
'<p>No Stripe prices configured</p>' +
'</div>';
return;
}
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
data.prices.forEach(p => {
html += '<tr>' +
'<td>' + p.displayName + '</td>' +
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
'</tr>';
});
html += '</tbody></table>';
document.getElementById('prices-content').innerHTML = html;
})
.catch(err => {
document.getElementById('prices-content').innerHTML =
'<div class="error">Failed to load prices</div>';
});
</script>
</body>
</html>`