feat: auto-detect login text color based on background image brightness
This commit is contained in:
+1
-1
@@ -235,7 +235,7 @@ Navigate to **Admin Settings** (owner only) to configure:
|
||||
|
||||
### Login Page Customization
|
||||
|
||||
- **Background Image** — Upload a custom background image for the login page (max 5 MB, JPEG/PNG/WebP). The image is centered and fills the screen without distortion (`background-size: cover`).
|
||||
- **Background Image** — Upload a custom background image for the login page (max 5 MB, JPEG/PNG/WebP). The image is centered and fills the screen without distortion (`background-size: cover`). The text color (heading, subtitle, footer) is automatically adjusted based on the image brightness — light text for dark images, dark text for bright images.
|
||||
- **Card Style** — Choose between **Default** (solid card) and **Glass** (transparent, blurred backdrop).
|
||||
- **Subtitle** — Custom text shown below the application name on the login page. Leave empty for the default subtitle.
|
||||
|
||||
|
||||
@@ -121,4 +121,5 @@ In addition to environment variables, the following settings are configured thro
|
||||
| `lockout_duration` | `15` | Lockout duration in minutes |
|
||||
| `mfa_required` | `false` | Enforce MFA for all users |
|
||||
| `login_card_style` | `default` | Login card style: `default` or `glass` |
|
||||
| `login_text_color` | `light` | Login text color over background image: `light` or `dark` (auto-detected on upload) |
|
||||
| `login_subtitle` | _(empty)_ | Custom subtitle on the login page |
|
||||
|
||||
@@ -8,4 +8,7 @@ require (
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.42.0 // indirect
|
||||
require (
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmIT
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
@@ -15,6 +16,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
@@ -28,6 +32,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/auth"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/cron"
|
||||
@@ -341,6 +347,13 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
||||
}
|
||||
return style
|
||||
},
|
||||
"loginTextColor": func() string {
|
||||
c, _ := h.auth.GetSetting("login_text_color")
|
||||
if c == "" {
|
||||
return "light"
|
||||
}
|
||||
return c
|
||||
},
|
||||
"loginSubtitle": func() string {
|
||||
subtitle, _ := h.auth.GetSetting("login_subtitle")
|
||||
return subtitle
|
||||
@@ -3212,7 +3225,14 @@ func (h *Handler) handleLoginBrandingUpload(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image uploaded", clientIP(r))
|
||||
// Auto-detect brightness and set text color accordingly
|
||||
textColor := analyzeImageBrightness(data)
|
||||
if err := h.auth.SetSetting("login_text_color", textColor); err != nil {
|
||||
logging.Warn("Failed to save auto-detected text color: %v", err)
|
||||
}
|
||||
logging.Info("Login background uploaded: auto-detected text color = %s", textColor)
|
||||
|
||||
h.audit.Log(userID, audit.ActionBrandingChanged, fmt.Sprintf("Login background image uploaded (auto text color: %s)", textColor), clientIP(r))
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image uploaded successfully."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -3226,6 +3246,8 @@ func (h *Handler) handleLoginBrandingRemoveBg(w http.ResponseWriter, r *http.Req
|
||||
userID := h.getUserID(r)
|
||||
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
|
||||
os.Remove(bgPath)
|
||||
// Reset auto-detected text color
|
||||
_ = h.auth.SetSetting("login_text_color", "light")
|
||||
|
||||
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image removed", clientIP(r))
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image removed."), http.StatusSeeOther)
|
||||
@@ -3251,6 +3273,55 @@ func (h *Handler) handleLoginBgServe(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// analyzeImageBrightness decodes an image and computes the average perceived
|
||||
// brightness using the ITU-R BT.709 luminance formula. It samples every Nth
|
||||
// pixel for performance. Returns "light" if the image is dark (bright text
|
||||
// needed) or "dark" if the image is bright (dark text needed).
|
||||
func analyzeImageBrightness(data []byte) string {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
// Cannot decode → assume dark image, use light text
|
||||
return "light"
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Max.X - bounds.Min.X
|
||||
height := bounds.Max.Y - bounds.Min.Y
|
||||
totalPixels := width * height
|
||||
|
||||
// Sample step: aim for ~10 000 pixels max for performance
|
||||
step := 1
|
||||
if totalPixels > 10000 {
|
||||
step = int(math.Sqrt(float64(totalPixels) / 10000))
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
}
|
||||
|
||||
var sum float64
|
||||
var count int
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y += step {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x += step {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
// ITU-R BT.709 perceived luminance (values are 0–65535)
|
||||
lum := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
|
||||
sum += lum
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return "light"
|
||||
}
|
||||
|
||||
avg := sum / float64(count)
|
||||
// 65535 / 2 = 32767.5 → threshold at ~40% brightness
|
||||
if avg < 26214 {
|
||||
return "light" // dark image → use light/white text
|
||||
}
|
||||
return "dark" // bright image → use dark text
|
||||
}
|
||||
|
||||
func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := h.getUserID(r)
|
||||
user, _ := h.auth.GetUserByID(userID)
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
/* Dimmed subtitle */
|
||||
.login-subtitle { opacity: 0.55; }
|
||||
/* Consistent spacing between Tabler icons and adjacent text */
|
||||
i.ti { margin-right: 0.25em; }
|
||||
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
|
||||
@@ -65,7 +67,7 @@
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4">
|
||||
<h1><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
|
||||
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
/* Dimmed subtitle & footer */
|
||||
.login-subtitle { opacity: 0.55; }
|
||||
.login-footer { opacity: 0.55; }
|
||||
/* Consistent spacing between Tabler icons and adjacent text */
|
||||
i.ti { margin-right: 0.25em; }
|
||||
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
|
||||
@@ -65,7 +68,7 @@
|
||||
<div class="container container-tight py-4">
|
||||
<div class="text-center mb-4">
|
||||
<h1><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
|
||||
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
@@ -133,7 +136,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-secondary mt-3">
|
||||
<div class="text-center text-secondary login-footer mt-3">
|
||||
© 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
|
||||
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
|
||||
/* Dimmed subtitle & footer on login page */
|
||||
.login-subtitle { opacity: 0.55; }
|
||||
.login-footer { opacity: 0.55; }
|
||||
/* Consistent spacing between Tabler icons and adjacent text */
|
||||
i.ti { margin-right: 0.25em; }
|
||||
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
|
||||
@@ -38,9 +41,19 @@
|
||||
background-repeat: no-repeat !important;
|
||||
background-attachment: fixed !important;
|
||||
}
|
||||
.login-heading { text-shadow: 0 2px 8px rgba(0,0,0,0.5); color: #fff !important; }
|
||||
.login-subtitle { text-shadow: 0 1px 4px rgba(0,0,0,0.4); color: rgba(255,255,255,0.85) !important; }
|
||||
.login-footer { text-shadow: 0 1px 4px rgba(0,0,0,0.4); color: rgba(255,255,255,0.7) !important; }
|
||||
{{if eq (loginTextColor) "dark"}}
|
||||
/* Dark text for light background images */
|
||||
.login-heading { text-shadow: 0 1px 6px rgba(255,255,255,0.7); color: #0a0f1a !important; }
|
||||
.login-subtitle.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.85) !important; opacity: 0.75; }
|
||||
.login-footer.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.80) !important; opacity: 0.70; }
|
||||
.login-footer.text-secondary a.text-secondary { color: rgba(10,15,26,0.80) !important; }
|
||||
{{else}}
|
||||
/* Light text for dark background images (default) */
|
||||
.login-heading { text-shadow: 0 2px 10px rgba(0,0,0,0.7); color: #fff !important; }
|
||||
.login-subtitle.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.95) !important; opacity: 0.80; }
|
||||
.login-footer.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.90) !important; opacity: 0.75; }
|
||||
.login-footer.text-secondary a.text-secondary { color: rgba(255,255,255,0.90) !important; }
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if eq (loginCardStyle) "glass"}}
|
||||
/* Glass card effect */
|
||||
|
||||
Reference in New Issue
Block a user