mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 20:43:01 +00:00
681 lines
25 KiB
Go
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>`
|