feat: add TZ timezone support for all displayed timestamps
This commit is contained in:
@@ -19,6 +19,11 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
|
|||||||
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
|
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
|
||||||
KEYWARDEN_LOG_LEVEL=INFO
|
KEYWARDEN_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- Timezone ---
|
||||||
|
# IANA timezone name (e.g. Europe/Berlin, America/New_York).
|
||||||
|
# Affects all displayed timestamps in the UI.
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# --- Paths (optional, Docker defaults are usually fine) ---
|
# --- Paths (optional, Docker defaults are usually fine) ---
|
||||||
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
|
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
|
||||||
# The Dockerfile already sets correct defaults (/data/...). Only override
|
# The Dockerfile already sets correct defaults (/data/...). Only override
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ ENV KEYWARDEN_DATA_DIR=/data
|
|||||||
ENV KEYWARDEN_KEYS_DIR=/data/keys
|
ENV KEYWARDEN_KEYS_DIR=/data/keys
|
||||||
ENV KEYWARDEN_MASTER_DIR=/data/master
|
ENV KEYWARDEN_MASTER_DIR=/data/master
|
||||||
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
|
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/auth"
|
"git.techniverse.net/scriptos/keywarden/internal/auth"
|
||||||
@@ -46,11 +47,15 @@ func main() {
|
|||||||
// Load config first (needed for log level)
|
// Load config first (needed for log level)
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Set application-wide timezone from TZ environment variable
|
||||||
|
time.Local = cfg.Timezone
|
||||||
|
|
||||||
// Initialize structured logging
|
// Initialize structured logging
|
||||||
logging.Init(cfg.LogLevel)
|
logging.Init(cfg.LogLevel)
|
||||||
|
|
||||||
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.Version)
|
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.Version)
|
||||||
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
||||||
|
logging.Info("Timezone: %s", cfg.Timezone)
|
||||||
|
|
||||||
// Validate data paths – relative paths inside a container bypass the
|
// Validate data paths – relative paths inside a container bypass the
|
||||||
// persistent volume mount and lead to silent data loss on restart.
|
// persistent volume mount and lead to silent data loss on restart.
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
|
|||||||
KEYWARDEN_PORT=8080
|
KEYWARDEN_PORT=8080
|
||||||
KEYWARDEN_LOG_LEVEL=INFO
|
KEYWARDEN_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Timezone (IANA, e.g. Europe/Berlin, America/New_York)
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
# Initial owner (only used on first startup)
|
# Initial owner (only used on first startup)
|
||||||
KEYWARDEN_OWNER_USER=admin
|
KEYWARDEN_OWNER_USER=admin
|
||||||
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Complete reference of all configuration options for Keywarden. All settings are
|
|||||||
| `KEYWARDEN_KEYS_DIR` | `./data/keys` | Directory for key storage (reserved) |
|
| `KEYWARDEN_KEYS_DIR` | `./data/keys` | Directory for key storage (reserved) |
|
||||||
| `KEYWARDEN_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
|
| `KEYWARDEN_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
|
||||||
| `KEYWARDEN_LOG_LEVEL` | `INFO` | Log level: `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE` |
|
| `KEYWARDEN_LOG_LEVEL` | `INFO` | Log level: `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE` |
|
||||||
|
| `TZ` | `UTC` | Timezone for all displayed timestamps (e.g., `Europe/Berlin`, `America/New_York`). Uses standard IANA timezone names. |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ When running in the Docker container, these defaults are set in the Dockerfile:
|
|||||||
| `KEYWARDEN_DATA_DIR` | `/data` |
|
| `KEYWARDEN_DATA_DIR` | `/data` |
|
||||||
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
|
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
|
||||||
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
|
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
|
||||||
|
| `TZ` | `UTC` |
|
||||||
|
|
||||||
## Example .env File
|
## Example .env File
|
||||||
|
|
||||||
@@ -76,6 +78,9 @@ KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
|
|||||||
KEYWARDEN_PORT=8080
|
KEYWARDEN_PORT=8080
|
||||||
KEYWARDEN_LOG_LEVEL=INFO
|
KEYWARDEN_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Timezone (IANA timezone name, e.g. Europe/Berlin)
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# Initial owner (only used on first startup)
|
# Initial owner (only used on first startup)
|
||||||
KEYWARDEN_OWNER_USER=admin
|
KEYWARDEN_OWNER_USER=admin
|
||||||
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
KEYWARDEN_OWNER_EMAIL=admin@example.com
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Configure timezone if TZ is set (requires tzdata package)
|
||||||
|
if [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||||
|
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
fi
|
||||||
|
|
||||||
# Create data directories (bind-mount from host may be owned by root)
|
# Create data directories (bind-mount from host may be owned by root)
|
||||||
mkdir -p /data/keys /data/master /data/avatars
|
mkdir -p /data/keys /data/master /data/avatars
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all application configuration
|
// Config holds all application configuration
|
||||||
@@ -30,6 +31,9 @@ type Config struct {
|
|||||||
SMTPTLS bool
|
SMTPTLS bool
|
||||||
SMTPEnabled bool
|
SMTPEnabled bool
|
||||||
|
|
||||||
|
// Timezone
|
||||||
|
Timezone *time.Location // parsed from TZ env var; defaults to UTC
|
||||||
|
|
||||||
// Security / Hardening
|
// Security / Hardening
|
||||||
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
|
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
|
||||||
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
|
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
|
||||||
@@ -55,6 +59,13 @@ func Load() *Config {
|
|||||||
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
|
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
|
||||||
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
|
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
|
||||||
|
|
||||||
|
// Load timezone from TZ environment variable (standard for Docker)
|
||||||
|
tzName := getEnv("TZ", "UTC")
|
||||||
|
tz, err := time.LoadLocation(tzName)
|
||||||
|
if err != nil {
|
||||||
|
tz = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: getEnv("KEYWARDEN_PORT", "8080"),
|
Port: getEnv("KEYWARDEN_PORT", "8080"),
|
||||||
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
|
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
|
||||||
@@ -73,6 +84,8 @@ func Load() *Config {
|
|||||||
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
|
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
|
||||||
SMTPEnabled: smtpHost != "",
|
SMTPEnabled: smtpHost != "",
|
||||||
|
|
||||||
|
Timezone: tz,
|
||||||
|
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
|
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
|
||||||
SecureCookies: secureCookies,
|
SecureCookies: secureCookies,
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ type SystemInfo struct {
|
|||||||
Runtime string // e.g. "Docker" or "Native"
|
Runtime string // e.g. "Docker" or "Native"
|
||||||
Hostname string
|
Hostname string
|
||||||
Uptime string
|
Uptime string
|
||||||
|
Timezone string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUserInfo holds user info for the admin settings page
|
// AdminUserInfo holds user info for the admin settings page
|
||||||
@@ -344,6 +345,42 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
|||||||
subtitle, _ := h.auth.GetSetting("login_subtitle")
|
subtitle, _ := h.auth.GetSetting("login_subtitle")
|
||||||
return subtitle
|
return subtitle
|
||||||
},
|
},
|
||||||
|
// formatTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04"
|
||||||
|
"formatTime": func(v interface{}) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return t.Local().Format("2006-01-02 15:04")
|
||||||
|
case *time.Time:
|
||||||
|
if t != nil {
|
||||||
|
return t.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
// formatDateTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04:05"
|
||||||
|
"formatDateTime": func(v interface{}) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return t.Local().Format("2006-01-02 15:04:05")
|
||||||
|
case *time.Time:
|
||||||
|
if t != nil {
|
||||||
|
return t.Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
// formatDateTimeLocal converts a time.Time to the app timezone and formats for HTML datetime-local inputs
|
||||||
|
"formatDateTimeLocal": func(v interface{}) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return t.Local().Format("2006-01-02T15:04")
|
||||||
|
case *time.Time:
|
||||||
|
if t != nil {
|
||||||
|
return t.Local().Format("2006-01-02T15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||||
@@ -3109,6 +3146,7 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
Runtime: runtimeEnv,
|
Runtime: runtimeEnv,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Uptime: uptimeStr,
|
Uptime: uptimeStr,
|
||||||
|
Timezone: time.Local.String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
data := &PageData{
|
data := &PageData{
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
|
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
|
||||||
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<div class="btn-list flex-nowrap">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
{{range .AuditEntries}}
|
{{range .AuditEntries}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-secondary">
|
<td class="text-secondary">
|
||||||
<i class="ti ti-clock"></i> {{.CreatedAt.Format "2006-01-02 15:04:05"}}
|
<i class="ti ti-clock"></i> {{formatDateTime .CreatedAt}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
|
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
|
||||||
|
|||||||
@@ -109,12 +109,12 @@
|
|||||||
{{if eq .Status "done"}}
|
{{if eq .Status "done"}}
|
||||||
<span class="text-secondary">—</span>
|
<span class="text-secondary">—</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
{{formatTime .NextRun}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .LastRun}}
|
{{if .LastRun}}
|
||||||
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
|
{{formatTime .LastRun}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="text-secondary">never</span>
|
<span class="text-secondary">never</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
|
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
|
||||||
<label class="form-label required">Date & Time</label>
|
<label class="form-label required">Date & Time</label>
|
||||||
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
|
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
|
||||||
value="{{$job.ScheduledAt.Format "2006-01-02T15:04"}}">
|
value="{{formatDateTimeLocal $job.ScheduledAt}}">
|
||||||
<small class="form-hint">Select the exact date and time for this one-time job.</small>
|
<small class="form-hint">Select the exact date and time for this one-time job.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .RecentAudit}}
|
{{range .RecentAudit}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td class="text-nowrap">{{formatTime .CreatedAt}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
|
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
|
||||||
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
|
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{index . "deployed_at"}}</td>
|
<td>{{formatDateTime (index . "deployed_at")}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{.Bits}}</td>
|
<td>{{.Bits}}</td>
|
||||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<div class="btn-list flex-nowrap">
|
||||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{.Bits}}</td>
|
<td>{{.Bits}}</td>
|
||||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<div class="btn-list flex-nowrap">
|
||||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||||
|
|||||||
@@ -66,6 +66,10 @@
|
|||||||
<div class="datagrid-title">Uptime</div>
|
<div class="datagrid-title">Uptime</div>
|
||||||
<div class="datagrid-content">{{.Uptime}}</div>
|
<div class="datagrid-content">{{.Uptime}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="datagrid-item">
|
||||||
|
<div class="datagrid-title">Timezone</div>
|
||||||
|
<div class="datagrid-content">{{.Timezone}}</div>
|
||||||
|
</div>
|
||||||
<div class="datagrid-item">
|
<div class="datagrid-item">
|
||||||
<div class="datagrid-title">Encryption</div>
|
<div class="datagrid-title">Encryption</div>
|
||||||
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
|
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
|
||||||
|
|||||||
@@ -60,12 +60,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-secondary">
|
<td class="text-secondary">
|
||||||
{{if .LastLoginAt}}
|
{{if .LastLoginAt}}
|
||||||
{{.LastLoginAt.Format "2006-01-02 15:04"}}
|
{{formatTime .LastLoginAt}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="text-muted">Never</span>
|
<span class="text-muted">Never</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<div class="btn-list flex-nowrap">
|
||||||
{{if .LockedUntil}}
|
{{if .LockedUntil}}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
|
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<strong>Account locked</strong> until {{.EditUser.LockedUntil.Format "2006-01-02 15:04"}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
|
<strong>Account locked</strong> until {{formatTime .EditUser.LockedUntil}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user