Add role based login and user management
This commit is contained in:
parent
f67bbdce78
commit
353dafc30d
4 changed files with 681 additions and 34 deletions
|
|
@ -16,6 +16,10 @@ import (
|
||||||
func RegisterRoutes(mux *http.ServeMux) {
|
func RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/", homeHandler)
|
mux.HandleFunc("/", homeHandler)
|
||||||
mux.HandleFunc("/login", loginHandler)
|
mux.HandleFunc("/login", loginHandler)
|
||||||
|
mux.HandleFunc("/logout", logoutHandler)
|
||||||
|
mux.HandleFunc("/users", usersHandler)
|
||||||
|
mux.HandleFunc("/users/save", userSaveHandler)
|
||||||
|
mux.HandleFunc("/users/delete", userDeleteHandler)
|
||||||
|
|
||||||
// Umzug (Konten pflegen + starten/beobachten)
|
// Umzug (Konten pflegen + starten/beobachten)
|
||||||
mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07)
|
mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07)
|
||||||
|
|
@ -59,16 +63,16 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Mail-Graveyard</title>
|
<title>Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -81,7 +85,8 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<div class="three-pane">
|
<div class="three-pane">
|
||||||
|
|
@ -104,6 +109,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountsHandler(w http.ResponseWriter, r *http.Request) {
|
func accountsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
accounts, err := ListAccounts()
|
accounts, err := ListAccounts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
@ -122,6 +130,9 @@ func accountsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
|
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -151,6 +162,9 @@ func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -168,6 +182,9 @@ func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountTestHandler(w http.ResponseWriter, r *http.Request) {
|
func accountTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -185,6 +202,9 @@ func accountTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func archivesHandler(w http.ResponseWriter, r *http.Request) {
|
func archivesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
accounts, err := ListAccounts()
|
accounts, err := ListAccounts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
@ -194,6 +214,9 @@ func archivesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func transferHandler(w http.ResponseWriter, r *http.Request) {
|
func transferHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mode := strings.TrimPrefix(r.URL.Path, "/transfer/")
|
mode := strings.TrimPrefix(r.URL.Path, "/transfer/")
|
||||||
if mode == "" || mode == "/transfer" {
|
if mode == "" || mode == "/transfer" {
|
||||||
http.Redirect(w, r, "/transfer/postfach-archiv", http.StatusSeeOther)
|
http.Redirect(w, r, "/transfer/postfach-archiv", http.StatusSeeOther)
|
||||||
|
|
@ -225,6 +248,9 @@ func emailBoxHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
|
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -242,6 +268,9 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -277,6 +306,9 @@ func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
|
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -322,6 +354,105 @@ func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors)
|
fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func usersHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := ListAppUsers()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editName := strings.TrimSpace(r.URL.Query().Get("edit"))
|
||||||
|
var edit AppUser
|
||||||
|
if editName != "" {
|
||||||
|
edit, err = GetAppUser(editName)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderUsersPage(w, r, users, edit, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
role := strings.TrimSpace(r.FormValue("role"))
|
||||||
|
active := r.FormValue("active") == "1"
|
||||||
|
if username == "" {
|
||||||
|
redirectUsers(w, r, "", "Benutzername fehlt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, existingErr := GetAppUser(username)
|
||||||
|
if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser {
|
||||||
|
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins bearbeiten.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !IsAdmin(r) {
|
||||||
|
role = roleUser
|
||||||
|
}
|
||||||
|
if errors.Is(existingErr, sql.ErrNoRows) && password == "" {
|
||||||
|
redirectUsers(w, r, username, "Passwort fuer neuen Benutzer fehlt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var hash string
|
||||||
|
if password != "" {
|
||||||
|
var err error
|
||||||
|
hash, err = HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
redirectUsers(w, r, username, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := SaveAppUser(username, hash, role, active); err != nil {
|
||||||
|
redirectUsers(w, r, username, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectUsers(w, r, username, "Benutzer gespeichert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func userDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireManager(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
if username == "" {
|
||||||
|
redirectUsers(w, r, "", "Kein Benutzer gewaehlt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := CurrentUser(r)
|
||||||
|
if strings.EqualFold(username, current.Username) {
|
||||||
|
redirectUsers(w, r, username, "Eigenen Benutzer nicht loeschen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := GetAppUser(username)
|
||||||
|
if err != nil {
|
||||||
|
redirectUsers(w, r, username, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !IsAdmin(r) && target.Role != roleUser {
|
||||||
|
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins loeschen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := DeleteAppUser(username); err != nil {
|
||||||
|
redirectUsers(w, r, username, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectUsers(w, r, "", "Benutzer geloescht.")
|
||||||
|
}
|
||||||
|
|
||||||
func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) {
|
func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) {
|
||||||
if edit.Name == "" {
|
if edit.Name == "" {
|
||||||
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
|
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
|
||||||
|
|
@ -331,16 +462,16 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Konten-Verwaltung - Mail-Graveyard</title>
|
<title>Konten-Verwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -353,7 +484,8 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<main class="accounts-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
|
<main class="accounts-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
|
||||||
|
|
@ -375,16 +507,16 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
|
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -397,7 +529,8 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<main class="accounts-page archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
|
<main class="accounts-page archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
|
||||||
|
|
@ -414,6 +547,56 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
|
||||||
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
|
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, edit AppUser, msg, errMsg string) {
|
||||||
|
if edit.Username == "" {
|
||||||
|
edit = AppUser{Role: roleUser, Active: true}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Benutzerverwaltung - Mail-Graveyard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
|
<body class="ol2013">
|
||||||
|
<header class="ribbon">
|
||||||
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
<nav class="ribbon-tabs">
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
<a class="tab active" href="/users">Einstellungen</a>
|
||||||
|
</nav>
|
||||||
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
|
</header>
|
||||||
|
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||||
|
<a class="backstage-item" href="/view">Archiv-Boxen</a>
|
||||||
|
<a class="backstage-item" href="/view">Import/Export</a>
|
||||||
|
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
|
||||||
|
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
|
||||||
|
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
|
||||||
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
|
<a class="backstage-item active" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
|
</aside>
|
||||||
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
|
<main class="accounts-page users-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
|
||||||
|
if msg != "" {
|
||||||
|
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Benutzer</div>`)
|
||||||
|
renderUsersTable(w, users, edit.Username)
|
||||||
|
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Benutzer eintragen</div>`)
|
||||||
|
renderUserForm(w, r, edit)
|
||||||
|
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span>angemeldet als `)
|
||||||
|
fmt.Fprint(w, html.EscapeString(CurrentUser(r).Username+" / "+CurrentUser(r).Role))
|
||||||
|
fmt.Fprint(w, `</span></footer></body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
type transferMode struct {
|
type transferMode struct {
|
||||||
Slug string
|
Slug string
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -442,16 +625,16 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>%s - Mail-Graveyard</title>
|
<title>%s - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -462,7 +645,8 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc
|
||||||
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
|
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
|
||||||
<a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a>
|
<a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a>
|
||||||
%s
|
%s
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<main class="accounts-page transfer-page">
|
<main class="accounts-page transfer-page">
|
||||||
|
|
@ -482,16 +666,16 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Quell-Postfach - Mail-Graveyard</title>
|
<title>Quell-Postfach - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -504,7 +688,8 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<div class="three-pane email-box-view">
|
<div class="three-pane email-box-view">
|
||||||
|
|
@ -523,16 +708,16 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Ziel-Postfach - Mail-Graveyard</title>
|
<title>Ziel-Postfach - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-14">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-14" defer></script></head>
|
<script src="/static/app.js?v=20260712-15" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
<nav class="ribbon-tabs">
|
<nav class="ribbon-tabs">
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
<a class="tab" href="/accounts">Einstellungen</a>
|
<a class="tab" href="/users">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="ribbon-title">Mail-Graveyard</div>
|
<div class="ribbon-title">Mail-Graveyard</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -545,7 +730,8 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach <--> Archiv-MBox</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox <--> Postfach</a>
|
||||||
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach <--> Postfach</a>
|
||||||
<a class="backstage-item" href="/accounts">Einstellungen</a>
|
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
|
||||||
|
<a class="backstage-item" href="/logout">Logout</a>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||||
<div class="three-pane email-box-view email-box-target-view">
|
<div class="three-pane email-box-view email-box-target-view">
|
||||||
|
|
@ -767,6 +953,70 @@ func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName str
|
||||||
fmt.Fprint(w, `</tbody></table>`)
|
fmt.Fprint(w, `</tbody></table>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) {
|
||||||
|
if len(users) == 0 {
|
||||||
|
fmt.Fprint(w, `<div class="ef-empty">Noch keine Benutzer eingetragen.</div>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `<table class="account-table user-table"><thead><tr><th>Benutzer</th><th>Rolle</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
|
||||||
|
for _, u := range users {
|
||||||
|
activeClass := ""
|
||||||
|
if u.Username == editName {
|
||||||
|
activeClass = ` class="selected"`
|
||||||
|
}
|
||||||
|
status := "aktiv"
|
||||||
|
if !u.Active {
|
||||||
|
status = "inaktiv"
|
||||||
|
}
|
||||||
|
deleteButton := fmt.Sprintf(`<form method="post" action="/users/delete"><input type="hidden" name="username" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>`, html.EscapeString(u.Username))
|
||||||
|
fmt.Fprintf(w, `<tr%s><td><a href="/users?edit=%s">%s</a></td><td>%s</td><td>%s</td><td class="row-actions">%s</td></tr>`,
|
||||||
|
activeClass, urlQuery(u.Username), html.EscapeString(u.Username), html.EscapeString(u.Role), status, deleteButton)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `</tbody></table>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
|
||||||
|
roleField := roleSelect(u.Role, IsAdmin(r))
|
||||||
|
if !IsAdmin(r) {
|
||||||
|
roleField = `<input type="hidden" name="role" value="user"><input class="input" value="user" disabled>`
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="label">Benutzername<input class="input" name="username" value="%s" required></label>
|
||||||
|
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-columns">
|
||||||
|
<fieldset><legend>Zugang</legend>
|
||||||
|
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="new-password" placeholder="%s"></label>
|
||||||
|
<label class="label">Rolle%s</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" type="submit">Speichern</button>
|
||||||
|
<a class="btn secondary" href="/users">Neu</a>
|
||||||
|
</div>
|
||||||
|
</form>`,
|
||||||
|
html.EscapeString(u.Username), checked(u.Active), passwordPlaceholder(u.Username), roleField)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleSelect(current string, admin bool) string {
|
||||||
|
if current == "" {
|
||||||
|
current = roleUser
|
||||||
|
}
|
||||||
|
values := []string{roleUser}
|
||||||
|
if admin {
|
||||||
|
values = []string{roleUser, roleManager, roleAdmin}
|
||||||
|
}
|
||||||
|
return selectHTML("role", current, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordPlaceholder(username string) string {
|
||||||
|
if username == "" {
|
||||||
|
return "Pflicht fuer neuen Benutzer"
|
||||||
|
}
|
||||||
|
return "leer lassen = behalten"
|
||||||
|
}
|
||||||
|
|
||||||
func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) {
|
func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) {
|
||||||
if len(archives) == 0 {
|
if len(archives) == 0 {
|
||||||
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
|
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
|
||||||
|
|
@ -1014,6 +1264,18 @@ func redirectArchives(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
http.Redirect(w, r, "/archives?"+key+"="+urlQuery(msg), http.StatusSeeOther)
|
http.Redirect(w, r, "/archives?"+key+"="+urlQuery(msg), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redirectUsers(w http.ResponseWriter, r *http.Request, edit, msg string) {
|
||||||
|
key := "msg"
|
||||||
|
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "nicht") || strings.Contains(strings.ToLower(msg), "error") {
|
||||||
|
key = "err"
|
||||||
|
}
|
||||||
|
target := "/users?" + key + "=" + urlQuery(msg)
|
||||||
|
if edit != "" {
|
||||||
|
target += "&edit=" + urlQuery(edit)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func urlQuery(value string) string {
|
func urlQuery(value string) string {
|
||||||
return url.QueryEscape(value)
|
return url.QueryEscape(value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
@ -37,6 +38,14 @@ type Account struct {
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AppUser struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
|
PasswordHash string
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
|
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
|
||||||
//
|
//
|
||||||
// Schema (TODO Codex):
|
// Schema (TODO Codex):
|
||||||
|
|
@ -107,6 +116,21 @@ func ConnectDB() error {
|
||||||
errors INTEGER NOT NULL DEFAULT 0,
|
errors INTEGER NOT NULL DEFAULT 0,
|
||||||
state TEXT NOT NULL DEFAULT 'running'
|
state TEXT NOT NULL DEFAULT 'running'
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS app_users(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS app_sessions(
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
for _, stmt := range schema {
|
for _, stmt := range schema {
|
||||||
if _, err := db.Exec(stmt); err != nil {
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
|
@ -118,6 +142,84 @@ func ConnectDB() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CountAppUsers() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRow(`SELECT count(*) FROM app_users`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAppUsers() ([]AppUser, error) {
|
||||||
|
rows, err := DB.Query(`SELECT id, username, password_hash, role, active FROM app_users ORDER BY username`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []AppUser
|
||||||
|
for rows.Next() {
|
||||||
|
u, err := scanAppUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppUser(username string) (AppUser, error) {
|
||||||
|
row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE username=?`, username)
|
||||||
|
return scanAppUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAppUserByID(id int64) (AppUser, error) {
|
||||||
|
row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE id=?`, id)
|
||||||
|
return scanAppUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveAppUser(username, passwordHash, role string, active bool) error {
|
||||||
|
role = normalizeRole(role)
|
||||||
|
if passwordHash == "" {
|
||||||
|
_, err := DB.Exec(`UPDATE app_users SET role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`,
|
||||||
|
role, boolInt(active), username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := DB.Exec(`INSERT INTO app_users(username, password_hash, role, active)
|
||||||
|
VALUES(?,?,?,?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET
|
||||||
|
password_hash=excluded.password_hash,
|
||||||
|
role=excluded.role,
|
||||||
|
active=excluded.active,
|
||||||
|
updated_at=CURRENT_TIMESTAMP`,
|
||||||
|
username, passwordHash, role, boolInt(active))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAppUser(username string) error {
|
||||||
|
_, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSession(token string, userID int64, expiresAt string) error {
|
||||||
|
_, err := DB.Exec(`INSERT INTO app_sessions(token, user_id, expires_at) VALUES(?,?,?)`, token, userID, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteSession(token string) error {
|
||||||
|
_, err := DB.Exec(`DELETE FROM app_sessions WHERE token=?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SessionUser(token string) (AppUser, error) {
|
||||||
|
row := DB.QueryRow(`SELECT u.id, u.username, u.password_hash, u.role, u.active
|
||||||
|
FROM app_sessions s JOIN app_users u ON u.id=s.user_id
|
||||||
|
WHERE s.token=? AND s.expires_at > CURRENT_TIMESTAMP AND u.active=1`, token)
|
||||||
|
return scanAppUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupSessions() error {
|
||||||
|
_, err := DB.Exec(`DELETE FROM app_sessions WHERE expires_at <= CURRENT_TIMESTAMP`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func ListAccounts() ([]Account, error) {
|
func ListAccounts() ([]Account, error) {
|
||||||
rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
|
rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
|
||||||
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
|
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
|
||||||
|
|
@ -202,6 +304,10 @@ type accountScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type appUserScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
func scanAccount(s accountScanner) (Account, error) {
|
func scanAccount(s accountScanner) (Account, error) {
|
||||||
var a Account
|
var a Account
|
||||||
var srcInsecure, dstInsecure, active int
|
var srcInsecure, dstInsecure, active int
|
||||||
|
|
@ -242,6 +348,26 @@ func normalizeAccount(a *Account) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanAppUser(s appUserScanner) (AppUser, error) {
|
||||||
|
var u AppUser
|
||||||
|
var active int
|
||||||
|
err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &active)
|
||||||
|
u.Role = normalizeRole(u.Role)
|
||||||
|
u.Active = active != 0
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRole(role string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||||
|
case "admin":
|
||||||
|
return "admin"
|
||||||
|
case "verwalter":
|
||||||
|
return "verwalter"
|
||||||
|
default:
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func boolInt(v bool) int {
|
func boolInt(v bool) int {
|
||||||
if v {
|
if v {
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,257 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "mail_graveyard_session"
|
||||||
|
currentUserKey = contextKey("currentUser")
|
||||||
|
roleUser = "user"
|
||||||
|
roleManager = "verwalter"
|
||||||
|
roleAdmin = "admin"
|
||||||
|
passwordHashIterations = 120000
|
||||||
|
defaultSessionValidDays = 7
|
||||||
|
)
|
||||||
|
|
||||||
// InitAuth bereitet die Session-/Login-Verwaltung vor.
|
// InitAuth bereitet die Session-/Login-Verwaltung vor.
|
||||||
//
|
//
|
||||||
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
|
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
|
||||||
// kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen.
|
// kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen.
|
||||||
// - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass.
|
// - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass.
|
||||||
// - Wenn spaeter remote erreichbar (hinter Caddy): /vadmin-Mail-2FA-Muster
|
// - Wenn remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln
|
||||||
// aus dem Web-Deploy-Kit vorschalten.
|
// beibehalten. Rollen prueft die App serverseitig.
|
||||||
func InitAuth() error {
|
func InitAuth() error {
|
||||||
// TODO Codex: Session-Cookie-Store initialisieren.
|
if err := CleanupSessions(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, err := CountAppUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(Cfg.AdminUser) == "" || Cfg.AdminPass == "" {
|
||||||
|
return fmt.Errorf("admin_user/admin_pass fehlen fuer initialen Login-Benutzer")
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(Cfg.AdminPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return SaveAppUser(strings.TrimSpace(Cfg.AdminUser), hash, roleAdmin, true)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
||||||
func AuthMiddleware(next http.Handler) http.Handler {
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO Codex: /login + /static durchlassen, sonst Session pruefen,
|
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" {
|
||||||
// sonst Redirect /login.
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := SessionUser(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
clearSessionCookie(w)
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserKey, user)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
func CurrentUser(r *http.Request) AppUser {
|
||||||
// TODO Codex: Formular, Abgleich mit Cfg.AdminUser/AdminPass, Cookie setzen.
|
if u, ok := r.Context().Value(currentUserKey).(AppUser); ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return AppUser{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanManageAccounts(r *http.Request) bool {
|
||||||
|
return hasRole(CurrentUser(r), roleManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanManageUsers(r *http.Request) bool {
|
||||||
|
return hasRole(CurrentUser(r), roleManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdmin(r *http.Request) bool {
|
||||||
|
return CurrentUser(r).Role == roleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireManager(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if CanManageAccounts(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
http.Error(w, "Keine Berechtigung.", http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if IsAdmin(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
http.Error(w, "Nur Admins duerfen diese Aktion ausfuehren.", http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRole(user AppUser, needed string) bool {
|
||||||
|
rank := map[string]int{roleUser: 1, roleManager: 2, roleAdmin: 3}
|
||||||
|
return rank[user.Role] >= rank[needed]
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
renderLoginPage(w, r.URL.Query().Get("err"))
|
||||||
|
case http.MethodPost:
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
renderLoginPage(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
user, err := GetAppUser(username)
|
||||||
|
if err != nil || !user.Active || !CheckPassword(password, user.PasswordHash) {
|
||||||
|
renderLoginPage(w, "Login fehlgeschlagen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
renderLoginPage(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(defaultSessionValidDays * 24 * time.Hour)
|
||||||
|
if err := CreateSession(token, user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil {
|
||||||
|
renderLoginPage(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: expires,
|
||||||
|
MaxAge: int(time.Until(expires).Seconds()),
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/view", http.StatusSeeOther)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
_ = DeleteSession(cookie.Value)
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderLoginPage(w http.ResponseWriter, errMsg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Login - Mail-Graveyard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260712-15"></head>
|
||||||
|
<body class="ol2013 login-body">
|
||||||
|
<main class="login-card">
|
||||||
|
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
|
||||||
|
<form method="post" action="/login" class="login-form">
|
||||||
|
<label class="label">Benutzer<input class="input" name="username" autocomplete="username" autofocus required></label>
|
||||||
|
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
%s
|
||||||
|
<button class="btn" type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body></html>`, loginErrorHTML(errMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginErrorHTML(errMsg string) string {
|
||||||
|
if errMsg == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("HX-Redirect", "/login")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := passwordDigest(password, salt, passwordHashIterations)
|
||||||
|
return fmt.Sprintf("sha256:%d:%s:%s", passwordHashIterations, salt, base64.RawStdEncoding.EncodeToString(sum)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(password, encoded string) bool {
|
||||||
|
parts := strings.Split(encoded, ":")
|
||||||
|
if len(parts) != 4 || parts[0] != "sha256" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var iterations int
|
||||||
|
if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := passwordDigest(password, parts[2], iterations)
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordDigest(password, salt string, iterations int) []byte {
|
||||||
|
data := []byte(salt + ":" + password)
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
out := sum[:]
|
||||||
|
for i := 1; i < iterations; i++ {
|
||||||
|
next := sha256.Sum256(out)
|
||||||
|
out = next[:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(size int) (string, error) {
|
||||||
|
b := make([]byte, size)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
frontend-js/dist/style.css
vendored
30
frontend-js/dist/style.css
vendored
|
|
@ -26,6 +26,36 @@ body.ol2013 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-body {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f6f8fb 0%, #e8eef5 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--ol-border);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, .16);
|
||||||
|
}
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--ol-blue);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #f3b5b8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ribbon / Kopfband */
|
/* Ribbon / Kopfband */
|
||||||
.ribbon {
|
.ribbon {
|
||||||
background: var(--ol-blue);
|
background: var(--ol-blue);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue