feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
@@ -213,7 +212,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
+243
-41
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||
@@ -36,9 +37,12 @@ type Server struct {
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
}
|
||||
|
||||
type viewData struct {
|
||||
@@ -74,6 +78,7 @@ type viewData struct {
|
||||
ErrorPiHoles []model.PiHoleIntegration
|
||||
ErrorCount int
|
||||
PiHoleView *piHoleView
|
||||
SoftwareUpdate appupdate.State
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
@@ -91,12 +96,12 @@ type accessZone struct {
|
||||
ManagedTargetIDs map[int64]bool
|
||||
}
|
||||
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||||
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityPage))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
|
||||
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /access", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
|
||||
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
|
||||
mux.Handle("POST /users", s.adminOnly(s.userCreate))
|
||||
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
|
||||
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
|
||||
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
|
||||
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
|
||||
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
|
||||
return s.securityHeaders(mux)
|
||||
}
|
||||
|
||||
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
|
||||
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username, usernameErr := normalizeUsername(r.FormValue("username"))
|
||||
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
|
||||
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
lookupOK := usernameErr == nil && err == nil
|
||||
if !lookupOK {
|
||||
hash = s.dummyHash
|
||||
}
|
||||
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
|
||||
if !lookupOK || !user.Active || !passwordOK {
|
||||
s.limiter.Fail(clientIP)
|
||||
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
||||
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
|
||||
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientIP)
|
||||
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectNotice(w, r, "/", "Angemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
|
||||
if s.authSlots == nil {
|
||||
return secure.VerifyPassword(hash, password)
|
||||
}
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case s.authSlots <- struct{}{}:
|
||||
defer func() { <-s.authSlots }()
|
||||
return secure.VerifyPassword(hash, password)
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
|
||||
return
|
||||
}
|
||||
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
|
||||
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
|
||||
s.updates.TriggerCheck()
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
err := s.updates.CheckNow(checkCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
||||
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err := s.updates.RequestUpdate(startCtx, version)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
status, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
state := s.updates.State()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"agent_available": statusErr == nil,
|
||||
"phase": status.Phase,
|
||||
"message": status.Message,
|
||||
"target_version": status.TargetVersion,
|
||||
"current_version": state.CurrentVersion,
|
||||
"latest_version": state.LatestVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||
if err != nil {
|
||||
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
|
||||
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
|
||||
}
|
||||
|
||||
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
||||
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
return
|
||||
}
|
||||
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, "/security", err.Error(), "error")
|
||||
s.redirectNotice(w, r, "/account", err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
|
||||
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
|
||||
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
||||
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
|
||||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
||||
"running": s.scheduler.IsRunning(),
|
||||
"enabled": settings.UpdaterEnabled,
|
||||
"last_ipv4": settings.LastIPv4,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
|
||||
return
|
||||
}
|
||||
hash, err := secure.HashPassword(r.FormValue("password"))
|
||||
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderUserNewError(w, session, err.Error(), form)
|
||||
return
|
||||
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
|
||||
}
|
||||
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
|
||||
}
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
|
||||
return
|
||||
}
|
||||
if managedUser.IsAdmin() && (role != "admin" || !active) {
|
||||
count, countErr := s.store.CountActiveAdmins(r.Context())
|
||||
if countErr != nil {
|
||||
s.serverError(w, countErr)
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
if errors.Is(err, store.ErrLastActiveAdmin) {
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
if role != managedUser.Role || active != managedUser.Active {
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
|
||||
}
|
||||
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
||||
}
|
||||
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
|
||||
if settings, err := s.store.GetSettings(context.Background()); err == nil {
|
||||
data.UpdaterEnabled = settings.UpdaterEnabled
|
||||
}
|
||||
if data.IsAdmin && s.updates != nil {
|
||||
data.SoftwareUpdate = s.updates.State()
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
|
||||
}
|
||||
|
||||
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) serverError(w http.ResponseWriter, err error) {
|
||||
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func loadTemplates() (map[string]*template.Template, error) {
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
|
||||
functions := template.FuncMap{
|
||||
"formatTime": func(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
@@ -2043,15 +2202,30 @@ func remoteIP(r *http.Request) string {
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
loginAttemptLimit = 5
|
||||
loginAttemptWindow = 15 * time.Minute
|
||||
loginLimiterCleanupEvery = time.Minute
|
||||
loginLimiterMaxTrackedIPs = 4096
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
|
||||
func (l *loginLimiter) Allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := time.Now().Add(-15 * time.Minute)
|
||||
now := time.Now()
|
||||
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
|
||||
l.cleanupLocked(now)
|
||||
}
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
values := l.attempts[ip]
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
l.attempts[ip] = kept
|
||||
return len(kept) < 5
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return false
|
||||
}
|
||||
return len(kept) < loginAttemptLimit
|
||||
}
|
||||
func (l *loginLimiter) Fail(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return
|
||||
}
|
||||
l.attempts[ip] = append(l.attempts[ip], time.Now())
|
||||
}
|
||||
func (l *loginLimiter) Reset(ip string) {
|
||||
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) cleanupLocked(now time.Time) {
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
for ip, values := range l.attempts {
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
if value.After(cutoff) {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
}
|
||||
l.lastCleanup = now
|
||||
}
|
||||
+265
-2
@@ -9,10 +9,12 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
||||
@@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||
if strings.Contains(body, `class="flash"`) {
|
||||
t.Error("legacy in-flow flash message is still rendered")
|
||||
}
|
||||
for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} {
|
||||
if strings.Contains(body, removed) {
|
||||
t.Errorf("login still renders removed security marketing copy %q", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
limiter := newLoginLimiter()
|
||||
limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)}
|
||||
limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second)
|
||||
if !limiter.Allow("fresh") {
|
||||
t.Fatal("fresh client was rejected after expired entries should have been pruned")
|
||||
}
|
||||
if _, exists := limiter.attempts["expired"]; exists {
|
||||
t.Fatal("expired login limiter entry was not pruned")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for index := 0; index < loginLimiterMaxTrackedIPs; index++ {
|
||||
limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now}
|
||||
}
|
||||
limiter.lastCleanup = now
|
||||
if limiter.Allow("overflow") {
|
||||
t.Fatal("untracked client was accepted after the bounded limiter reached capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hash, err := secure.HashPassword("a sufficiently secure test password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const rawToken = "member-session-token"
|
||||
if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{config: config.Config{}, store: database}
|
||||
handler := server.Handler()
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{method: http.MethodGet, path: "/access"},
|
||||
{method: http.MethodGet, path: "/users"},
|
||||
{method: http.MethodGet, path: "/users/new"},
|
||||
{method: http.MethodGet, path: "/system"},
|
||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||
}
|
||||
for _, test := range requests {
|
||||
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken})
|
||||
if test.body != "" {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false}
|
||||
if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
if !strings.Contains(body, `href="/account"`) {
|
||||
t.Error("member navigation does not expose the personal account")
|
||||
}
|
||||
for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("member UI exposes administrator control %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}}
|
||||
if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("immutable administrator access UI is missing %q", expected)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("administrator detail renders editable permission input %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}}
|
||||
if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
navStart, navEnd := strings.Index(body, "<nav "), strings.Index(body, "</nav>")
|
||||
if navStart < 0 || navEnd <= navStart {
|
||||
t.Fatal("administrator navigation was not rendered")
|
||||
}
|
||||
navigation := body[navStart:navEnd]
|
||||
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff & Sicherheit") {
|
||||
t.Fatal("combined access administration entry is missing")
|
||||
}
|
||||
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
|
||||
t.Fatal("removed credential policy card is still rendered")
|
||||
}
|
||||
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
|
||||
if strings.Contains(navigation, legacy) {
|
||||
t.Errorf("legacy separate navigation entry remains: %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
|
||||
var notification bytes.Buffer
|
||||
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||
if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
|
||||
if !strings.Contains(notification.String(), expected) {
|
||||
t.Errorf("global update notification is missing %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
var page bytes.Buffer
|
||||
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} {
|
||||
if !strings.Contains(page.String(), expected) {
|
||||
t.Errorf("software update page is missing %q", expected)
|
||||
}
|
||||
}
|
||||
var freshPage bytes.Buffer
|
||||
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
|
||||
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
|
||||
if !strings.Contains(freshPage.String(), expected) {
|
||||
t.Errorf("fresh software update page is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(freshPage.String(), "Update-Kanal") {
|
||||
t.Error("confusing legacy update channel box is still rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
|
||||
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("user creation form is missing %q", expected)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
|
||||
start := strings.Index(body, field)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
end := strings.Index(body[start:], ">")
|
||||
if end < 0 {
|
||||
t.Fatalf("password input %q has no closing bracket", field)
|
||||
}
|
||||
if strings.Contains(body[start:start+end], "minlength=") {
|
||||
t.Errorf("password input %q still has a minimum length", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "Ersteinrichtung",
|
||||
Page: "setup",
|
||||
Mode: "lan",
|
||||
CSRF: "csrf",
|
||||
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
|
||||
}
|
||||
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
|
||||
t.Fatalf("render setup: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("first-run onboarding is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Kein E-Mail-Reset") {
|
||||
t.Error("first-run onboarding still renders the removed password reset footnote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
||||
@@ -238,7 +501,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: true},
|
||||
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
||||
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
||||
}
|
||||
@@ -251,7 +514,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
||||
if timeAt < 0 || statusAt < timeAt {
|
||||
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
||||
}
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("domain detail is missing %q", expected)
|
||||
}
|
||||
|
||||
+143
-8
@@ -91,6 +91,32 @@
|
||||
syncIPv6Settings();
|
||||
}
|
||||
|
||||
const setupForm = document.querySelector('[data-setup-form]');
|
||||
if (setupForm) {
|
||||
const password = setupForm.querySelector('[data-setup-password]');
|
||||
const confirmation = setupForm.querySelector('[data-setup-confirm]');
|
||||
const lengthHint = setupForm.querySelector('[data-password-length]');
|
||||
const matchHint = setupForm.querySelector('[data-password-match]');
|
||||
|
||||
const syncPasswordGuidance = () => {
|
||||
const length = password ? [...password.value].length : 0;
|
||||
const longEnough = length >= 15 && length <= 128;
|
||||
const matches = Boolean(confirmation?.value) && confirmation.value === password?.value;
|
||||
lengthHint?.classList.toggle('is-valid', longEnough);
|
||||
matchHint?.classList.toggle('is-valid', matches);
|
||||
lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending');
|
||||
matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending');
|
||||
password?.classList.toggle('is-valid', longEnough);
|
||||
confirmation?.classList.toggle('is-valid', matches);
|
||||
setupForm.classList.toggle('is-password-ready', longEnough && matches);
|
||||
confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : '');
|
||||
};
|
||||
|
||||
password?.addEventListener('input', syncPasswordGuidance);
|
||||
confirmation?.addEventListener('input', syncPasswordGuidance);
|
||||
syncPasswordGuidance();
|
||||
}
|
||||
|
||||
for (const form of document.querySelectorAll('[data-target-form]')) {
|
||||
const active = form.querySelector('[data-target-active]');
|
||||
const staticFields = form.querySelector('[data-static-ip-fields]');
|
||||
@@ -226,12 +252,107 @@
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
if (countdowns.length === 0) return;
|
||||
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
|
||||
if (softwareUpdateForms.length > 0) {
|
||||
const overlay = document.querySelector('[data-software-update-overlay]');
|
||||
const message = overlay?.querySelector('[data-software-update-message]');
|
||||
const close = overlay?.querySelector('[data-software-update-close]');
|
||||
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
|
||||
const phaseOrder = ['pulling', 'restarting', 'verifying'];
|
||||
let polling = false;
|
||||
let targetVersion = '';
|
||||
let startedAt = 0;
|
||||
|
||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
||||
let running = countdowns[0].dataset.running === 'true';
|
||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
||||
const setUpdatePhase = (phase, text) => {
|
||||
if (!overlay) return;
|
||||
overlay.dataset.updateState = phase;
|
||||
if (message && text) message.textContent = text;
|
||||
const currentIndex = phaseOrder.indexOf(phase);
|
||||
for (const node of phaseNodes) {
|
||||
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
|
||||
node.classList.toggle('is-active', nodeIndex === currentIndex);
|
||||
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
|
||||
}
|
||||
};
|
||||
|
||||
const pollSoftwareUpdate = async () => {
|
||||
if (!polling) return;
|
||||
if (Date.now() - startedAt > 12 * 60 * 1000) {
|
||||
polling = false;
|
||||
setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.');
|
||||
if (close) close.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('status unavailable');
|
||||
const state = await response.json();
|
||||
const phase = state.phase || 'queued';
|
||||
const messages = {
|
||||
queued: 'Update ist eingereiht.',
|
||||
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
|
||||
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
|
||||
verifying: 'Die neue Instanz durchläuft den Healthcheck.',
|
||||
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
|
||||
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
|
||||
};
|
||||
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.');
|
||||
if (phase === 'complete') {
|
||||
polling = false;
|
||||
window.setTimeout(() => window.location.assign('/system'), 1400);
|
||||
return;
|
||||
}
|
||||
if (phase === 'failed') {
|
||||
polling = false;
|
||||
if (close) close.hidden = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
|
||||
}
|
||||
window.setTimeout(pollSoftwareUpdate, 1500);
|
||||
};
|
||||
|
||||
for (const form of softwareUpdateForms) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (polling || !overlay) return;
|
||||
const formData = new FormData(form);
|
||||
targetVersion = String(formData.get('version') || '');
|
||||
overlay.hidden = false;
|
||||
document.body.classList.add('software-update-open');
|
||||
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
|
||||
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
|
||||
try {
|
||||
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
|
||||
polling = true;
|
||||
startedAt = Date.now();
|
||||
window.setTimeout(pollSoftwareUpdate, 400);
|
||||
} catch (error) {
|
||||
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
|
||||
if (close) close.hidden = false;
|
||||
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close?.addEventListener('click', () => {
|
||||
overlay.hidden = true;
|
||||
document.body.classList.remove('software-update-open');
|
||||
close.hidden = true;
|
||||
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||
if (countdowns.length === 0 && currentIPv4Fields.length === 0) return;
|
||||
|
||||
let nextRun = Number(countdowns[0]?.dataset.countdown || 0);
|
||||
let running = countdowns[0]?.dataset.running === 'true';
|
||||
let enabled = countdowns[0]?.dataset.enabled !== 'false';
|
||||
let lastRefresh = 0;
|
||||
|
||||
const formatRemaining = (seconds) => {
|
||||
@@ -243,6 +364,7 @@
|
||||
};
|
||||
|
||||
const refreshSchedule = async () => {
|
||||
if (document.hidden) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < 2500) return;
|
||||
lastRefresh = now;
|
||||
@@ -253,12 +375,17 @@
|
||||
nextRun = Number(state.next_run_unix || 0);
|
||||
running = Boolean(state.running);
|
||||
enabled = state.enabled !== false;
|
||||
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) {
|
||||
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
|
||||
}
|
||||
} catch {
|
||||
// The visible countdown keeps its last known state when the status request fails.
|
||||
// Visible scheduler data keeps its last known state when the request fails.
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (document.hidden) return;
|
||||
if (countdowns.length === 0) return;
|
||||
if (!enabled) {
|
||||
for (const node of countdowns) {
|
||||
node.textContent = 'Inaktiv';
|
||||
@@ -276,8 +403,16 @@
|
||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||
};
|
||||
|
||||
tick();
|
||||
if (countdowns.length > 0) {
|
||||
tick();
|
||||
window.setInterval(tick, 1000);
|
||||
}
|
||||
refreshSchedule();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setInterval(refreshSchedule, 30000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
lastRefresh = 0;
|
||||
tick();
|
||||
refreshSchedule();
|
||||
});
|
||||
})();
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
||||
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
@@ -59,12 +60,15 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
||||
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
||||
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
||||
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.nav-update-dot { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.14), 0 0 12px rgba(96,165,250,.42); }
|
||||
.sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.sidebar-account-link { display: grid; min-width: 0; grid-template-columns: 32px minmax(0,1fr); gap: 9px; align-items: center; padding: 3px; border-radius: 7px; }
|
||||
.sidebar-account-link:hover { background: #1b2739; color: #fff; }
|
||||
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
||||
.sidebar-account small { color: #748298; font-size: 9px; }
|
||||
.sidebar-account form { display: contents; }
|
||||
.sidebar-account form { display: block; }
|
||||
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
||||
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
||||
|
||||
@@ -72,10 +76,21 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
||||
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
||||
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
|
||||
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
|
||||
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.software-update-banner div > strong { margin-top: 2px; font-size: 12px; }
|
||||
.software-update-banner-actions { display: flex; gap: 7px; align-items: center; }
|
||||
.software-update-banner-actions form { display: contents; }
|
||||
.software-update-banner .button-quiet { border-color: rgba(191,219,254,.2); background: rgba(255,255,255,.055); color: #dbeafe; }
|
||||
.software-update-banner .button-quiet:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
||||
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
||||
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
||||
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
|
||||
.auth-shell { display: grid; min-height: 100vh; min-height: 100svh; place-items: center; padding: clamp(18px, 4vw, 48px); background: radial-gradient(circle at 12% 18%, rgba(37,99,235,.1), transparent 28%), linear-gradient(145deg, #edf2f7 0%, #f8fafc 55%, #eef2f6 100%); }
|
||||
.page-setup { overflow: hidden; }
|
||||
.page-setup .auth-shell { width: 100%; height: 100vh; height: 100svh; min-height: 0; overflow: hidden; padding: 0; }
|
||||
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
||||
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
||||
@@ -315,6 +330,91 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
||||
.security-strip span { color: var(--success); }
|
||||
.login-meta { text-align: center; }
|
||||
.login-card { position: relative; overflow: hidden; width: min(460px, 100%); padding: 34px; border-radius: 18px; }
|
||||
.login-card::before { position: absolute; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, var(--accent), #2dd4bf); content: ""; }
|
||||
.login-card-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.login-mode { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 99px; background: var(--warning-soft); color: var(--warning); font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.login-mode.is-secure { border-color: #b4ddd4; background: #effaf7; color: var(--success); }
|
||||
.login-card h1 { margin-top: 36px; }
|
||||
.login-card .lede { margin-bottom: 24px; }
|
||||
.onboarding-shell { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: minmax(400px, .9fr) minmax(560px, 1.1fr); overflow: hidden; background: #f8fafc; }
|
||||
.onboarding-intro { position: relative; display: flex; min-width: 0; overflow: hidden; flex-direction: column; padding: clamp(34px, 4vw, 64px); background: radial-gradient(circle at 12% -5%, rgba(59,130,246,.27), transparent 34%), radial-gradient(circle at 105% 88%, rgba(45,212,191,.12), transparent 36%), linear-gradient(150deg, #0e1726 0%, #152238 100%); color: #fff; }
|
||||
.onboarding-intro::before { position: absolute; inset: 0; background-image: linear-gradient(rgba(148,163,184,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.035) 1px, transparent 1px); background-size: 38px 38px; content: ""; pointer-events: none; }
|
||||
.onboarding-intro::after { position: absolute; top: 0; right: 0; width: 1px; height: 100%; background: rgba(255,255,255,.12); content: ""; pointer-events: none; }
|
||||
.onboarding-brand-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.onboarding-edition { color: #718096; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.auth-brand-inverse { color: #f8fafc; }
|
||||
.auth-brand-inverse span { border: 1px solid #3a4b65; background: rgba(28,41,64,.88); color: #b9d0ff; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
|
||||
.onboarding-copy { position: relative; z-index: 1; margin-top: clamp(38px, 6vh, 68px); }
|
||||
.onboarding-eyebrow { display: inline-flex; margin-bottom: 15px; padding: 5px 8px; border: 1px solid rgba(147,164,186,.2); border-radius: 99px; background: rgba(15,23,42,.24); color: #9fb0c7; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.onboarding-copy h1 { max-width: 620px; margin-bottom: 16px; color: #f8fafc; font-size: clamp(40px, 4.3vw, 64px); line-height: 1; letter-spacing: -.06em; }
|
||||
.onboarding-copy p { max-width: 500px; margin: 0; color: #9aa8ba; font-size: clamp(11px, 1vw, 14px); line-height: 1.6; }
|
||||
.onboarding-progress { position: relative; z-index: 1; max-width: 520px; margin-top: clamp(28px, 4vh, 42px); }
|
||||
.onboarding-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #77869a; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.onboarding-progress strong { color: #aec8f6; font-size: 8px; }
|
||||
.onboarding-progress-track { display: block; height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: rgba(148,163,184,.14); }
|
||||
.onboarding-progress-track i { display: block; width: 33.333%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #3b82f6, #75a7ff); box-shadow: 0 0 16px rgba(59,130,246,.55); }
|
||||
.onboarding-steps { position: relative; z-index: 1; display: grid; max-width: 520px; gap: 7px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.onboarding-steps::before { position: absolute; top: 35px; bottom: 35px; left: 29px; z-index: -1; width: 1px; background: rgba(148,163,184,.14); content: ""; }
|
||||
.onboarding-steps li { position: relative; display: grid; min-height: 62px; grid-template-columns: 36px minmax(0,1fr); gap: 13px; align-items: center; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; color: #718096; }
|
||||
.onboarding-steps li > span { position: relative; z-index: 1; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(148,163,184,.16); border-radius: 10px; background: #152238; color: #718096; font-family: var(--mono); font-size: 9px; font-weight: 750; }
|
||||
.onboarding-steps strong, .onboarding-steps small { display: block; }
|
||||
.onboarding-steps strong { color: #bac5d4; font-size: 12px; }
|
||||
.onboarding-steps small { margin-top: 2px; color: #718096; font-size: 9px; line-height: 1.45; }
|
||||
.onboarding-step-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.onboarding-step-title em { padding: 3px 6px; border-radius: 99px; background: rgba(59,130,246,.18); color: #a8c7ff; font-family: var(--mono); font-size: 7px; font-style: normal; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.onboarding-steps .is-current { border-color: rgba(96,165,250,.26); background: linear-gradient(90deg, rgba(37,99,235,.18), rgba(37,99,235,.055)); box-shadow: inset 3px 0 #4f8ff7, 0 10px 28px rgba(2,8,23,.12); }
|
||||
.onboarding-steps .is-current > span { border-color: #4f8ff7; background: #2563eb; color: #fff; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
|
||||
.onboarding-steps .is-current strong { color: #fff; }
|
||||
.onboarding-card { display: grid; min-width: 0; place-items: center; padding: clamp(32px, 4vw, 64px) clamp(36px, 6vw, 96px) clamp(48px, 7vw, 96px); background: #f8fafc; }
|
||||
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||
.onboarding-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: clamp(20px, 3vh, 28px); color: #7b8797; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.setup-ready { display: inline-flex; align-items: center; gap: 8px; color: #4f6074; }
|
||||
.setup-ready i { width: 7px; height: 7px; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.1); }
|
||||
.onboarding-form-heading h2 { margin: 0 0 10px; font-size: clamp(30px, 3.2vw, 44px); line-height: 1.08; letter-spacing: -.05em; }
|
||||
.onboarding-lede { max-width: 590px; margin-bottom: clamp(18px, 2.6vh, 24px); color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.setup-form { gap: 14px; }
|
||||
.setup-field-row { gap: 16px; }
|
||||
.setup-form label { gap: 6px; color: #334155; font-size: 10px; }
|
||||
.setup-form input { min-height: 46px; border-radius: 8px; background: #fff; }
|
||||
.setup-form input.is-valid:not(:focus) { border-color: #91cfc3; box-shadow: 0 0 0 3px rgba(15,118,110,.055); }
|
||||
.password-guidance { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
|
||||
.password-guidance span { display: flex; min-width: 0; min-height: 38px; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid #dbe2e9; border-radius: 9px; background: #fff; color: #738093; font-size: 9px; font-weight: 700; line-height: 1.25; transition: border-color .24s ease, background .24s ease, color .24s ease, box-shadow .24s ease, transform .24s ease; }
|
||||
.password-guidance i { display: grid; width: 20px; height: 20px; flex: none; place-items: center; border-radius: 7px; background: #edf1f5; color: transparent; font-family: var(--mono); font-size: 10px; font-style: normal; font-weight: 900; transform: scale(.82); transition: background .24s ease, color .18s ease, transform .3s cubic-bezier(.2,1.5,.4,1); }
|
||||
.password-guidance span.is-valid { border-color: #a8dacf; background: linear-gradient(120deg, #f4fbf9, #edf9f6); color: #0d6f67; box-shadow: 0 5px 16px rgba(15,118,110,.08); transform: translateY(-1px); animation: guidance-pop .38s cubic-bezier(.2,.9,.3,1.35) both; }
|
||||
.password-guidance span.is-valid i { background: #0f8a7f; color: #fff; box-shadow: 0 4px 10px rgba(15,118,110,.2); transform: scale(1); animation: guidance-check .42s cubic-bezier(.2,1.55,.4,1) both; }
|
||||
.setup-submit { position: relative; width: 100%; min-height: 48px; overflow: hidden; margin-top: 2px; border-radius: 8px; transition: border-color .2s, background .2s, box-shadow .24s, transform .2s; }
|
||||
.setup-submit > span { position: relative; z-index: 1; transition: transform .22s ease; }
|
||||
.setup-submit:hover > span { transform: translateX(3px); }
|
||||
.setup-form.is-password-ready .setup-submit { box-shadow: 0 10px 24px rgba(37,99,235,.18); }
|
||||
.setup-form.is-password-ready .setup-submit::after { position: absolute; top: -80%; bottom: -80%; left: -35%; width: 24%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.48), transparent); content: ""; pointer-events: none; transform: rotate(16deg); animation: setup-button-sheen .75s ease-out both; }
|
||||
.onboarding-footer { margin-top: clamp(16px, 2.5vh, 22px); padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.onboarding-mode { display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 8px; align-items: center; color: #7a5a16; }
|
||||
.onboarding-mode > span { display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid #d7bd80; border-radius: 50%; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.onboarding-mode p { margin: 0; }
|
||||
.onboarding-mode strong, .onboarding-mode small { display: block; }
|
||||
.onboarding-mode strong { font-size: 9px; }
|
||||
.onboarding-mode small { margin-top: 1px; color: #887347; font-size: 8px; font-weight: 500; line-height: 1.4; }
|
||||
.onboarding-mode.is-secure { color: var(--success); }
|
||||
.onboarding-mode.is-secure > span { border-color: #8bc5ba; }
|
||||
.onboarding-mode.is-secure small { color: #59877f; }
|
||||
@keyframes setup-reveal { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes setup-panel-reveal { from { opacity: 0; transform: translateY(18px) scale(.988); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes setup-progress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@keyframes setup-ready-pulse { 0% { box-shadow: 0 0 0 0 rgba(34,160,107,.32); } 65% { box-shadow: 0 0 0 7px rgba(34,160,107,0); } 100% { box-shadow: 0 0 0 3px rgba(34,160,107,.1); } }
|
||||
@keyframes guidance-pop { 0% { transform: translateY(0) scale(.97); } 70% { transform: translateY(-2px) scale(1.015); } 100% { transform: translateY(-1px) scale(1); } }
|
||||
@keyframes guidance-check { 0% { opacity: 0; transform: scale(.35) rotate(-18deg); } 70% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { opacity: 1; transform: scale(1) rotate(0); } }
|
||||
@keyframes setup-button-sheen { from { left: -35%; } to { left: 118%; } }
|
||||
.onboarding-brand-row { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-copy { animation: setup-reveal .55s .06s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-progress { animation: setup-reveal .55s .12s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-progress-track i { transform-origin: left; animation: setup-progress-fill .75s .38s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-steps li { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-steps li:nth-child(1) { animation-delay: .18s; }
|
||||
.onboarding-steps li:nth-child(2) { animation-delay: .24s; }
|
||||
.onboarding-steps li:nth-child(3) { animation-delay: .3s; }
|
||||
.onboarding-form-wrap { animation: setup-panel-reveal .58s .08s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.setup-ready i { animation: setup-ready-pulse .8s .58s ease-out both; }
|
||||
.token-card { width: min(680px, 100%); }
|
||||
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
||||
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
||||
@@ -336,6 +436,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
||||
.row-arrow { color: var(--accent); }
|
||||
.access-panel { margin-top: 12px; }
|
||||
.access-policy-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.access-policy-card { display: grid; min-height: 76px; grid-template-columns: 36px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.access-policy-card.is-admin { border-color: #b9cdef; background: linear-gradient(120deg,#fff 62%,#f0f5ff); }
|
||||
.access-policy-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; background: #edf1f5; color: #526174; font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||
.access-policy-card.is-admin .access-policy-icon { background: var(--accent-soft); color: #315eaa; }
|
||||
.access-policy-card strong, .access-policy-card small { display: block; }
|
||||
.access-policy-card strong { font-size: 11px; }
|
||||
.access-policy-card small { margin-top: 2px; color: var(--muted); font-size: 8px; line-height: 1.4; }
|
||||
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
||||
.access-list { display: grid; gap: 9px; }
|
||||
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
||||
@@ -349,6 +457,77 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.access-target strong, .access-target small { display: block; }
|
||||
.access-target small { color: var(--muted); font-size: 8px; }
|
||||
.access-manage-switch { white-space: nowrap; }
|
||||
.admin-full-access-card { display: grid; grid-template-columns: 46px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 17px; border: 1px solid #acd8ce; border-radius: var(--radius); background: linear-gradient(110deg,#fff 65%,#eefaf7); box-shadow: var(--shadow); }
|
||||
.admin-full-access-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; background: #dff5ef; color: var(--success); font-family: var(--mono); font-size: 13px; font-weight: 900; }
|
||||
.admin-full-access-card h2 { margin-bottom: 3px; }
|
||||
.admin-full-access-card p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.account-password-panel { width: min(760px,100%); }
|
||||
.user-create-panel { width: min(930px,100%); }
|
||||
.user-create-panel .wide-form { max-width: none; }
|
||||
.user-create-grid { gap: 14px; align-items: stretch; }
|
||||
.user-create-grid > label { min-width: 0; align-content: start; }
|
||||
.user-create-grid input { height: 42px; }
|
||||
.user-create-identity-grid > label small { min-height: 27px; }
|
||||
.field-spacer { visibility: hidden; }
|
||||
|
||||
.software-version-card { position: relative; display: grid; min-height: 164px; overflow: hidden; grid-template-columns: 62px minmax(0,1fr) auto; gap: 20px; align-items: center; margin-bottom: 12px; padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow); }
|
||||
.software-version-card::after { position: absolute; top: -90px; right: -70px; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle,rgba(37,99,235,.08),transparent 68%); content: ""; pointer-events: none; }
|
||||
.software-version-card.has-update { border-color: #b4c9ec; background: linear-gradient(120deg,#fff 55%,#f1f6ff); box-shadow: 0 16px 38px rgba(37,99,235,.09); }
|
||||
.software-version-visual { position: relative; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid #cbd8e8; border-radius: 16px; background: #f4f7fb; color: #315eaa; font-family: var(--mono); font-size: 11px; font-weight: 900; }
|
||||
.software-version-visual i { position: absolute; right: -3px; bottom: -3px; width: 14px; height: 14px; border: 3px solid #fff; border-radius: 50%; background: #22a06b; }
|
||||
.software-version-copy { min-width: 0; }
|
||||
.software-version-values { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
|
||||
.software-version-cell { display: grid; min-width: 150px; gap: 3px; align-content: center; padding-right: 24px; }
|
||||
.software-version-cell + .software-version-cell { padding-left: 24px; border-left: 1px solid var(--line); }
|
||||
.software-version-cell span { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.software-version-cell strong { color: var(--text); font-family: var(--mono); font-size: clamp(22px,2.6vw,32px); line-height: 1.1; letter-spacing: -.04em; }
|
||||
.software-version-cell.is-latest strong { color: var(--accent); }
|
||||
.software-version-state { margin: 9px 0 0; color: var(--muted); font-size: 10px; }
|
||||
.software-version-meta { display: flex; flex-wrap: wrap; gap: 6px 15px; margin-top: 10px; color: var(--muted); font-size: 8px; }
|
||||
.software-version-meta b { color: #4b5c70; font-weight: 750; }
|
||||
.software-version-action { position: relative; z-index: 1; }
|
||||
.software-check-button { gap: 7px; }
|
||||
.software-check-button i { font-style: normal; font-size: 15px; line-height: 1; transition: transform .2s ease; }
|
||||
.software-check-button:hover i { transform: rotate(45deg); }
|
||||
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
||||
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||
.update-credential-panel { width: min(930px,100%); }
|
||||
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
|
||||
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
|
||||
.update-inline-warning p { margin: 0; }
|
||||
.update-inline-warning strong, .update-inline-warning small { display: block; }
|
||||
.update-inline-warning strong { color: #74540b; font-size: 9px; }
|
||||
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
|
||||
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s ease both; }
|
||||
.software-update-dialog { width: min(520px,100%); padding: 30px; border: 1px solid rgba(255,255,255,.14); border-radius: 18px; background: #111c2e; color: #f8fafc; box-shadow: 0 30px 80px rgba(0,0,0,.32); text-align: center; }
|
||||
.software-update-dialog .section-label { color: #7f94af; }
|
||||
.software-update-dialog h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; }
|
||||
.software-update-dialog p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; }
|
||||
.software-update-orbit { position: relative; display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 20px; border: 1px solid #344862; border-radius: 18px; background: #18283f; color: #b7d1ff; font-family: var(--mono); font-size: 11px; font-weight: 900; }
|
||||
.software-update-orbit i { position: absolute; inset: -8px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.25); border-radius: 22px; animation: update-orbit 1.15s linear infinite; }
|
||||
.software-update-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; }
|
||||
.software-update-progress i { display: block; width: 8%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#60a5fa,#2dd4bf); box-shadow: 0 0 14px rgba(96,165,250,.45); transition: width .55s cubic-bezier(.2,.8,.3,1), background .2s; }
|
||||
.software-update-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; }
|
||||
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; }
|
||||
.software-update-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; }
|
||||
.software-update-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; }
|
||||
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; }
|
||||
.software-update-phases li span { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid #2c3e57; border-radius: 8px; font-family: var(--mono); font-size: 7px; }
|
||||
.software-update-phases li.is-active { color: #b9d4ff; }
|
||||
.software-update-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; }
|
||||
.software-update-phases li.is-complete { color: #6fc5b6; }
|
||||
.software-update-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; }
|
||||
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||
body.software-update-open { overflow: hidden; }
|
||||
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes update-orbit { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||
@@ -381,6 +560,23 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.onboarding-shell { grid-template-columns: minmax(300px, .72fr) minmax(500px, 1.28fr); }
|
||||
.onboarding-intro { padding: 34px; }
|
||||
.onboarding-copy { margin-top: 38px; }
|
||||
.onboarding-copy h1 { font-size: 43px; }
|
||||
.onboarding-progress { margin-top: 27px; }
|
||||
.onboarding-steps small { display: none; }
|
||||
.onboarding-card { padding: 34px 42px; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px), (max-height: 600px) {
|
||||
.onboarding-shell { grid-template-columns: 1fr; }
|
||||
.onboarding-intro { display: none; }
|
||||
.onboarding-card { padding: clamp(20px, 5vw, 38px); }
|
||||
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-layout { display: block; }
|
||||
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
||||
@@ -413,6 +609,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.archive-row .button { width: 100%; }
|
||||
.info-box { grid-template-columns: 1fr; }
|
||||
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
||||
.access-policy-grid { grid-template-columns: 1fr; }
|
||||
.access-zone-controls { width: 100%; }
|
||||
.access-target { grid-template-columns: 1fr; }
|
||||
.error-summary-grid { grid-template-columns: 1fr; }
|
||||
@@ -420,9 +617,78 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.error-item > a { grid-column: 2; }
|
||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||
.pihole-connect-stage { min-height: 0; }
|
||||
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
|
||||
.software-update-banner-actions { grid-column: 1 / -1; }
|
||||
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
|
||||
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
|
||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||
.software-version-action { grid-column: 1 / -1; }
|
||||
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||
.admin-full-access-card > .status-pill { grid-column: 2; }
|
||||
.auth-shell { align-items: start; padding: 12px; }
|
||||
.page-setup .auth-shell { align-items: stretch; padding: 0; }
|
||||
.setup-field-row { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.onboarding-card { padding: 24px; }
|
||||
.onboarding-card-meta { margin-bottom: 24px; }
|
||||
.onboarding-form-heading h2 { font-size: 32px; }
|
||||
.onboarding-lede { margin-bottom: 20px; }
|
||||
.setup-form { gap: 12px; }
|
||||
.setup-form input { min-height: 43px; }
|
||||
.onboarding-footer { margin-top: 17px; padding-top: 15px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.access-policy-card { grid-template-columns: 34px minmax(0,1fr); }
|
||||
.access-policy-card > .status-pill { grid-column: 2; justify-self: start; }
|
||||
.software-version-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.software-version-cell { min-width: 0; padding-right: 12px; }
|
||||
.software-version-cell + .software-version-cell { padding-left: 12px; }
|
||||
.software-version-cell strong { font-size: 21px; }
|
||||
.software-update-dialog { padding: 24px 18px; }
|
||||
.onboarding-card { padding: 18px; }
|
||||
.onboarding-card-meta { margin-bottom: 18px; }
|
||||
.onboarding-form-heading h2 { font-size: 28px; }
|
||||
.onboarding-lede { margin-bottom: 15px; font-size: 10px; }
|
||||
.setup-field-row { grid-template-columns: 1fr; gap: 9px; }
|
||||
.setup-form { gap: 9px; }
|
||||
.setup-form label { gap: 4px; }
|
||||
.setup-form input { min-height: 39px; }
|
||||
.setup-submit { min-height: 42px; }
|
||||
.onboarding-footer { margin-top: 12px; padding-top: 11px; }
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.onboarding-intro { padding-block: 30px; }
|
||||
.onboarding-copy { margin-top: 28px; }
|
||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||
.onboarding-copy p { font-size: 11px; }
|
||||
.onboarding-progress { margin-top: 20px; }
|
||||
.onboarding-progress-track { margin-top: 7px; }
|
||||
.onboarding-steps { gap: 4px; margin-top: 9px; }
|
||||
.onboarding-steps li { min-height: 50px; padding-block: 7px; }
|
||||
.onboarding-steps small { display: none; }
|
||||
.onboarding-card { padding-block: 24px; }
|
||||
.onboarding-card-meta { margin-bottom: 18px; }
|
||||
.onboarding-form-heading h2 { font-size: 31px; }
|
||||
.onboarding-lede { margin-bottom: 17px; }
|
||||
.setup-form { gap: 10px; }
|
||||
.setup-form input { min-height: 41px; }
|
||||
.onboarding-footer { margin-top: 13px; padding-top: 13px; }
|
||||
}
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.onboarding-card-meta { margin-bottom: 12px; }
|
||||
.onboarding-lede { display: none; }
|
||||
.onboarding-form-heading h2 { margin-bottom: 14px; }
|
||||
.setup-form input { min-height: 38px; }
|
||||
.password-guidance span { min-height: 32px; padding: 5px 7px; }
|
||||
.password-guidance i { width: 18px; height: 18px; }
|
||||
.setup-submit { min-height: 40px; }
|
||||
.onboarding-footer { padding-top: 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
.toast, .toast-progress { animation: none !important; }
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder value="{{.StaticIPv4}}" inputmode="decimal" placeholder="{{if $.Settings.LastIPv4}}{{$.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
||||
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
@@ -106,7 +106,7 @@
|
||||
{{if or .IsAdmin .CanCreateTargets}}
|
||||
<section class="action-grid admin-actions-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder inputmode="decimal" placeholder="{{if .Settings.LastIPv4}}{{.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · DNS Control</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-15">
|
||||
<script src="/static/app.js?v=features-15" defer></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-21">
|
||||
<script src="/static/app.js?v=features-21" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
@@ -26,13 +26,12 @@
|
||||
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
|
||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
|
||||
<a href="/access" {{if eq .Page "access"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Zugriff</a>
|
||||
<a href="/system" {{if eq .Page "system"}}aria-current="page"{{end}}><span class="nav-icon">07</span>Update{{if .SoftwareUpdate.UpdateAvailable}}<i class="nav-update-dot" aria-label="Update verfügbar"></i>{{end}}</a>
|
||||
{{end}}
|
||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{else}}03{{end}}</span>Sicherheit</a>
|
||||
</nav>
|
||||
<div class="sidebar-account">
|
||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
|
||||
<a class="sidebar-account-link" href="/account" aria-label="Eigenes Konto öffnen"><span class="account-avatar">{{initial .User.DisplayName}}</span><span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span></a>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
||||
@@ -44,6 +43,16 @@
|
||||
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
|
||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||
</header>
|
||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||
<aside class="software-update-banner" aria-label="Software-Update verfügbar">
|
||||
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
|
||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||
<div class="software-update-banner-actions">
|
||||
<a class="button button-quiet" href="/system">Details</a>
|
||||
{{if .SoftwareUpdate.AgentAvailable}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
{{end}}
|
||||
<main id="main" class="app-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
@@ -67,6 +76,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title-global">
|
||||
<div class="software-update-dialog">
|
||||
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
|
||||
<span class="section-label">Systemupdate</span><h2 id="software-update-title-global">Update wird vorbereitet</h2>
|
||||
<p data-software-update-message>Das neue Container-Image wird geladen.</p>
|
||||
<div class="software-update-progress"><i data-software-update-progress></i></div>
|
||||
<ol class="software-update-phases" aria-label="Update-Fortschritt"><li data-update-phase="pulling"><span>01</span>Image laden</li><li data-update-phase="restarting"><span>02</span>Dienst wechseln</li><li data-update-phase="verifying"><span>03</span>Healthcheck</li></ol>
|
||||
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,18 +1,18 @@
|
||||
{{define "login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>Anmelden</h1>
|
||||
<section class="auth-card login-card">
|
||||
<div class="login-card-top"><div class="auth-brand"><span>DD</span> DNS Control</div><span class="login-mode {{if eq .Mode "https"}}is-secure{{end}}">{{if eq .Mode "https"}}HTTPS{{else}}LAN{{end}}</span></div>
|
||||
<h1>Willkommen zurück</h1>
|
||||
<p class="lede">Melde dich mit deinem persönlichen Zugang an.</p>
|
||||
<form action="/login" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Benutzername
|
||||
<input name="username" type="text" autocomplete="username" required autofocus>
|
||||
<input name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
<input name="password" type="password" autocomplete="current-password" maxlength="128" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,8 +1,5 @@
|
||||
{{define "security"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
|
||||
<section class="split-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
|
||||
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
|
||||
</section>
|
||||
<header class="page-heading"><div><span class="page-kicker">Persönlich</span><h1>Mein Konto</h1><p>@{{.User.Username}} · {{if .IsAdmin}}Administrator{{else}}Mitglied{{end}}</p></div>{{if .IsAdmin}}<a class="button button-quiet" href="/access">Zugriff verwalten</a>{{end}}</header>
|
||||
<section class="form-panel account-password-panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Eigenes Passwort ändern</h2></div></div><form action="/account/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" maxlength="128" required></label><div class="form-grid"><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="15" maxlength="128" required></label><label>Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="15" maxlength="128" required></label></div><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></section>
|
||||
{{end}}
|
||||
@@ -1,26 +1,64 @@
|
||||
{{define "setup"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>System einrichten</h1>
|
||||
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
|
||||
<form action="/setup" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Admin-Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
|
||||
</label></div>
|
||||
<label>Admin-Passwort
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
|
||||
</label>
|
||||
<label>Passwort wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
|
||||
<section class="onboarding-shell">
|
||||
<aside class="onboarding-intro">
|
||||
<div class="onboarding-brand-row">
|
||||
<div class="auth-brand auth-brand-inverse"><span>DD</span> DNS Control</div>
|
||||
<span class="onboarding-edition">Self-hosted</span>
|
||||
</div>
|
||||
<div class="onboarding-copy">
|
||||
<span class="onboarding-eyebrow">Erster Start</span>
|
||||
<h1>DNS-Verwaltung,<br>die einfach läuft.</h1>
|
||||
<p>Einrichten, verbinden, automatisch aktuell halten.</p>
|
||||
</div>
|
||||
<div class="onboarding-progress" aria-label="Einrichtung: Schritt 1 von 3">
|
||||
<div><span>Einrichtung</span><strong>1 von 3</strong></div>
|
||||
<span class="onboarding-progress-track" aria-hidden="true"><i></i></span>
|
||||
</div>
|
||||
<ol class="onboarding-steps" aria-label="Einrichtung">
|
||||
<li class="is-current" aria-current="step"><span>01</span><div><div class="onboarding-step-title"><strong>Administrator anlegen</strong><em>Aktuell</em></div><small>Sicheren Zugang für diese Instanz erstellen.</small></div></li>
|
||||
<li><span>02</span><div><strong>Dienste verbinden</strong><small>Hetzner und optional Pi-hole hinzufügen.</small></div></li>
|
||||
<li><span>03</span><div><strong>Automatisieren</strong><small>DNS-Einträge zuverlässig aktuell halten.</small></div></li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<section class="onboarding-card" aria-labelledby="setup-title">
|
||||
<div class="onboarding-form-wrap">
|
||||
<div class="onboarding-card-meta">
|
||||
<span class="setup-ready"><i aria-hidden="true"></i> Instanz bereit</span>
|
||||
<span>Schritt 1 von 3</span>
|
||||
</div>
|
||||
<header class="onboarding-form-heading">
|
||||
<h2 id="setup-title">Administrator anlegen</h2>
|
||||
<p class="onboarding-lede">Lege den ersten Administrator für diese Instanz an.</p>
|
||||
</header>
|
||||
|
||||
<form action="/setup" method="post" class="stacked-form setup-form" data-setup-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid setup-field-row"><label>Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" autocomplete="name" maxlength="80" required>
|
||||
</label></div>
|
||||
<div class="form-grid setup-field-row"><label>Admin-Passphrase
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="15" maxlength="128" aria-describedby="password-guidance" data-setup-password required>
|
||||
</label>
|
||||
<label>Passphrase wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="15" maxlength="128" data-setup-confirm required>
|
||||
</label></div>
|
||||
<div class="password-guidance" id="password-guidance" aria-live="polite">
|
||||
<span data-password-length><i aria-hidden="true">✓</i> Mindestens 15 Zeichen</span>
|
||||
<span data-password-match><i aria-hidden="true">✓</i> Eingaben stimmen überein</span>
|
||||
</div>
|
||||
<button class="button button-primary setup-submit" type="submit">Administrator erstellen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
|
||||
<div class="onboarding-footer onboarding-mode {{if eq .Mode "https"}}is-secure{{end}}">
|
||||
<span aria-hidden="true">{{if eq .Mode "https"}}●{{else}}!{{end}}</span>
|
||||
<p><strong>{{if eq .Mode "https"}}HTTPS-Modus aktiv{{else}}LAN-Modus aktiv{{end}}</strong><small>{{if eq .Mode "https"}}Verschlüsselte Verbindung{{else}}Nur im lokalen Netz verwenden{{end}}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "system"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading system-heading">
|
||||
<div><span class="page-kicker">Wartung</span><h1>Software-Update</h1><p>Version prüfen und die Instanz kontrolliert aktualisieren.</p></div>
|
||||
<form action="/system/check" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet software-check-button" type="submit"><i aria-hidden="true">↻</i> Nach Update suchen</button></form>
|
||||
</header>
|
||||
|
||||
<section class="software-version-card {{if .SoftwareUpdate.UpdateAvailable}}has-update{{end}}">
|
||||
<div class="software-version-visual" aria-hidden="true"><span>DC</span><i></i></div>
|
||||
<div class="software-version-copy">
|
||||
<div class="software-version-values">
|
||||
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
||||
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
|
||||
</div>
|
||||
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
||||
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{end}}</b></span></div>
|
||||
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||
</div>
|
||||
<div class="software-version-action">
|
||||
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}}
|
||||
<form action="/system/update" method="post" data-software-update-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
||||
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||
</form>
|
||||
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span>
|
||||
{{else}}
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured)}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel update-credential-panel" id="update-access">
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>{{if .SoftwareUpdate.CredentialConfigured}}Update-Zugang ersetzen{{else}}Update-Zugang einrichten{{end}}</h2></div>{{if .SoftwareUpdate.CredentialConfigured}}<span class="status-pill status-good">verschlüsselt</span>{{end}}</div>
|
||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
||||
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Zugang sicher speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title">
|
||||
<div class="software-update-dialog">
|
||||
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
|
||||
<span class="section-label">Systemupdate</span>
|
||||
<h2 id="software-update-title">Update wird vorbereitet</h2>
|
||||
<p data-software-update-message>Das neue Container-Image wird geladen. DNS-Daten und Secrets bleiben erhalten.</p>
|
||||
<div class="software-update-progress"><i data-software-update-progress></i></div>
|
||||
<ol class="software-update-phases" aria-label="Update-Fortschritt">
|
||||
<li data-update-phase="pulling"><span>01</span>Image laden</li>
|
||||
<li data-update-phase="restarting"><span>02</span>Dienst wechseln</li>
|
||||
<li data-update-phase="verifying"><span>03</span>Healthcheck</li>
|
||||
</ol>
|
||||
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -2,7 +2,7 @@
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a class="back-link" href="/users">← Benutzer</a>
|
||||
<a class="back-link" href="/access">← Zugriff & Sicherheit</a>
|
||||
<span class="page-kicker">Benutzerprofil</span>
|
||||
<h1>{{.ManagedUser.DisplayName}}</h1>
|
||||
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
||||
@@ -25,8 +25,8 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
||||
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
||||
</form>
|
||||
@@ -68,6 +68,6 @@
|
||||
</form>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
|
||||
<section class="admin-full-access-card access-panel"><span class="admin-full-access-icon" aria-hidden="true">A</span><div><span class="section-label">Administratorrolle</span><h2>Vollzugriff ist fest mit der Rolle verbunden</h2><p>Für Administratoren gibt es keine bearbeitbaren Domain- oder Host-Einzelrechte.</p></div><span class="status-pill status-good">Alle Rechte</span></section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,13 +1,13 @@
|
||||
{{define "user_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<header class="page-heading"><div><a class="back-link" href="/access">← Zugriff & Sicherheit</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten starten ohne Freigaben.</p></div></header>
|
||||
<section class="form-panel compact-panel user-create-panel">
|
||||
<form action="/users" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
|
||||
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
|
||||
<div class="form-grid user-create-grid user-create-identity-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required><small class="field-spacer" aria-hidden="true"> </small></label></div>
|
||||
<div class="form-grid user-create-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" maxlength="128" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" maxlength="128" required></label></div>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/access">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,5 +1,9 @@
|
||||
{{define "users"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
|
||||
<header class="page-heading"><div><span class="page-kicker">Administration</span><h1>Zugriff & Sicherheit</h1><p>Konten, Rollen und Freigaben an einem Ort.</p></div><div class="heading-actions"><a class="button button-quiet" href="/account">Mein Konto</a><a class="button button-primary" href="/users/new">Benutzer anlegen</a></div></header>
|
||||
<section class="access-policy-grid" aria-label="Rollenmodell">
|
||||
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
|
||||
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
|
||||
</section>
|
||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user