Rework admin console authentication and UI
This commit is contained in:
@@ -22,6 +22,64 @@ func BindAdminDependencies(rtmpSrv *stream.RTMPServer) {
|
||||
adminRTMP = rtmpSrv
|
||||
}
|
||||
|
||||
func AdminLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordHash(req.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
|
||||
monitor.Auditf("admin=%s signed in", user.Username)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminLogout(c *gin.Context) {
|
||||
operator, _ := c.Get("username")
|
||||
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
|
||||
monitor.Auditf("admin=%v signed out", operator)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
|
||||
}
|
||||
|
||||
func GetAdminSession(c *gin.Context) {
|
||||
username, _ := c.Get("username")
|
||||
role, _ := c.Get("role")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": username,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminOverview(c *gin.Context) {
|
||||
stats := monitor.GetSnapshot()
|
||||
chatStats := chat.StatsSnapshot{}
|
||||
@@ -83,10 +141,6 @@ func ListAdminLogs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func StreamAdminLogs(c *gin.Context) {
|
||||
if !authorizeAdminTokenFromQuery(c) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
@@ -114,39 +168,6 @@ func StreamAdminLogs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeAdminTokenFromQuery(c *gin.Context) bool {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"})
|
||||
return false
|
||||
}
|
||||
|
||||
claims, err := utils.ParseToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return false
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token subject"})
|
||||
return false
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return false
|
||||
}
|
||||
|
||||
if !user.Enabled || user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type updateRoleRequest struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -32,6 +34,12 @@ func Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
var existingUser model.User
|
||||
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||
@@ -146,3 +154,11 @@ func ChangePassword(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
||||
}
|
||||
|
||||
func bootstrapAdminUsername() string {
|
||||
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
|
||||
if adminUsername == "" {
|
||||
return "admin"
|
||||
}
|
||||
return adminUsername
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,47 +14,30 @@ import (
|
||||
"hightube/internal/utils"
|
||||
)
|
||||
|
||||
const adminSessionCookieName = "hightube_admin_session"
|
||||
|
||||
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
claims, err := utils.ParseToken(tokenStr)
|
||||
user, err := authenticateRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
switch {
|
||||
case errors.Is(err, errMissingToken):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
|
||||
case errors.Is(err, errInvalidToken):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
case errors.Is(err, errUserNotFound):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
case errors.Is(err, errDisabledAccount):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
default:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := strconv.ParseUint(claims.Subject, 10, 32)
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", uint(userID))
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("username", user.Username)
|
||||
c.Set("role", user.Role)
|
||||
c.Next()
|
||||
@@ -82,6 +66,58 @@ func RequestMetricsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errMissingToken = errors.New("missing token")
|
||||
errInvalidToken = errors.New("invalid token")
|
||||
errUserNotFound = errors.New("user not found")
|
||||
errDisabledAccount = errors.New("disabled account")
|
||||
)
|
||||
|
||||
func authenticateRequest(c *gin.Context) (*model.User, error) {
|
||||
tokenStr := extractToken(c)
|
||||
if tokenStr == "" {
|
||||
return nil, errMissingToken
|
||||
}
|
||||
|
||||
claims, err := utils.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, errInvalidToken
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
|
||||
if err != nil {
|
||||
return nil, errInvalidToken
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||
return nil, errUserNotFound
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
return nil, errDisabledAccount
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader != "" {
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
cookieToken, err := c.Cookie(adminSessionCookieName)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(cookieToken)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// CORSMiddleware handles cross-origin requests from web clients
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -23,13 +23,14 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||
// Public routes
|
||||
r.POST("/api/register", Register)
|
||||
r.POST("/api/login", Login)
|
||||
r.POST("/api/admin/login", AdminLogin)
|
||||
r.GET("/api/rooms/active", GetActiveRooms)
|
||||
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
||||
|
||||
// WebSocket endpoint for live chat
|
||||
r.GET("/api/ws/room/:room_id", WSHandler)
|
||||
r.GET("/admin", AdminPage)
|
||||
r.GET("/api/admin/logs/stream", StreamAdminLogs)
|
||||
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
|
||||
|
||||
// Protected routes (require JWT)
|
||||
authGroup := r.Group("/api")
|
||||
@@ -41,9 +42,11 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||
adminGroup := authGroup.Group("/admin")
|
||||
adminGroup.Use(AdminMiddleware())
|
||||
{
|
||||
adminGroup.GET("/session", GetAdminSession)
|
||||
adminGroup.GET("/overview", GetAdminOverview)
|
||||
adminGroup.GET("/health", GetAdminHealth)
|
||||
adminGroup.GET("/logs", ListAdminLogs)
|
||||
adminGroup.POST("/logout", AdminLogout)
|
||||
adminGroup.GET("/users", ListUsers)
|
||||
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
|
||||
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
@@ -9,217 +9,431 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1c2e;
|
||||
--bg2: #102944;
|
||||
--card: #f7f6f3;
|
||||
--ink: #12263d;
|
||||
--accent: #ff6b35;
|
||||
--accent2: #0ea5a3;
|
||||
--bg: #eef4fb;
|
||||
--bg-accent: #dbe9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fc;
|
||||
--ink: #19324d;
|
||||
--muted: #5a718a;
|
||||
--line: #d7e1eb;
|
||||
--primary: #2f6fed;
|
||||
--primary-strong: #1d4ed8;
|
||||
--primary-soft: #dce8ff;
|
||||
--danger: #dc2626;
|
||||
--ok: #15803d;
|
||||
--line: #d9d5cc;
|
||||
--danger-soft: #fee2e2;
|
||||
--success: #15803d;
|
||||
--success-soft: #dcfce7;
|
||||
--warning: #b45309;
|
||||
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 600px at -10% -10%, #2a4b70 0%, transparent 65%),
|
||||
radial-gradient(1000px 500px at 110% 0%, #1f4f6d 0%, transparent 65%),
|
||||
linear-gradient(140deg, var(--bg) 0%, var(--bg2) 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
|
||||
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.layout {
|
||||
|
||||
.shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 5vw, 42px);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
border: 1px solid rgba(47,111,237,0.12);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--primary-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
animation: appear 500ms ease-out;
|
||||
}
|
||||
@keyframes appear {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.22);
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
grid-column: 4 / span 6;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.full { grid-column: 1 / -1; }
|
||||
.col4 { grid-column: span 4; }
|
||||
.col6 { grid-column: span 6; }
|
||||
.col5 { grid-column: span 5; }
|
||||
.col7 { grid-column: span 7; }
|
||||
.col8 { grid-column: span 8; }
|
||||
h1 { margin: 0; color: #f8fafc; letter-spacing: .02em; }
|
||||
h2 { margin: 0 0 10px; font-size: 18px; }
|
||||
.topbar {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row.between {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tokenbox {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: min(800px, 100%);
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c8c2b7;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
input { width: 100%; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, var(--accent), #f97316);
|
||||
border: none;
|
||||
color: white;
|
||||
background: linear-gradient(120deg, var(--primary), #5994ff);
|
||||
font-weight: 700;
|
||||
transition: transform 140ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #184f77;
|
||||
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
|
||||
}
|
||||
|
||||
button.subtle {
|
||||
color: var(--ink);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
background: linear-gradient(120deg, #ef4444, #dc2626);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px dashed #c9c2b4;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.metric .k { font-size: 12px; opacity: .75; }
|
||||
.metric .v { font-size: 22px; font-weight: 700; }
|
||||
.mono { font-family: "IBM Plex Mono", monospace; font-size: 12px; }
|
||||
#logs {
|
||||
height: 280px;
|
||||
overflow: auto;
|
||||
background: #0e1624;
|
||||
color: #d9f6ea;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
.metric .k {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric .v {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.health-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-chip {
|
||||
min-width: 120px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.health-chip .k {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
border: 1px solid #1f3552;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#logs {
|
||||
height: 300px;
|
||||
overflow: auto;
|
||||
background: #f5f9ff;
|
||||
color: #17304d;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--line);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 6px;
|
||||
padding: 10px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill.ok { background: #dcfce7; color: var(--ok); }
|
||||
.pill.off { background: #fee2e2; color: var(--danger); }
|
||||
.pill.admin { background: #dbeafe; color: #1d4ed8; }
|
||||
.pill.user { background: #e2e8f0; color: #334155; }
|
||||
|
||||
.pill.ok {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.pill.off {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pill.admin {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.pill.user {
|
||||
background: #edf2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
color: var(--warning);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.col4, .col6, .col8 { grid-column: 1 / -1; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.topbar { flex-direction: column; align-items: stretch; }
|
||||
.login-card, .col4, .col5, .col7, .col8 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.health-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<div class="topbar">
|
||||
<h1>Hightube Admin Console</h1>
|
||||
<div class="tokenbox">
|
||||
<input id="token" placeholder="粘贴 admin JWT token(来自 /api/login)" />
|
||||
<button onclick="connectAll()">连接</button>
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<div class="brand">
|
||||
<div class="badge">Admin Panel</div>
|
||||
<h1>Hightube Control Console</h1>
|
||||
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
|
||||
</div>
|
||||
<div class="session-info" id="sessionInfo">Not signed in</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid" id="loginView">
|
||||
<div class="card login-card">
|
||||
<h2>Admin Sign In</h2>
|
||||
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
|
||||
<div class="stack">
|
||||
<input id="loginUsername" placeholder="Admin username" value="admin" />
|
||||
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
|
||||
<div class="row">
|
||||
<button onclick="login()">Sign In</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
Change the default admin password immediately after first login.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col8">
|
||||
<h2>系统状态</h2>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="mono" id="health"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col4">
|
||||
<h2>在线状态</h2>
|
||||
<div id="online"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col8">
|
||||
<h2>实时日志</h2>
|
||||
<div style="display:flex; gap:8px; margin-bottom:8px;">
|
||||
<button class="secondary" onclick="loadHistory()">加载历史日志</button>
|
||||
<select id="logLevel">
|
||||
<option value="">全部级别</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
<option value="audit">audit</option>
|
||||
</select>
|
||||
<input id="logKeyword" placeholder="关键词过滤" />
|
||||
<div class="panel-grid hidden" id="appView">
|
||||
<div class="card full">
|
||||
<div class="row between">
|
||||
<div>
|
||||
<h2 style="margin-bottom:6px;">Session</h2>
|
||||
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
|
||||
<button class="danger" onclick="logout()">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logs"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col4">
|
||||
<h2>操作说明</h2>
|
||||
<ul>
|
||||
<li>先在上方输入 admin token 并点击连接。</li>
|
||||
<li>用户管理支持搜索、角色切换、启用禁用、重置密码、删除。</li>
|
||||
<li>日志区会持续接收后端实时事件。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card full">
|
||||
<h2>用户管理</h2>
|
||||
<div style="display:flex; gap:8px; margin-bottom:8px;">
|
||||
<input id="userKeyword" placeholder="按用户名搜索" />
|
||||
<button class="secondary" onclick="loadUsers()">查询</button>
|
||||
<div class="card col7">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col5">
|
||||
<h2>Admin Password</h2>
|
||||
<div class="stack">
|
||||
<input id="oldPassword" type="password" placeholder="Current password" />
|
||||
<input id="newPassword" type="password" placeholder="New password" />
|
||||
<div class="row">
|
||||
<button onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col4">
|
||||
<h2>Live Status</h2>
|
||||
<div id="online"></div>
|
||||
<div class="health-strip" id="health" style="margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col8">
|
||||
<div class="row between" style="margin-bottom:10px;">
|
||||
<h2 style="margin:0;">Audit and Runtime Logs</h2>
|
||||
<div class="row">
|
||||
<select id="logLevel">
|
||||
<option value="">All levels</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
<option value="audit">audit</option>
|
||||
</select>
|
||||
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
|
||||
<button class="secondary" onclick="loadHistory()">Load History</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logs"></div>
|
||||
</div>
|
||||
|
||||
<div class="card full">
|
||||
<div class="row between" style="margin-bottom:10px;">
|
||||
<h2 style="margin:0;">User Management</h2>
|
||||
<div class="row">
|
||||
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
|
||||
<button class="secondary" onclick="loadUsers()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let evt = null;
|
||||
let overviewTimer = null;
|
||||
let healthTimer = null;
|
||||
let currentAdmin = null;
|
||||
|
||||
function authHeaders() {
|
||||
const token = document.getElementById('token').value.trim();
|
||||
return {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
function setSessionText(text) {
|
||||
document.getElementById('sessionInfo').textContent = text;
|
||||
}
|
||||
|
||||
function addLogLine(text) {
|
||||
@@ -228,62 +442,144 @@
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
async function connectAll() {
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
let data = null;
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (_) {
|
||||
data = { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function clearPolling() {
|
||||
if (overviewTimer) clearInterval(overviewTimer);
|
||||
if (healthTimer) clearInterval(healthTimer);
|
||||
overviewTimer = null;
|
||||
healthTimer = null;
|
||||
}
|
||||
|
||||
function disconnectLogs() {
|
||||
if (evt) {
|
||||
evt.close();
|
||||
evt = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
clearPolling();
|
||||
disconnectLogs();
|
||||
currentAdmin = null;
|
||||
setSessionText('Not signed in');
|
||||
document.getElementById('loginView').classList.remove('hidden');
|
||||
document.getElementById('appView').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showApp(session) {
|
||||
currentAdmin = session;
|
||||
setSessionText(`Signed in as ${session.username}`);
|
||||
document.getElementById('loginView').classList.add('hidden');
|
||||
document.getElementById('appView').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
try {
|
||||
const session = await api('/api/admin/session');
|
||||
showApp(session);
|
||||
await refreshAll();
|
||||
connectLiveLogs();
|
||||
clearPolling();
|
||||
overviewTimer = setInterval(loadOverview, 1000);
|
||||
healthTimer = setInterval(loadHealth, 1000);
|
||||
} catch (_) {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
try {
|
||||
await api('/api/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
document.getElementById('loginPassword').value = '';
|
||||
await ensureSession();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api('/api/admin/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
} finally {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
|
||||
connectLiveLogs();
|
||||
setInterval(loadOverview, 1000);
|
||||
setInterval(loadHealth, 1000);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const resp = await fetch('/api/admin/overview', { headers: authHeaders() });
|
||||
if (!resp.ok) {
|
||||
addLogLine('[error] 概览拉取失败,请检查 token 是否为 admin。');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const data = await api('/api/admin/overview');
|
||||
const sys = data.system || {};
|
||||
const stream = data.stream || {};
|
||||
const chat = data.chat || {};
|
||||
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="metric"><div class="k">运行时长(秒)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">请求总量</div><div class="v">${sys.requests_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">错误总量</div><div class="v">${sys.errors_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Errors</div><div class="v">${sys.errors_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">内存Alloc(MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">内存Sys(MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">CPU核心数</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">磁盘剩余/总量(GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">Alloc (MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Disk Free / Total (GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
|
||||
`;
|
||||
|
||||
document.getElementById('online').innerHTML = `
|
||||
<p>活跃流数量:<b>${stream.active_stream_count ?? 0}</b></p>
|
||||
<p>活跃聊天室:<b>${chat.room_count ?? 0}</b></p>
|
||||
<p>在线聊天连接:<b>${chat.total_connected_client ?? 0}</b></p>
|
||||
<div class="mono">流路径: ${(stream.active_stream_paths || []).join(', ') || '无'}</div>
|
||||
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
|
||||
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
|
||||
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
|
||||
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const resp = await fetch('/api/admin/health', { headers: authHeaders() });
|
||||
if (!resp.ok) return;
|
||||
const h = await resp.json();
|
||||
const h = await api('/api/admin/health');
|
||||
const dbOk = h.db && h.db.ok;
|
||||
document.getElementById('health').innerHTML =
|
||||
`API: <span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span> ` +
|
||||
`RTMP: <span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span> ` +
|
||||
`DB: <span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span>`;
|
||||
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
|
||||
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
|
||||
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
||||
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
||||
const resp = await fetch(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
|
||||
const items = data.items || [];
|
||||
const box = document.getElementById('logs');
|
||||
box.textContent = '';
|
||||
@@ -291,32 +587,24 @@
|
||||
}
|
||||
|
||||
function connectLiveLogs() {
|
||||
if (evt) evt.close();
|
||||
const token = document.getElementById('token').value.trim();
|
||||
evt = new EventSource('/api/admin/logs/stream?token=' + encodeURIComponent(token));
|
||||
disconnectLogs();
|
||||
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
|
||||
evt.onmessage = () => {};
|
||||
evt.addEventListener('log', (e) => {
|
||||
const item = JSON.parse(e.data);
|
||||
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
||||
});
|
||||
evt.onerror = () => {
|
||||
addLogLine('[warn] 实时日志连接断开,稍后可重连。');
|
||||
addLogLine('[warn] Live log stream disconnected.');
|
||||
};
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
||||
const resp = await fetch(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLogLine('[error] 用户列表拉取失败。');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
|
||||
const body = document.getElementById('usersBody');
|
||||
body.innerHTML = '';
|
||||
(data.items || []).forEach(u => {
|
||||
(data.items || []).forEach((u) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${u.id}</td>
|
||||
@@ -325,10 +613,10 @@
|
||||
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
|
||||
<td>${new Date(u.created_at).toLocaleString()}</td>
|
||||
<td class="actions">
|
||||
<button class="secondary" onclick="toggleRole(${u.id}, '${u.role}')">切换角色</button>
|
||||
<button class="secondary" onclick="toggleEnabled(${u.id}, ${u.enabled})">启用/禁用</button>
|
||||
<button class="secondary" onclick="resetPwd(${u.id})">重置密码</button>
|
||||
<button class="danger" onclick="deleteUser(${u.id})">删除</button>
|
||||
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
|
||||
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
|
||||
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
|
||||
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
@@ -337,42 +625,53 @@
|
||||
|
||||
async function toggleRole(id, role) {
|
||||
const next = role === 'admin' ? 'user' : 'admin';
|
||||
await fetch(`/api/admin/users/${id}/role`, {
|
||||
await api(`/api/admin/users/${id}/role`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ role: next })
|
||||
body: JSON.stringify({ role: next }),
|
||||
});
|
||||
loadUsers();
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function toggleEnabled(id, enabled) {
|
||||
await fetch(`/api/admin/users/${id}/enabled`, {
|
||||
await api(`/api/admin/users/${id}/enabled`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ enabled: !enabled })
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
loadUsers();
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function resetPwd(id) {
|
||||
const newPwd = prompt('输入新密码(至少 6 位)');
|
||||
const newPwd = prompt('Enter a new password for this user');
|
||||
if (!newPwd) return;
|
||||
await fetch(`/api/admin/users/${id}/reset-password`, {
|
||||
await api(`/api/admin/users/${id}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ new_password: newPwd })
|
||||
body: JSON.stringify({ new_password: newPwd }),
|
||||
});
|
||||
addLogLine(`[audit] user ${id} password reset requested`);
|
||||
addLogLine(`[audit] password reset requested for user ${id}`);
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
if (!confirm('确认删除该用户?')) return;
|
||||
await fetch(`/api/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
loadUsers();
|
||||
if (!confirm('Delete this user account?')) return;
|
||||
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const oldPassword = document.getElementById('oldPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
await api('/api/user/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
document.getElementById('oldPassword').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
alert('Password updated successfully.');
|
||||
}
|
||||
|
||||
window.addEventListener('load', ensureSession);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -59,7 +59,7 @@ func ensureAdminUser() {
|
||||
|
||||
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
|
||||
if adminPassword == "" {
|
||||
adminPassword = "admin123456"
|
||||
adminPassword = "admin"
|
||||
}
|
||||
|
||||
var user model.User
|
||||
@@ -106,5 +106,5 @@ func ensureAdminUser() {
|
||||
monitor.Warnf("Failed to create default admin room: %v", roomErr)
|
||||
}
|
||||
|
||||
monitor.Warnf("Default admin created: username=%s password=%s", adminUsername, adminPassword)
|
||||
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user