Add accounts.eagle0.net user self-service portal (#5760)

Implements a user-facing account management portal at accounts.eagle0.net
that allows non-admin users to view their account information and delete
their account.

The same admin server backend handles both admin.eagle0.net (admin-only)
and accounts.eagle0.net (user self-service), distinguished by Host header.

Changes:
- nginx: Add server blocks for accounts.eagle0.net
- admin_server: Add host detection (isAccountsHost/isAdminHost)
- admin_server: Add requireUser auth wrapper (auth without admin check)
- admin_server: Add /my-account, /my-account/delete, /goodbye routes
- admin_server: Modify login flow to not require admin for accounts portal
- templates: Add my_account_layout.html, my_account.html, goodbye.html
- templates: Update login.html to show different text per portal

Post-deploy manual steps required:
1. DNS: Add A record for accounts.eagle0.net -> droplet IP
2. SSL: Run certbot on droplet for accounts.eagle0.net certificate

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:46:42 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 60ef23e4e0
commit ecf7f78a06
6 changed files with 786 additions and 53 deletions
+40
View File
@@ -269,4 +269,44 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
# HTTP server for Accounts Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Accounts Console (user self-service portal)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/accounts.eagle0.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
location / {
proxy_pass http://admin:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
} }
@@ -78,6 +78,8 @@ var (
invitationsRowsTemplate *template.Template invitationsRowsTemplate *template.Template
accountsTemplate *template.Template accountsTemplate *template.Template
loginTemplate *template.Template loginTemplate *template.Template
myAccountTemplate *template.Template
goodbyeTemplate *template.Template
) )
func main() { func main() {
@@ -150,6 +152,16 @@ func main() {
log.Fatalf("Failed to parse login template: %v", err) log.Fatalf("Failed to parse login template: %v", err)
} }
myAccountTemplate, err = template.ParseFS(templatesFS, "templates/my_account_layout.html", "templates/my_account.html")
if err != nil {
log.Fatalf("Failed to parse my account template: %v", err)
}
goodbyeTemplate, err = template.ParseFS(templatesFS, "templates/goodbye.html")
if err != nil {
log.Fatalf("Failed to parse goodbye template: %v", err)
}
// Log configuration at startup // Log configuration at startup
log.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS) log.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS)
@@ -195,6 +207,11 @@ func main() {
// Set up HTTP routes // Set up HTTP routes
// All admin routes require authentication except login/logout/health // All admin routes require authentication except login/logout/health
// accounts.eagle0.net routes for user self-service
http.HandleFunc("/my-account", requireUser(handleMyAccountPage))
http.HandleFunc("/my-account/delete", requireUser(handleMyAccountDelete))
http.HandleFunc("/goodbye", handleGoodbyePage)
// Admin and accounts portal share common routes (differentiated by host)
http.HandleFunc("/", handleIndex) http.HandleFunc("/", handleIndex)
http.HandleFunc("/games", requireAuth(handleGamesPage)) http.HandleFunc("/games", requireAuth(handleGamesPage))
http.HandleFunc("/games/upload", requireAuth(handleGameUpload)) // Must be before /games/ to match first http.HandleFunc("/games/upload", requireAuth(handleGameUpload)) // Must be before /games/ to match first
@@ -231,11 +248,27 @@ func main() {
} }
} }
// isAccountsHost returns true if the request is for accounts.eagle0.net
func isAccountsHost(r *http.Request) bool {
return strings.HasPrefix(r.Host, "accounts.")
}
// isAdminHost returns true if the request is for admin.eagle0.net or localhost
func isAdminHost(r *http.Request) bool {
return strings.HasPrefix(r.Host, "admin.") || strings.HasPrefix(r.Host, "localhost")
}
func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
// Route based on host
if isAccountsHost(r) {
http.Redirect(w, r, "/my-account", http.StatusFound)
return
}
http.Redirect(w, r, "/games", http.StatusFound) http.Redirect(w, r, "/games", http.StatusFound)
} }
@@ -2360,9 +2393,10 @@ const (
// LoginPageData is the data for the login page // LoginPageData is the data for the login page
type LoginPageData struct { type LoginPageData struct {
Title string Title string
Error string Error string
BuildInfo BuildInfo BuildInfo BuildInfo
IsAccountsHost bool // true if on accounts.eagle0.net
} }
// requireAuth wraps a handler to require admin authentication // requireAuth wraps a handler to require admin authentication
@@ -2398,20 +2432,74 @@ func requireAuth(next http.HandlerFunc) http.HandlerFunc {
} }
} }
func handleLoginPage(w http.ResponseWriter, r *http.Request) { // requireUser wraps a handler to require user authentication (not necessarily admin)
// If already logged in with valid token, redirect to users // Used for accounts.eagle0.net self-service portal
token := getAdminToken(r) func requireUser(next http.HandlerFunc) http.HandlerFunc {
if token != "" { return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := createAdminContext(r) token := getAdminToken(r)
defer cancel() if token == "" {
if _, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{Limit: 1}); err == nil { http.Redirect(w, r, "/login?redirect="+r.URL.Path, http.StatusFound)
redirect := r.URL.Query().Get("redirect")
if redirect == "" {
redirect = "/users"
}
http.Redirect(w, r, redirect, http.StatusFound)
return return
} }
// Validate token by calling GetCurrentUser (any authenticated user can call this)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
_, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{})
if err != nil {
log.Printf("User auth check failed: %v", err)
// Clear invalid token
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login?error=session_expired&redirect="+r.URL.Path, http.StatusFound)
return
}
next(w, r)
}
}
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
isAccounts := isAccountsHost(r)
token := getAdminToken(r)
// If already logged in with valid token, redirect appropriately
if token != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
if isAccounts {
// For accounts portal, just check if user is authenticated (not admin required)
if _, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{}); err == nil {
redirect := r.URL.Query().Get("redirect")
if redirect == "" {
redirect = "/my-account"
}
http.Redirect(w, r, redirect, http.StatusFound)
return
}
} else {
// For admin portal, require admin privileges
if _, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{Limit: 1}); err == nil {
redirect := r.URL.Query().Get("redirect")
if redirect == "" {
redirect = "/games"
}
http.Redirect(w, r, redirect, http.StatusFound)
return
}
}
} }
errorMsg := "" errorMsg := ""
@@ -2429,9 +2517,10 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) {
} }
data := LoginPageData{ data := LoginPageData{
Title: "Login", Title: "Login",
Error: errorMsg, Error: errorMsg,
BuildInfo: getBuildInfo(), BuildInfo: getBuildInfo(),
IsAccountsHost: isAccounts,
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@@ -2499,6 +2588,8 @@ func handleLoginStart(w http.ResponseWriter, r *http.Request) {
} }
func handleLoginComplete(w http.ResponseWriter, r *http.Request) { func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
isAccounts := isAccountsHost(r)
// Get state from cookie // Get state from cookie
stateCookie, err := r.Cookie(oauthStateCookie) stateCookie, err := r.Cookie(oauthStateCookie)
if err != nil || stateCookie.Value == "" { if err != nil || stateCookie.Value == "" {
@@ -2519,24 +2610,33 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
return return
} }
// Determine redirect target based on host
successRedirect := "/games"
if isAccounts {
successRedirect = "/my-account"
}
switch resp.Status { switch resp.Status {
case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS: case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS:
// Check if user is admin by trying to call admin service
md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken) md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken)
adminCtx := metadata.NewOutgoingContext(ctx, md) adminCtx := metadata.NewOutgoingContext(ctx, md)
_, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1}) // For admin portal, require admin privileges
if err != nil { // For accounts portal, any authenticated user is allowed
log.Printf("Admin check failed (auth server: %s, TLS: %v): %v", *authAddr, *authTLS, err) if !isAccounts {
// Clear cookies _, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1})
http.SetCookie(w, &http.Cookie{ if err != nil {
Name: oauthStateCookie, log.Printf("Admin check failed (auth server: %s, TLS: %v): %v", *authAddr, *authTLS, err)
Value: "", // Clear cookies
Path: "/", http.SetCookie(w, &http.Cookie{
MaxAge: -1, Name: oauthStateCookie,
}) Value: "",
http.Redirect(w, r, "/login?error=not_admin", http.StatusFound) Path: "/",
return MaxAge: -1,
})
http.Redirect(w, r, "/login?error=not_admin", http.StatusFound)
return
}
} }
// Set admin token cookie // Set admin token cookie
@@ -2557,8 +2657,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
MaxAge: -1, MaxAge: -1,
}) })
// Redirect to games (the main admin page) http.Redirect(w, r, successRedirect, http.StatusFound)
http.Redirect(w, r, "/games", http.StatusFound)
case authpb.OAuthStatus_OAUTH_STATUS_PENDING: case authpb.OAuthStatus_OAUTH_STATUS_PENDING:
// OAuth not complete yet - show polling page (shouldn't happen with redirect flow) // OAuth not complete yet - show polling page (shouldn't happen with redirect flow)
@@ -2566,7 +2665,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<!DOCTYPE html> fmt.Fprintf(w, `<!DOCTYPE html>
<html> <html>
<head> <head>
<title>Completing Login - Eagle Admin</title> <title>Completing Login - Eagle0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
@@ -2581,12 +2680,13 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
<script> <script>
let attempts = 0; let attempts = 0;
const maxAttempts = 60; const maxAttempts = 60;
const successRedirect = '%s';
function checkStatus() { function checkStatus() {
fetch('/login/check') fetch('/login/check')
.then(resp => resp.json()) .then(resp => resp.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
window.location.href = '/games'; window.location.href = data.redirect || successRedirect;
} else if (data.pending) { } else if (data.pending) {
attempts++; attempts++;
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
@@ -2603,7 +2703,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
checkStatus(); checkStatus();
</script> </script>
</body> </body>
</html>`) </html>`, successRedirect)
case authpb.OAuthStatus_OAUTH_STATUS_FAILED: case authpb.OAuthStatus_OAUTH_STATUS_FAILED:
http.Redirect(w, r, "/login?error="+resp.ErrorMessage, http.StatusFound) http.Redirect(w, r, "/login?error="+resp.ErrorMessage, http.StatusFound)
@@ -2617,6 +2717,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
} }
func handleLoginCheck(w http.ResponseWriter, r *http.Request) { func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
isAccounts := isAccountsHost(r)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Get state from cookie // Get state from cookie
@@ -2644,6 +2745,12 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
// Determine redirect target based on host
successRedirect := "/games"
if isAccounts {
successRedirect = "/my-account"
}
switch resp.Status { switch resp.Status {
case authpb.OAuthStatus_OAUTH_STATUS_PENDING: case authpb.OAuthStatus_OAUTH_STATUS_PENDING:
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@@ -2651,25 +2758,28 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
}) })
case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS: case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS:
// Check if user is admin by trying to call admin service
md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken) md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken)
adminCtx := metadata.NewOutgoingContext(ctx, md) adminCtx := metadata.NewOutgoingContext(ctx, md)
_, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1}) // For admin portal, require admin privileges
if err != nil { // For accounts portal, any authenticated user is allowed
log.Printf("User is not admin: %v", err) if !isAccounts {
// Clear oauth state cookie _, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1})
http.SetCookie(w, &http.Cookie{ if err != nil {
Name: oauthStateCookie, log.Printf("User is not admin: %v", err)
Value: "", // Clear oauth state cookie
Path: "/", http.SetCookie(w, &http.Cookie{
MaxAge: -1, Name: oauthStateCookie,
}) Value: "",
json.NewEncoder(w).Encode(map[string]interface{}{ Path: "/",
"success": false, MaxAge: -1,
"error": "not_admin", })
}) json.NewEncoder(w).Encode(map[string]interface{}{
return "success": false,
"error": "not_admin",
})
return
}
} }
// Set admin token cookie // Set admin token cookie
@@ -2692,7 +2802,7 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"success": true, "success": true,
"redirect": "/games", "redirect": successRedirect,
}) })
case authpb.OAuthStatus_OAUTH_STATUS_FAILED: case authpb.OAuthStatus_OAUTH_STATUS_FAILED:
@@ -2726,5 +2836,180 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
// Redirect based on host
if isAccountsHost(r) {
http.Redirect(w, r, "/goodbye", http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound) http.Redirect(w, r, "/login", http.StatusFound)
} }
// =============================================================================
// Account Self-Service Handlers (for accounts.eagle0.net)
// =============================================================================
// MyAccountPageData is the data for the user's account page
type MyAccountPageData struct {
Title string
UserID string
DisplayName string
AvatarURL string
CreatedAt string
LastLoginAt string
OAuthIdentities []OAuthIdentityInfo
BuildInfo BuildInfo
}
// handleMyAccountPage shows the current user's account information
func handleMyAccountPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := getAdminToken(r)
if token == "" {
http.Redirect(w, r, "/login?redirect=/my-account", http.StatusFound)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
// Get current user info via auth service
userResp, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{})
if err != nil {
log.Printf("Failed to get current user: %v", err)
http.Redirect(w, r, "/login?error=session_expired&redirect=/my-account", http.StatusFound)
return
}
user := userResp.User
// Try to get more detailed info via admin service (if user is admin, this will work)
// For non-admin users, we use the basic info from GetCurrentUser
var identities []OAuthIdentityInfo
createdAt := ""
lastLoginAt := ""
// Try admin API for detailed info (only admins can call this successfully for themselves)
adminResp, err := adminClient.GetUser(ctx, &adminpb.GetUserRequest{UserId: user.UserId})
if err == nil && adminResp.User != nil {
for _, id := range adminResp.User.OauthIdentities {
identities = append(identities, OAuthIdentityInfo{
Provider: id.Provider,
ProviderUserID: id.ProviderUserId,
Email: id.Email,
})
}
if adminResp.User.CreatedAt != nil {
createdAt = adminResp.User.CreatedAt.AsTime().Format("2006-01-02 15:04")
}
if adminResp.User.LastLoginAt != nil {
lastLoginAt = adminResp.User.LastLoginAt.AsTime().Format("2006-01-02 15:04")
}
}
data := MyAccountPageData{
Title: "My Account",
UserID: user.UserId,
DisplayName: user.DisplayName,
AvatarURL: user.AvatarUrl,
CreatedAt: createdAt,
LastLoginAt: lastLoginAt,
OAuthIdentities: identities,
BuildInfo: getBuildInfo(),
}
w.Header().Set("Content-Type", "text/html")
if err := myAccountTemplate.ExecuteTemplate(w, "my_account_layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
// handleMyAccountDelete handles the user deleting their own account
func handleMyAccountDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := getAdminToken(r)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
// Get current user ID
userResp, err := authClient.GetCurrentUser(ctx, &authpb.GetCurrentUserRequest{})
if err != nil {
log.Printf("Failed to get current user: %v", err)
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
return
}
userID := userResp.User.UserId
// Delete the user's own account via admin service
// Note: The admin service should allow users to delete their own account
deleteResp, err := adminClient.DeleteUser(ctx, &adminpb.DeleteUserRequest{
UserId: userID,
})
if err != nil {
log.Printf("Failed to delete user %s: %v", userID, err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `<div class="alert alert-error">Failed to delete account: %v</div>`, err)
return
}
if !deleteResp.Success {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, deleteResp.ErrorMessage)
return
}
log.Printf("User %s deleted their own account", userID)
// Clear auth cookie
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to goodbye page
w.Header().Set("HX-Redirect", "/goodbye")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Account deleted. Redirecting...</div>`)
}
// GoodbyePageData is the data for the goodbye page after account deletion
type GoodbyePageData struct {
Title string
}
// handleGoodbyePage shows the goodbye page after account deletion
func handleGoodbyePage(w http.ResponseWriter, r *http.Request) {
data := GoodbyePageData{
Title: "Goodbye",
}
w.Header().Set("Content-Type", "text/html")
if err := goodbyeTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Eagle0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<div class="goodbye-container">
<article class="goodbye-card">
<header>
<h1>Goodbye</h1>
</header>
<div class="goodbye-content">
<p class="message">Your account has been successfully deleted.</p>
<p class="details">All your data has been removed from our systems. We're sorry to see you go!</p>
</div>
<footer>
<p class="note">If you ever want to play Eagle0 again, you can create a new account at any time.</p>
</footer>
</article>
</div>
</main>
<style>
.goodbye-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.goodbye-card {
max-width: 500px;
width: 100%;
text-align: center;
}
.goodbye-card header {
margin-bottom: 1.5rem;
}
.goodbye-card header h1 {
margin: 0;
color: var(--pico-primary);
}
.goodbye-content {
margin-bottom: 2rem;
}
.goodbye-content .message {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.goodbye-content .details {
color: var(--pico-muted-color);
margin: 0;
}
.goodbye-card footer {
border-top: 1px solid var(--pico-muted-border-color);
padding-top: 1rem;
}
.goodbye-card .note {
font-size: 0.9rem;
color: var(--pico-muted-color);
margin: 0;
}
</style>
</body>
</html>
@@ -2,8 +2,13 @@
<div class="login-container"> <div class="login-container">
<article class="login-card"> <article class="login-card">
<header> <header>
{{if .IsAccountsHost}}
<h2>Sign In</h2>
<p>Sign in to manage your Eagle0 account.</p>
{{else}}
<h2>Admin Login</h2> <h2>Admin Login</h2>
<p>Sign in with your admin account to manage users.</p> <p>Sign in with your admin account to manage users.</p>
{{end}}
</header> </header>
{{if .Error}} {{if .Error}}
@@ -53,7 +58,11 @@
</div> </div>
<footer> <footer>
{{if .IsAccountsHost}}
<p class="note">After signing in with your OAuth provider, you'll be redirected back to manage your account.</p>
{{else}}
<p class="note">After signing in with your OAuth provider, return here and go to <a href="/login/complete">/login/complete</a> to finish the login process.</p> <p class="note">After signing in with your OAuth provider, return here and go to <a href="/login/complete">/login/complete</a> to finish the login process.</p>
{{end}}
</footer> </footer>
</article> </article>
</div> </div>
@@ -0,0 +1,297 @@
{{define "content"}}
<div class="page-header">
<h1>My Account</h1>
</div>
<section class="account-section">
<h2>Profile</h2>
<div class="profile-info">
{{if .AvatarURL}}
<img src="{{.AvatarURL}}" alt="Avatar" class="avatar">
{{end}}
<dl class="account-details">
<dt>Display Name</dt>
<dd>{{if .DisplayName}}{{.DisplayName}}{{else}}<em class="no-value">(not set)</em>{{end}}</dd>
<dt>User ID</dt>
<dd><code class="user-id">{{.UserID}}</code></dd>
{{if .CreatedAt}}
<dt>Account Created</dt>
<dd>{{.CreatedAt}}</dd>
{{end}}
{{if .LastLoginAt}}
<dt>Last Login</dt>
<dd>{{.LastLoginAt}}</dd>
{{end}}
</dl>
</div>
</section>
{{if .OAuthIdentities}}
<section class="account-section">
<h2>Linked Accounts</h2>
<div class="oauth-identities-list">
{{range .OAuthIdentities}}
<div class="oauth-identity-card">
<span class="provider-badge {{.Provider}}">{{.Provider}}</span>
<span class="email">{{.Email}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
<section class="account-section danger-zone">
<h2>Danger Zone</h2>
<div class="danger-content">
<div class="danger-description">
<h3>Delete Account</h3>
<p>Permanently delete your account and all associated data. This action cannot be undone.</p>
</div>
<button class="btn-danger" onclick="confirmDelete()">Delete My Account</button>
</div>
</section>
<dialog id="delete-confirm-dialog">
<article style="max-width: 450px;">
<header>
<h3>Delete Account?</h3>
</header>
<p>Are you sure you want to permanently delete your account? This will:</p>
<ul>
<li>Remove all your account data</li>
<li>Unlink all OAuth providers</li>
<li>This action <strong>cannot be undone</strong></li>
</ul>
<div id="delete-feedback"></div>
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="secondary outline" onclick="cancelDelete()">Cancel</button>
<button class="btn-danger" onclick="executeDelete()" id="confirm-delete-btn">Yes, Delete My Account</button>
</footer>
</article>
</dialog>
<script>
function confirmDelete() {
document.getElementById('delete-feedback').innerHTML = '';
document.getElementById('delete-confirm-dialog').showModal();
}
function cancelDelete() {
document.getElementById('delete-confirm-dialog').close();
}
function executeDelete() {
const btn = document.getElementById('confirm-delete-btn');
btn.disabled = true;
btn.textContent = 'Deleting...';
fetch('/my-account/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
return;
}
// Check for HX-Redirect header
const redirect = response.headers.get('HX-Redirect');
if (redirect) {
window.location.href = redirect;
return;
}
return response.text();
})
.then(html => {
if (html) {
if (html.includes('alert-success')) {
window.location.href = '/goodbye';
} else {
document.getElementById('delete-feedback').innerHTML = html;
btn.disabled = false;
btn.textContent = 'Yes, Delete My Account';
}
}
})
.catch(err => {
document.getElementById('delete-feedback').innerHTML =
'<div class="alert alert-error">An error occurred: ' + err.message + '</div>';
btn.disabled = false;
btn.textContent = 'Yes, Delete My Account';
});
}
</script>
<style>
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0;
}
.account-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--pico-card-background-color);
border-radius: 8px;
border: 1px solid var(--pico-muted-border-color);
}
.account-section h2 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.25rem;
border-bottom: 1px solid var(--pico-muted-border-color);
padding-bottom: 0.5rem;
}
.profile-info {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.account-details {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
}
.account-details dt {
font-weight: 600;
color: var(--pico-muted-color);
}
.account-details dd {
margin: 0;
}
.user-id {
font-size: 0.85rem;
word-break: break-all;
}
.no-value {
color: var(--pico-muted-color);
}
.oauth-identities-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.oauth-identity-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: var(--pico-background-color);
border-radius: 6px;
border: 1px solid var(--pico-muted-border-color);
}
.provider-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
color: white;
}
.provider-badge.discord { background-color: #5865F2; }
.provider-badge.google { background-color: #4285F4; }
.provider-badge.github { background-color: #333; }
.provider-badge.apple { background-color: #000; }
.provider-badge.steam { background-color: #1b2838; }
.provider-badge.twitch { background-color: #9146FF; }
.email {
font-size: 0.95rem;
}
.danger-zone {
border-color: #dc3545;
}
.danger-zone h2 {
color: #dc3545;
border-color: #dc3545;
}
.danger-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.danger-description h3 {
margin: 0 0 0.25rem 0;
font-size: 1rem;
}
.danger-description p {
margin: 0;
color: var(--pico-muted-color);
font-size: 0.9rem;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
color: white;
white-space: nowrap;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 600px) {
.profile-info {
flex-direction: column;
}
.danger-content {
flex-direction: column;
align-items: stretch;
}
}
</style>
{{end}}
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Eagle0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>
<ul>
<li class="brand">Eagle0 Account</li>
<li><a href="/my-account">My Account</a></li>
<li><a href="/logout" class="logout-link">Logout</a></li>
</ul>
</nav>
<main class="container">
{{template "content" .}}
</main>
</body>
</html>