mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:15:45 +00:00
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:
@@ -269,4 +269,44 @@ http {
|
||||
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
|
||||
accountsTemplate *template.Template
|
||||
loginTemplate *template.Template
|
||||
myAccountTemplate *template.Template
|
||||
goodbyeTemplate *template.Template
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -150,6 +152,16 @@ func main() {
|
||||
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.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS)
|
||||
|
||||
@@ -195,6 +207,11 @@ func main() {
|
||||
|
||||
// Set up HTTP routes
|
||||
// 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("/games", requireAuth(handleGamesPage))
|
||||
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) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on host
|
||||
if isAccountsHost(r) {
|
||||
http.Redirect(w, r, "/my-account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/games", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -2363,6 +2396,7 @@ type LoginPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
BuildInfo BuildInfo
|
||||
IsAccountsHost bool // true if on accounts.eagle0.net
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// If already logged in with valid token, redirect to users
|
||||
// requireUser wraps a handler to require user authentication (not necessarily admin)
|
||||
// Used for accounts.eagle0.net self-service portal
|
||||
func requireUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := getAdminToken(r)
|
||||
if token != "" {
|
||||
ctx, cancel := createAdminContext(r)
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/login?redirect="+r.URL.Path, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token by calling GetCurrentUser (any authenticated user can call this)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{Limit: 1}); err == nil {
|
||||
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 = "/users"
|
||||
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 := ""
|
||||
@@ -2432,6 +2520,7 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
Title: "Login",
|
||||
Error: errorMsg,
|
||||
BuildInfo: getBuildInfo(),
|
||||
IsAccountsHost: isAccounts,
|
||||
}
|
||||
|
||||
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) {
|
||||
isAccounts := isAccountsHost(r)
|
||||
|
||||
// Get state from cookie
|
||||
stateCookie, err := r.Cookie(oauthStateCookie)
|
||||
if err != nil || stateCookie.Value == "" {
|
||||
@@ -2519,12 +2610,20 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine redirect target based on host
|
||||
successRedirect := "/games"
|
||||
if isAccounts {
|
||||
successRedirect = "/my-account"
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS:
|
||||
// Check if user is admin by trying to call admin service
|
||||
md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken)
|
||||
adminCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// For admin portal, require admin privileges
|
||||
// For accounts portal, any authenticated user is allowed
|
||||
if !isAccounts {
|
||||
_, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1})
|
||||
if err != nil {
|
||||
log.Printf("Admin check failed (auth server: %s, TLS: %v): %v", *authAddr, *authTLS, err)
|
||||
@@ -2538,6 +2637,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login?error=not_admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set admin token cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
@@ -2557,8 +2657,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
// Redirect to games (the main admin page)
|
||||
http.Redirect(w, r, "/games", http.StatusFound)
|
||||
http.Redirect(w, r, successRedirect, http.StatusFound)
|
||||
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_PENDING:
|
||||
// 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>
|
||||
<html>
|
||||
<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="/static/style.css">
|
||||
</head>
|
||||
@@ -2581,12 +2680,13 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
|
||||
<script>
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const successRedirect = '%s';
|
||||
function checkStatus() {
|
||||
fetch('/login/check')
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = '/games';
|
||||
window.location.href = data.redirect || successRedirect;
|
||||
} else if (data.pending) {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
@@ -2603,7 +2703,7 @@ func handleLoginComplete(w http.ResponseWriter, r *http.Request) {
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
</html>`, successRedirect)
|
||||
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_FAILED:
|
||||
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) {
|
||||
isAccounts := isAccountsHost(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Get state from cookie
|
||||
@@ -2644,6 +2745,12 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine redirect target based on host
|
||||
successRedirect := "/games"
|
||||
if isAccounts {
|
||||
successRedirect = "/my-account"
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_PENDING:
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
@@ -2651,10 +2758,12 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_SUCCESS:
|
||||
// Check if user is admin by trying to call admin service
|
||||
md := metadata.Pairs("authorization", "Bearer "+resp.AccessToken)
|
||||
adminCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// For admin portal, require admin privileges
|
||||
// For accounts portal, any authenticated user is allowed
|
||||
if !isAccounts {
|
||||
_, err := adminClient.ListUsers(adminCtx, &adminpb.ListUsersRequest{Limit: 1})
|
||||
if err != nil {
|
||||
log.Printf("User is not admin: %v", err)
|
||||
@@ -2671,6 +2780,7 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set admin token cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
@@ -2692,7 +2802,7 @@ func handleLoginCheck(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"redirect": "/games",
|
||||
"redirect": successRedirect,
|
||||
})
|
||||
|
||||
case authpb.OAuthStatus_OAUTH_STATUS_FAILED:
|
||||
@@ -2726,5 +2836,180 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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">
|
||||
<article class="login-card">
|
||||
<header>
|
||||
{{if .IsAccountsHost}}
|
||||
<h2>Sign In</h2>
|
||||
<p>Sign in to manage your Eagle0 account.</p>
|
||||
{{else}}
|
||||
<h2>Admin Login</h2>
|
||||
<p>Sign in with your admin account to manage users.</p>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
{{if .Error}}
|
||||
@@ -53,7 +58,11 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{end}}
|
||||
</footer>
|
||||
</article>
|
||||
</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>
|
||||
Reference in New Issue
Block a user