feat: add login page customization (background image, glass card style, subtitle)
This commit is contained in:
@@ -231,6 +231,12 @@ See [Roles & Permissions](roles.md) for details on which settings are owner-only
|
||||
|
||||
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`).
|
||||
- **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.
|
||||
|
||||
### Application Settings
|
||||
|
||||
- **App Name** — Custom application name displayed in the UI
|
||||
|
||||
@@ -31,6 +31,8 @@ Keywarden provides a built-in encrypted backup and restore feature for the entir
|
||||
| Audit Log | ✅ |
|
||||
| Application Settings | ✅ |
|
||||
|
||||
> **Note:** Uploaded branding assets (e.g., custom login background images in `data/branding/`) are stored as files and are **not** included in the `.kwbak` database backup. Use a Docker volume backup to include these files.
|
||||
|
||||
> **Note:** SSH private keys are stored with double encryption in backups — first with the application's `KEYWARDEN_ENCRYPTION_KEY`, then with the backup password. Both keys are needed to access the private keys.
|
||||
|
||||
## Importing a Backup
|
||||
|
||||
@@ -132,6 +132,7 @@ All persistent data is stored in the `/data` volume:
|
||||
| `/data/keys/` | Reserved for future use |
|
||||
| `/data/master/` | Reserved for future use |
|
||||
| `/data/avatars/` | User profile pictures |
|
||||
| `/data/branding/` | Login page branding assets (background images) |
|
||||
|
||||
> **Important:** The SQLite database contains encrypted private keys. Back up the `/data` volume regularly. See [Backup & Restore](backup-restore.md).
|
||||
|
||||
|
||||
@@ -115,3 +115,5 @@ In addition to environment variables, the following settings are configured thro
|
||||
| `lockout_attempts` | `5` | Failed login attempts before lockout (0 = disabled) |
|
||||
| `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_subtitle` | _(empty)_ | Custom subtitle on the login page |
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ If email is configured, you can enable **Login Notifications**. Every time someo
|
||||
|
||||
### Profile Picture
|
||||
|
||||
Upload a profile picture (avatar) that is displayed next to your name in the navigation. Supported formats: JPEG, PNG, GIF, WebP. Maximum size is limited by the server's request size limit.
|
||||
Upload a profile picture (avatar) that is displayed next to your name in the navigation. Supported formats: JPEG, PNG, GIF, WebP. Maximum file size: 5 MB.
|
||||
|
||||
## Audit Log
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const (
|
||||
ActionMasterKeyRegenerated = "masterkey_regenerated"
|
||||
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
|
||||
ActionAvatarChanged = "avatar_changed"
|
||||
ActionBrandingChanged = "branding_changed"
|
||||
|
||||
// Email
|
||||
ActionEmailNotifyChanged = "email_notify_changed"
|
||||
|
||||
@@ -266,6 +266,12 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
|
||||
logging.Warn("Failed to create avatars directory %s: %v", avatarsDir, err)
|
||||
}
|
||||
|
||||
// Ensure branding directory exists
|
||||
brandingDir := filepath.Join(dataDir, "branding")
|
||||
if err := os.MkdirAll(brandingDir, 0700); err != nil {
|
||||
logging.Warn("Failed to create branding directory %s: %v", brandingDir, err)
|
||||
}
|
||||
|
||||
h := &Handler{
|
||||
auth: authSvc,
|
||||
keys: keysSvc,
|
||||
@@ -317,6 +323,24 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
||||
"releaseURL": func() string {
|
||||
return h.updater.ReleaseURL()
|
||||
},
|
||||
"loginBgImage": func() string {
|
||||
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
|
||||
if _, err := os.Stat(bgPath); err == nil {
|
||||
return "/branding/login-bg"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"loginCardStyle": func() string {
|
||||
style, _ := h.auth.GetSetting("login_card_style")
|
||||
if style == "" {
|
||||
return "default"
|
||||
}
|
||||
return style
|
||||
},
|
||||
"loginSubtitle": func() string {
|
||||
subtitle, _ := h.auth.GetSetting("login_subtitle")
|
||||
return subtitle
|
||||
},
|
||||
}
|
||||
|
||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||
@@ -399,6 +423,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/static/", h.handleStatic)
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("/branding/login-bg", h.handleLoginBgServe)
|
||||
mux.HandleFunc("/login", h.handleLogin)
|
||||
mux.HandleFunc("/login/mfa", h.handleLoginMFA)
|
||||
mux.HandleFunc("/logout", h.handleLogout)
|
||||
@@ -454,6 +479,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
|
||||
// Owner-only routes
|
||||
mux.HandleFunc("/admin/settings", h.requireOwner(h.handleAdminSettings))
|
||||
mux.HandleFunc("/admin/branding/upload", h.requireOwner(h.handleLoginBrandingUpload))
|
||||
mux.HandleFunc("/admin/branding/remove-bg", h.requireOwner(h.handleLoginBrandingRemoveBg))
|
||||
mux.HandleFunc("/admin/masterkey/regenerate", h.requireOwner(h.handleMasterKeyRegenerate))
|
||||
mux.HandleFunc("/admin/backup/export", h.requireOwner(h.handleBackupExport))
|
||||
mux.HandleFunc("/admin/backup/import", h.requireOwner(h.handleBackupImport))
|
||||
@@ -2543,9 +2570,9 @@ func (h *Handler) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Limit upload to 2MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
// Limit upload to 5MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -3090,6 +3117,88 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
h.templates["system_info"].ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
// handleLoginBrandingUpload handles background image upload for the login page
|
||||
func (h *Handler) handleLoginBrandingUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userID := h.getUserID(r)
|
||||
|
||||
// Limit upload to 5MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("File too large. Maximum size is 5 MB."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("login_bg")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("No file selected."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate content type
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if ct != "image/png" && ct != "image/jpeg" && ct != "image/webp" {
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Invalid file type. Only PNG, JPEG and WebP are allowed."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to read uploaded file."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
|
||||
if err := os.WriteFile(bgPath, data, 0600); err != nil {
|
||||
logging.Warn("Failed to save login background image: %v", err)
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to save background image."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image uploaded", clientIP(r))
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image uploaded successfully."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLoginBrandingRemoveBg removes the login page background image
|
||||
func (h *Handler) handleLoginBrandingRemoveBg(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userID := h.getUserID(r)
|
||||
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
|
||||
os.Remove(bgPath)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// handleLoginBgServe serves the login page background image (public, no auth required)
|
||||
func (h *Handler) handleLoginBgServe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
|
||||
data, err := os.ReadFile(bgPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(data)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := h.getUserID(r)
|
||||
user, _ := h.auth.GetUserByID(userID)
|
||||
@@ -3132,6 +3241,26 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
logging.Info("Admin settings POST: form_type=%s from user_id=%d", formType, userID)
|
||||
|
||||
switch formType {
|
||||
case "branding_settings":
|
||||
batch := make(map[string]string)
|
||||
cardStyle := r.FormValue("login_card_style")
|
||||
if cardStyle == "glass" || cardStyle == "default" {
|
||||
batch["login_card_style"] = cardStyle
|
||||
changed = append(changed, "login_card_style="+cardStyle)
|
||||
}
|
||||
subtitle := r.FormValue("login_subtitle")
|
||||
batch["login_subtitle"] = subtitle
|
||||
if subtitle != "" {
|
||||
changed = append(changed, "login_subtitle="+subtitle)
|
||||
}
|
||||
if err := h.auth.SetSettingsBatch(batch); err != nil {
|
||||
logging.Error("Failed to save branding settings: %v", err)
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to save branding settings: "+err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
h.audit.Log(userID, audit.ActionBrandingChanged, fmt.Sprintf("Branding settings updated: %s", strings.Join(changed, ", ")), clientIP(r))
|
||||
}
|
||||
case "security_settings":
|
||||
// Collect all settings to save
|
||||
batch := make(map[string]string)
|
||||
|
||||
@@ -1,6 +1,80 @@
|
||||
{{define "content"}}
|
||||
<div class="row row-deck row-cards">
|
||||
|
||||
<!-- Login Page Customization -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-palette"></i> Login Page Customization</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Background Image -->
|
||||
<h4 class="mb-3"><i class="ti ti-photo"></i> Background Image</h4>
|
||||
{{if loginBgImage}}
|
||||
<div class="mb-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<img src="{{loginBgImage}}" class="rounded border" style="max-width: 240px; max-height: 140px; object-fit: cover;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<form action="/admin/branding/remove-bg" method="post" onsubmit="return confirm('Remove the login background image?');">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="ti ti-trash"></i> Remove Image
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<form action="/admin/branding/upload" method="post" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Upload Background Image</label>
|
||||
<input type="file" name="login_bg" class="form-control" accept="image/png,image/jpeg,image/webp">
|
||||
<small class="form-hint">Max 5 MB. JPEG, PNG or WebP. The image is centered and fills the screen without distortion.</small>
|
||||
</div>
|
||||
<div class="col-auto mb-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-upload"></i> Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Login Theme Settings -->
|
||||
<h4 class="mb-3"><i class="ti ti-brush"></i> Login Theme</h4>
|
||||
<form action="/admin/settings" method="post">
|
||||
<input type="hidden" name="form_type" value="branding_settings">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Card Style</label>
|
||||
<select name="login_card_style" class="form-select">
|
||||
<option value="default" {{if eq (loginCardStyle) "default"}}selected{{end}}>Default (Solid)</option>
|
||||
<option value="glass" {{if eq (loginCardStyle) "glass"}}selected{{end}}>Glass (Transparent)</option>
|
||||
</select>
|
||||
<small class="form-hint">Visual style of the login form.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">Subtitle</label>
|
||||
<input type="text" name="login_subtitle" class="form-control"
|
||||
value="{{loginSubtitle}}" placeholder="Centralized SSH Key Management and Deployment">
|
||||
<small class="form-hint">Text shown below the application name on the login page. Leave empty for default.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Login Theme
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Settings -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
|
||||
@@ -29,6 +29,37 @@
|
||||
/* 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; }
|
||||
{{if loginBgImage}}
|
||||
/* Custom background image */
|
||||
body {
|
||||
background-image: url('{{loginBgImage}}') !important;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
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; }
|
||||
{{end}}
|
||||
{{if eq (loginCardStyle) "glass"}}
|
||||
/* Glass card effect */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
[data-bs-theme="dark"] .card {
|
||||
background: rgba(26, 34, 52, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.card .form-control { background: rgba(255,255,255,0.55); }
|
||||
[data-bs-theme="dark"] .card .form-control { background: rgba(0,0,0,0.3); }
|
||||
.card .form-label, .card .h2, .card h2 { text-shadow: none; }
|
||||
{{end}}
|
||||
</style>
|
||||
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
|
||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||
@@ -38,8 +69,8 @@
|
||||
<div class="page page-center">
|
||||
<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>
|
||||
<h1 class="login-heading"><i class="ti ti-key"></i> {{appName}}</h1>
|
||||
<p class="text-secondary login-subtitle">{{if loginSubtitle}}{{loginSubtitle}}{{else}}Centralized SSH Key Management and Deployment{{end}}</p>
|
||||
</div>
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
@@ -106,7 +137,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-secondary mt-3">
|
||||
<div class="text-center text-secondary login-footer mt-3">
|
||||
© 2026 Keywarden | AGPLv3
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user