feat: add TZ timezone support for all displayed timestamps

This commit is contained in:
2026-04-09 13:37:51 +02:00
parent c15bac108d
commit da6d66e048
18 changed files with 92 additions and 12 deletions
+5
View File
@@ -19,6 +19,11 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
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) ---
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
# The Dockerfile already sets correct defaults (/data/...). Only override
+1
View File
@@ -40,6 +40,7 @@ ENV KEYWARDEN_DATA_DIR=/data
ENV KEYWARDEN_KEYS_DIR=/data/keys
ENV KEYWARDEN_MASTER_DIR=/data/master
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
ENV TZ=UTC
EXPOSE 8080
+5
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
@@ -46,11 +47,15 @@ func main() {
// Load config first (needed for log level)
cfg := config.Load()
// Set application-wide timezone from TZ environment variable
time.Local = cfg.Timezone
// Initialize structured logging
logging.Init(cfg.LogLevel)
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.Version)
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
logging.Info("Timezone: %s", cfg.Timezone)
// Validate data paths relative paths inside a container bypass the
// persistent volume mount and lead to silent data loss on restart.
+3
View File
@@ -98,6 +98,9 @@ KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Timezone (IANA, e.g. Europe/Berlin, America/New_York)
TZ=UTC
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
+5
View File
@@ -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_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
| `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
@@ -60,6 +61,7 @@ When running in the Docker container, these defaults are set in the Dockerfile:
| `KEYWARDEN_DATA_DIR` | `/data` |
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
| `TZ` | `UTC` |
## Example .env File
@@ -76,6 +78,9 @@ KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Timezone (IANA timezone name, e.g. Europe/Berlin)
TZ=Europe/Berlin
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
+6
View File
@@ -5,6 +5,12 @@
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)
mkdir -p /data/keys /data/master /data/avatars
+13
View File
@@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration
@@ -30,6 +31,9 @@ type Config struct {
SMTPTLS bool
SMTPEnabled bool
// Timezone
Timezone *time.Location // parsed from TZ env var; defaults to UTC
// Security / Hardening
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"
@@ -55,6 +59,13 @@ func Load() *Config {
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
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{
Port: getEnv("KEYWARDEN_PORT", "8080"),
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
@@ -73,6 +84,8 @@ func Load() *Config {
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
SMTPEnabled: smtpHost != "",
Timezone: tz,
BaseURL: baseURL,
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
SecureCookies: secureCookies,
+38
View File
@@ -194,6 +194,7 @@ type SystemInfo struct {
Runtime string // e.g. "Docker" or "Native"
Hostname string
Uptime string
Timezone string
}
// 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")
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")
@@ -3109,6 +3146,7 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
Runtime: runtimeEnv,
Hostname: hostname,
Uptime: uptimeStr,
Timezone: time.Local.String(),
}
data := &PageData{
+1 -1
View File
@@ -123,7 +123,7 @@
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
{{end}}
</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")}}
<td>
<div class="btn-list flex-nowrap">
+1 -1
View File
@@ -34,7 +34,7 @@
{{range .AuditEntries}}
<tr>
<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>
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
+2 -2
View File
@@ -109,12 +109,12 @@
{{if eq .Status "done"}}
<span class="text-secondary"></span>
{{else}}
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{formatTime .NextRun}}
{{end}}
</td>
<td>
{{if .LastRun}}
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{formatTime .LastRun}}
{{else}}
<span class="text-secondary">never</span>
{{end}}
+1 -1
View File
@@ -202,7 +202,7 @@
<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>
<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>
</div>
+1 -1
View File
@@ -202,7 +202,7 @@
<tbody>
{{range .RecentAudit}}
<tr>
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="text-nowrap">{{formatTime .CreatedAt}}</td>
<td>{{.Username}}</td>
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
+1 -1
View File
@@ -223,7 +223,7 @@
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
{{end}}
</td>
<td>{{index . "deployed_at"}}</td>
<td>{{formatDateTime (index . "deployed_at")}}</td>
</tr>
{{else}}
<tr>
+2 -2
View File
@@ -47,7 +47,7 @@
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{formatTime .CreatedAt}}</td>
<td>
<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}}')">
@@ -89,7 +89,7 @@
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{formatTime .CreatedAt}}</td>
<td>
<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}}')">
+4
View File
@@ -66,6 +66,10 @@
<div class="datagrid-title">Uptime</div>
<div class="datagrid-content">{{.Uptime}}</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-title">Encryption</div>
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
+2 -2
View File
@@ -60,12 +60,12 @@
</td>
<td class="text-secondary">
{{if .LastLoginAt}}
{{.LastLoginAt.Format "2006-01-02 15:04"}}
{{formatTime .LastLoginAt}}
{{else}}
<span class="text-muted">Never</span>
{{end}}
</td>
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
<td>
<div class="btn-list flex-nowrap">
{{if .LockedUntil}}
+1 -1
View File
@@ -79,7 +79,7 @@
<div class="d-flex align-items-center">
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
<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>