feat(PROJ-53): Konfigurierbare Listenanzahl pro Seite
- users.list_page_size (Default 25), PATCH /api/auth/preferences, Whitelist 25/50/100/200, Wert in login/me-Response - Settings-UI mit Select, /search nutzt gespeicherte Seitengröße - /api/search page_size serverseitig auf max. 500 gecappt fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool (Backend kompilierte nicht) feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler, cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
This commit is contained in:
@@ -124,6 +124,7 @@ func main() {
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, " "),
|
To: strings.Join(pm.To, " "),
|
||||||
|
CC: strings.Join(pm.CC, " "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody + " " + pm.HTMLBody,
|
Body: pm.TextBody + " " + pm.HTMLBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,12 +36,16 @@ func runExport(args []string) {
|
|||||||
dateFrom := fs.String("date-from", "", "filter from date (ISO 8601: 2024-01-01)")
|
dateFrom := fs.String("date-from", "", "filter from date (ISO 8601: 2024-01-01)")
|
||||||
dateTo := fs.String("date-to", "", "filter to date (ISO 8601: 2024-12-31)")
|
dateTo := fs.String("date-to", "", "filter to date (ISO 8601: 2024-12-31)")
|
||||||
query := fs.String("query", "", "fulltext search query")
|
query := fs.String("query", "", "fulltext search query")
|
||||||
|
tenant := fs.String("tenant", "", "export ALL mails of the given tenant id (mutually exclusive with from/to/date/query)")
|
||||||
force := fs.Bool("force", false, "overwrite existing output file")
|
force := fs.Bool("force", false, "overwrite existing output file")
|
||||||
jsonOut := fs.Bool("json", false, "machine-readable JSON output")
|
jsonOut := fs.Bool("json", false, "machine-readable JSON output")
|
||||||
|
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(os.Stderr, "Usage: archivmail export [flags]")
|
fmt.Fprintln(os.Stderr, "Usage: archivmail export [flags]")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "Exports archived mails either by search filters (from/to/date/query)")
|
||||||
|
fmt.Fprintln(os.Stderr, "or, with --tenant, the complete mail set of a single tenant.")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Flags:")
|
fmt.Fprintln(os.Stderr, "Flags:")
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
}
|
}
|
||||||
@@ -51,6 +57,22 @@ func runExport(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --tenant and search filters are mutually exclusive.
|
||||||
|
var tenantID *int64
|
||||||
|
if *tenant != "" {
|
||||||
|
if *from != "" || *to != "" || *dateFrom != "" || *dateTo != "" || *query != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: --tenant cannot be combined with --from/--to/--date-from/--date-to/--query")
|
||||||
|
fs.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
id, perr := strconv.ParseInt(*tenant, 10, 64)
|
||||||
|
if perr != nil || id <= 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: invalid --tenant %q (expected positive integer id)\n", *tenant)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
tenantID = &id
|
||||||
|
}
|
||||||
|
|
||||||
if *format != "eml" && *format != "mbox" {
|
if *format != "eml" && *format != "mbox" {
|
||||||
fmt.Fprintf(os.Stderr, "error: unknown format %q (supported: eml, mbox)\n", *format)
|
fmt.Fprintf(os.Stderr, "error: unknown format %q (supported: eml, mbox)\n", *format)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -85,20 +107,22 @@ func runExport(args []string) {
|
|||||||
if backend == "" {
|
if backend == "" {
|
||||||
backend = "xapian"
|
backend = "xapian"
|
||||||
}
|
}
|
||||||
idx, err := index.New(cfg.Index.Path, batchSize, backend)
|
// The fulltext index is only needed when exporting via search filters.
|
||||||
if err != nil {
|
var idx index.Indexer
|
||||||
fmt.Fprintf(os.Stderr, "error: index init: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer idx.Close()
|
|
||||||
|
|
||||||
// Build search request
|
|
||||||
req := index.SearchRequest{
|
req := index.SearchRequest{
|
||||||
Query: *query,
|
Query: *query,
|
||||||
From: *from,
|
From: *from,
|
||||||
To: *to,
|
To: *to,
|
||||||
PageSize: 500,
|
PageSize: 500,
|
||||||
}
|
}
|
||||||
|
if tenantID == nil {
|
||||||
|
idx, err = index.New(cfg.Index.Path, batchSize, backend)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: index init: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer idx.Close()
|
||||||
|
|
||||||
if *dateFrom != "" {
|
if *dateFrom != "" {
|
||||||
if t, err := time.Parse(time.DateOnly, *dateFrom); err == nil {
|
if t, err := time.Parse(time.DateOnly, *dateFrom); err == nil {
|
||||||
req.DateFrom = &t
|
req.DateFrom = &t
|
||||||
@@ -116,6 +140,7 @@ func runExport(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare output
|
// Prepare output
|
||||||
var mboxFile *os.File
|
var mboxFile *os.File
|
||||||
@@ -146,8 +171,58 @@ func runExport(args []string) {
|
|||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
errors := 0
|
errors := 0
|
||||||
page := 0
|
|
||||||
|
|
||||||
|
// exportID loads a single mail and writes it in the selected format.
|
||||||
|
// Returns false on a (logged) per-mail error so the caller can count it.
|
||||||
|
exportID := func(id string) bool {
|
||||||
|
raw, err := mailStore.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: load %s: %v\n", id, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if *format == "mbox" {
|
||||||
|
if err := writeMboxMessage(mboxFile, raw); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: write mbox: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outPath := filepath.Join(*out, id+".eml")
|
||||||
|
if _, err := os.Stat(outPath); err == nil && !*force {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: %s exists, skipping (use --force)\n", outPath)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: write %s: %v\n", outPath, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenantID != nil {
|
||||||
|
// Full tenant export: pull all mail IDs directly from storage.
|
||||||
|
ids, err := mailStore.GetAllIDsByTenant(context.Background(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: get tenant ids: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
if !*jsonOut {
|
||||||
|
fmt.Printf("Hinweis: 0 Mails exportiert (Tenant %d hat keine Mails oder existiert nicht)\n", *tenantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if exportID(id) {
|
||||||
|
exported++
|
||||||
|
} else {
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !*jsonOut && len(ids) > 0 {
|
||||||
|
fmt.Printf("Progress: %d exported, %d errors\n", exported, errors)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
page := 0
|
||||||
for {
|
for {
|
||||||
req.Page = page
|
req.Page = page
|
||||||
result, err := idx.Search(req)
|
result, err := idx.Search(req)
|
||||||
@@ -160,33 +235,11 @@ func runExport(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, hit := range result.Hits {
|
for _, hit := range result.Hits {
|
||||||
raw, err := mailStore.Load(hit.ID)
|
if exportID(hit.ID) {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: load %s: %v\n", hit.ID, err)
|
|
||||||
errors++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if *format == "mbox" {
|
|
||||||
if err := writeMboxMessage(mboxFile, raw); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: write mbox: %v\n", err)
|
|
||||||
errors++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outPath := filepath.Join(*out, hit.ID+".eml")
|
|
||||||
if _, err := os.Stat(outPath); err == nil && !*force {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s exists, skipping (use --force)\n", outPath)
|
|
||||||
errors++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: write %s: %v\n", outPath, err)
|
|
||||||
errors++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exported++
|
exported++
|
||||||
|
} else {
|
||||||
|
errors++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*jsonOut {
|
if !*jsonOut {
|
||||||
@@ -198,6 +251,7 @@ func runExport(args []string) {
|
|||||||
}
|
}
|
||||||
page++
|
page++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if *jsonOut {
|
if *jsonOut {
|
||||||
r := exportResult{
|
r := exportResult{
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ func importMessage(mailStore *storage.Store, idxMgr index.TenantIndexer, raw []b
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, " "),
|
To: strings.Join(pm.To, " "),
|
||||||
|
CC: strings.Join(pm.CC, " "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody + " " + pm.HTMLBody,
|
Body: pm.TextBody + " " + pm.HTMLBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func runReindex(args []string) {
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, ", "),
|
To: strings.Join(pm.To, ", "),
|
||||||
|
CC: strings.Join(pm.CC, ", "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody,
|
Body: pm.TextBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -501,6 +501,7 @@ func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw [
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, ", "),
|
To: strings.Join(pm.To, ", "),
|
||||||
|
CC: strings.Join(pm.CC, ", "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody,
|
Body: pm.TextBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
@@ -631,6 +632,7 @@ func reindexTenant(ctx context.Context, store *storage.Store, mgr index.TenantIn
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, ", "),
|
To: strings.Join(pm.To, ", "),
|
||||||
|
CC: strings.Join(pm.CC, ", "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody,
|
Body: pm.TextBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
+3
-2
@@ -66,10 +66,11 @@
|
|||||||
| PROJ-47 | Tenant-Voll-Export per CLI | In Review | [PROJ-47](PROJ-47-tenant-voll-export-cli.md) | 2026-06-13 |
|
| PROJ-47 | Tenant-Voll-Export per CLI | In Review | [PROJ-47](PROJ-47-tenant-voll-export-cli.md) | 2026-06-13 |
|
||||||
| PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 |
|
| PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 |
|
||||||
| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 |
|
| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 |
|
||||||
| PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | In Progress | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 |
|
| PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | In Review | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 |
|
||||||
| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | Deployed | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 |
|
| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | Deployed | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 |
|
||||||
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
|
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
|
||||||
|
| PROJ-53 | Konfigurierbare Listenanzahl pro Seite | In Review | [PROJ-53](PROJ-53-konfigurierbare-listenanzahl.md) | 2026-06-14 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
## Next Available ID: PROJ-53
|
## Next Available ID: PROJ-54
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
|
"list_page_size": user.ListPageSize,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -109,6 +110,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
|
"list_page_size": user.ListPageSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"archivmail/internal/audit"
|
||||||
|
"archivmail/internal/index"
|
||||||
|
"archivmail/internal/storage"
|
||||||
|
"archivmail/internal/userstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dsgvoMaxHits caps the number of mails evaluated per request, mirroring the
|
||||||
|
// eDiscovery export limit (PROJ-39). Beyond this the admin is asked to narrow
|
||||||
|
// the date range.
|
||||||
|
const dsgvoMaxHits = 10000
|
||||||
|
|
||||||
|
// canManageDSGVO reports whether the role may create/process DSGVO requests.
|
||||||
|
func canManageDSGVO(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin, userstore.RoleAdmin:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// canViewDSGVO reports whether the role may read DSGVO requests (incl. auditors).
|
||||||
|
func canViewDSGVO(role string) bool {
|
||||||
|
if canManageDSGVO(role) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch role {
|
||||||
|
case userstore.RoleAuditor, userstore.RoleDomainAuditor:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateDSGVORequest creates a DSGVO erasure request, runs the address
|
||||||
|
// search in the admin's tenant scope, evaluates retain_until per mail and
|
||||||
|
// stores the documented result.
|
||||||
|
//
|
||||||
|
// POST /api/admin/dsgvo
|
||||||
|
// Body: {address, date_from?, date_to?}
|
||||||
|
func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if !canManageDSGVO(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
DateFrom string `json:"date_from"`
|
||||||
|
DateTo string `json:"date_to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
address := strings.ToLower(strings.TrimSpace(req.Address))
|
||||||
|
if address == "" || !strings.Contains(address, "@") {
|
||||||
|
writeError(w, http.StatusBadRequest, "valid e-mail address required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the address search across from/to/cc/bcc.
|
||||||
|
searchReq := index.SearchRequest{
|
||||||
|
AnyAddress: address,
|
||||||
|
PageSize: dsgvoMaxHits,
|
||||||
|
Page: 1,
|
||||||
|
}
|
||||||
|
if req.DateFrom != "" {
|
||||||
|
if t, err := time.Parse(time.DateOnly, req.DateFrom); err == nil {
|
||||||
|
searchReq.DateFrom = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.DateTo != "" {
|
||||||
|
if t, err := time.Parse(time.DateOnly, req.DateTo); err == nil {
|
||||||
|
t = t.Add(24*time.Hour - time.Second)
|
||||||
|
searchReq.DateTo = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIdx := s.idx
|
||||||
|
if s.idxMgr != nil && tenantID != nil {
|
||||||
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := searchIdx.Search(searchReq)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the request first so the vorgang is documented even if later steps fail.
|
||||||
|
dsReq, err := s.store.CreateDSGVORequest(r.Context(), tenantID, address, sess.Username)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not create request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0, len(result.Hits))
|
||||||
|
for _, h := range result.Hits {
|
||||||
|
ids = append(ids, h.ID)
|
||||||
|
}
|
||||||
|
meta, err := s.store.GetDSGVOMailMeta(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "metadata lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
summary := storage.DSGVOResultSummary{
|
||||||
|
Truncated: result.Total > dsgvoMaxHits,
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
m, ok := meta[id]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.TotalHits++
|
||||||
|
am := storage.DSGVOAffectedMail{
|
||||||
|
MailID: id,
|
||||||
|
Subject: m.Subject,
|
||||||
|
RetainUntil: m.RetainUntil,
|
||||||
|
}
|
||||||
|
if !m.ReceivedAt.IsZero() {
|
||||||
|
d := m.ReceivedAt
|
||||||
|
am.Date = &d
|
||||||
|
}
|
||||||
|
if m.RetainUntil != nil && now.Before(*m.RetainUntil) {
|
||||||
|
summary.Rejected++
|
||||||
|
am.Deletable = false
|
||||||
|
am.Reason = fmt.Sprintf("Abgelehnt - gesetzliche Aufbewahrungspflicht bis %s",
|
||||||
|
m.RetainUntil.Format("2006-01-02"))
|
||||||
|
} else {
|
||||||
|
summary.Deletable++
|
||||||
|
am.Deletable = true
|
||||||
|
am.Reason = "Loeschbar - keine Aufbewahrungspflicht"
|
||||||
|
}
|
||||||
|
summary.Mails = append(summary.Mails, am)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := dsgvoStatus(summary)
|
||||||
|
if err := s.store.UpdateDSGVOResult(r.Context(), dsReq.ID, status, &summary); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not store result")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dsReq.Status = status
|
||||||
|
dsReq.ResultSummary = &summary
|
||||||
|
|
||||||
|
if s.audlog != nil {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventDSGVORequest,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: fmt.Sprintf("dsgvo: address=%q hits=%d rejected=%d deletable=%d truncated=%t",
|
||||||
|
address, summary.TotalHits, summary.Rejected, summary.Deletable, summary.Truncated),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, dsReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsgvoStatus computes the request status from the result summary.
|
||||||
|
func dsgvoStatus(s storage.DSGVOResultSummary) string {
|
||||||
|
if s.TotalHits == 0 {
|
||||||
|
return storage.DSGVOStatusCompleted
|
||||||
|
}
|
||||||
|
if s.Rejected > 0 && s.Deletable > 0 {
|
||||||
|
return storage.DSGVOStatusPartial
|
||||||
|
}
|
||||||
|
if s.Rejected > 0 {
|
||||||
|
return storage.DSGVOStatusPartial
|
||||||
|
}
|
||||||
|
return storage.DSGVOStatusCompleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListDSGVORequests lists requests in the caller's tenant scope.
|
||||||
|
// GET /api/admin/dsgvo
|
||||||
|
func (s *Server) handleListDSGVORequests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if !canViewDSGVO(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
list, err := s.store.ListDSGVORequests(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "list failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"requests": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetDSGVORequest returns a single request detail.
|
||||||
|
// GET /api/admin/dsgvo/{id}
|
||||||
|
func (s *Server) handleGetDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if !canViewDSGVO(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteDSGVOMails deletes the deletable mails of a request using the
|
||||||
|
// existing retention-aware delete mechanism (PROJ-34). Mails under retention
|
||||||
|
// lock are skipped. Each deletion is documented and the request summary is
|
||||||
|
// updated to reflect what was actually removed.
|
||||||
|
//
|
||||||
|
// POST /api/admin/dsgvo/{id}/delete
|
||||||
|
func (s *Server) handleDeleteDSGVOMails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if !canManageDSGVO(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
||||||
|
if err != nil || req.ResultSummary == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIdx := s.idx
|
||||||
|
if s.idxMgr != nil && tenantID != nil {
|
||||||
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for i := range req.ResultSummary.Mails {
|
||||||
|
m := &req.ResultSummary.Mails[i]
|
||||||
|
if !m.Deletable || m.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.Delete(m.MailID); err != nil {
|
||||||
|
// Retention lock or missing file → leave as-is, keep documented.
|
||||||
|
m.Reason = "Loeschung nicht moeglich: " + err.Error()
|
||||||
|
m.Deletable = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = searchIdx.Delete(m.MailID)
|
||||||
|
m.Deleted = true
|
||||||
|
m.Deletable = false
|
||||||
|
m.Reason = "Geloescht auf DSGVO-Antrag"
|
||||||
|
deleted++
|
||||||
|
|
||||||
|
if s.audlog != nil {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventDSGVORequest,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
MailID: m.MailID,
|
||||||
|
Success: true,
|
||||||
|
Detail: fmt.Sprintf("dsgvo: deleted mail (request_id=%d address=%q)", id, req.RequestedAddress),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute counters.
|
||||||
|
req.ResultSummary.Deleted += deleted
|
||||||
|
req.ResultSummary.Deletable = 0
|
||||||
|
for _, m := range req.ResultSummary.Mails {
|
||||||
|
if m.Deletable {
|
||||||
|
req.ResultSummary.Deletable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := dsgvoStatus(*req.ResultSummary)
|
||||||
|
if req.ResultSummary.Deleted > 0 && req.ResultSummary.Deletable == 0 && req.ResultSummary.Rejected == 0 {
|
||||||
|
status = storage.DSGVOStatusCompleted
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateDSGVOResult(r.Context(), id, status, req.ResultSummary); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not update request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Status = status
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"deleted": deleted, "request": req})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExportDSGVORequest produces a PDF summary suitable as a written reply
|
||||||
|
// to the data subject, documenting the legal basis for any rejection.
|
||||||
|
//
|
||||||
|
// GET /api/admin/dsgvo/{id}/export
|
||||||
|
func (s *Server) handleExportDSGVORequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if !canViewDSGVO(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := buildDSGVOPDF(req)
|
||||||
|
filename := fmt.Sprintf("dsgvo-antrag-%d.pdf", req.ID)
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
_, _ = w.Write(pdf)
|
||||||
|
|
||||||
|
if s.audlog != nil {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventExport,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: fmt.Sprintf("dsgvo: export request_id=%d", req.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDSGVOPDF renders a DSGVO request summary as a PDF using the existing
|
||||||
|
// minimal PDF writer (PROJ-12 buildMailPDF pattern).
|
||||||
|
func buildDSGVOPDF(req *storage.DSGVORequest) []byte {
|
||||||
|
p := newPDFWriter()
|
||||||
|
pageW := 595.28
|
||||||
|
pageH := 841.89
|
||||||
|
margin := 50.0
|
||||||
|
lineH := 14.0
|
||||||
|
smallLineH := 12.0
|
||||||
|
|
||||||
|
var cs bytes.Buffer
|
||||||
|
y := pageH - margin
|
||||||
|
newline := func(h float64) { y -= h }
|
||||||
|
text := func(x, ypos, size float64, bold bool, s string) {
|
||||||
|
font := "Helvetica"
|
||||||
|
if bold {
|
||||||
|
font = "Helvetica-Bold"
|
||||||
|
}
|
||||||
|
cs.WriteString(fmt.Sprintf("BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n",
|
||||||
|
font, size, x, ypos, escapePDF(toLatinSafe(s))))
|
||||||
|
}
|
||||||
|
|
||||||
|
text(margin, y, 16, true, "DSGVO-Loeschersuchen - Bearbeitungsnachweis")
|
||||||
|
newline(lineH * 2)
|
||||||
|
|
||||||
|
text(margin, y, 10, true, "Antrags-ID:")
|
||||||
|
text(margin+120, y, 10, false, strconv.FormatInt(req.ID, 10))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, true, "Betroffene Adresse:")
|
||||||
|
text(margin+120, y, 10, false, req.RequestedAddress)
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, true, "Bearbeitet von:")
|
||||||
|
text(margin+120, y, 10, false, req.RequestedBy)
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, true, "Erstellt am:")
|
||||||
|
text(margin+120, y, 10, false, req.CreatedAt.Format("02.01.2006 15:04:05"))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, true, "Status:")
|
||||||
|
text(margin+120, y, 10, false, req.Status)
|
||||||
|
newline(lineH * 1.5)
|
||||||
|
|
||||||
|
if req.ResultSummary != nil {
|
||||||
|
sum := req.ResultSummary
|
||||||
|
text(margin, y, 12, true, "Zusammenfassung")
|
||||||
|
newline(lineH)
|
||||||
|
text(margin, y, 10, false, fmt.Sprintf("Treffer gesamt: %d", sum.TotalHits))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, false, fmt.Sprintf("Abgelehnt (Aufbewahrungspflicht): %d", sum.Rejected))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, false, fmt.Sprintf("Loeschbar: %d", sum.Deletable))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin, y, 10, false, fmt.Sprintf("Bereits geloescht: %d", sum.Deleted))
|
||||||
|
newline(smallLineH)
|
||||||
|
if sum.Truncated {
|
||||||
|
text(margin, y, 9, false, fmt.Sprintf("Hinweis: Ergebnis auf %d Treffer begrenzt - Zeitraum eingrenzen.", dsgvoMaxHits))
|
||||||
|
newline(smallLineH)
|
||||||
|
}
|
||||||
|
newline(6)
|
||||||
|
|
||||||
|
text(margin, y, 12, true, "Begruendung der Rechtslage")
|
||||||
|
newline(lineH)
|
||||||
|
legal := "Soweit E-Mails einer gesetzlichen Aufbewahrungspflicht (GoBD/HGB/AO) " +
|
||||||
|
"unterliegen, ueberwiegt diese den Loeschanspruch nach Art. 17 DSGVO. " +
|
||||||
|
"Eine Loeschung erfolgt erst nach Ablauf der jeweiligen Aufbewahrungsfrist."
|
||||||
|
for _, l := range wrapText(legal, 90) {
|
||||||
|
text(margin, y, 9, false, l)
|
||||||
|
newline(smallLineH)
|
||||||
|
}
|
||||||
|
newline(6)
|
||||||
|
|
||||||
|
text(margin, y, 12, true, "Betroffene E-Mails")
|
||||||
|
newline(lineH)
|
||||||
|
for _, m := range sum.Mails {
|
||||||
|
if y < margin+30 {
|
||||||
|
text(margin, y, 8, false, "[... gekuerzt ...]")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dateStr := ""
|
||||||
|
if m.Date != nil {
|
||||||
|
dateStr = m.Date.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
subj := m.Subject
|
||||||
|
if len(subj) > 60 {
|
||||||
|
subj = subj[:60]
|
||||||
|
}
|
||||||
|
text(margin, y, 9, false, fmt.Sprintf("- [%s] %s", dateStr, subj))
|
||||||
|
newline(smallLineH)
|
||||||
|
text(margin+12, y, 8, false, m.Reason)
|
||||||
|
newline(smallLineH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStream := cs.String()
|
||||||
|
|
||||||
|
p.write("%PDF-1.4\n")
|
||||||
|
p.startObj(1)
|
||||||
|
p.write("<< /Type /Catalog /Pages 2 0 R >>\n")
|
||||||
|
p.endObj()
|
||||||
|
p.startObj(2)
|
||||||
|
p.writef("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 %.2f %.2f] >>\n", pageW, pageH)
|
||||||
|
p.endObj()
|
||||||
|
p.startObj(3)
|
||||||
|
p.writef("<< /Type /Page /Parent 2 0 R\n")
|
||||||
|
p.writef(" /Resources << /Font << /Helvetica << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n")
|
||||||
|
p.writef(" /Helvetica-Bold << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >>\n")
|
||||||
|
p.writef(" /Contents 4 0 R\n")
|
||||||
|
p.writef(" /MediaBox [0 0 %.2f %.2f]\n", pageW, pageH)
|
||||||
|
p.write(">>\n")
|
||||||
|
p.endObj()
|
||||||
|
p.startObj(4)
|
||||||
|
streamBytes := []byte("0.5 w\n" + contentStream)
|
||||||
|
p.writef("<< /Length %d >>\n", len(streamBytes))
|
||||||
|
p.write("stream\n")
|
||||||
|
p.buf.Write(streamBytes)
|
||||||
|
p.write("\nendstream\n")
|
||||||
|
p.endObj()
|
||||||
|
|
||||||
|
xrefOffset := p.buf.Len()
|
||||||
|
p.writef("xref\n0 5\n")
|
||||||
|
p.writef("0000000000 65535 f \n")
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
off := 0
|
||||||
|
if i < len(p.offsets) {
|
||||||
|
off = p.offsets[i]
|
||||||
|
}
|
||||||
|
p.writef("%010d 00000 n \n", off)
|
||||||
|
}
|
||||||
|
p.write("trailer\n")
|
||||||
|
p.writef("<< /Size 5 /Root 1 0 R >>\n")
|
||||||
|
p.write("startxref\n")
|
||||||
|
p.writef("%d\n", xrefOffset)
|
||||||
|
p.write("%%EOF\n")
|
||||||
|
|
||||||
|
return p.buf.Bytes()
|
||||||
|
}
|
||||||
@@ -150,3 +150,45 @@ func (s *Server) handleChangeEmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "email": req.Email})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "email": req.Email})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowedListPageSizes are the valid values for users.list_page_size (PROJ-53).
|
||||||
|
var allowedListPageSizes = map[int]bool{25: true, 50: true, 100: true, 200: true}
|
||||||
|
|
||||||
|
// handleChangePreferences allows a user to change their own UI preferences,
|
||||||
|
// currently just the number of list entries shown per page.
|
||||||
|
// PATCH /api/auth/preferences
|
||||||
|
func (s *Server) handleChangePreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if sess.UserID == 0 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ListPageSize int `json:"list_page_size"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedListPageSizes[req.ListPageSize] {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid list_page_size: must be 25, 50, 100 or 200")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("change_preferences: user not found", "err", err, "username", sess.Username)
|
||||||
|
writeError(w, http.StatusInternalServerError, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.users.UpdateListPageSize(r.Context(), user.ID, req.ListPageSize); err != nil {
|
||||||
|
s.logger.Error("change_preferences: update failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update preferences")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "list_page_size": req.ListPageSize})
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pageSize <= 0 {
|
if pageSize <= 0 {
|
||||||
pageSize = 25
|
pageSize = 25
|
||||||
}
|
}
|
||||||
|
if pageSize > 500 {
|
||||||
|
pageSize = 500
|
||||||
|
}
|
||||||
|
|
||||||
req := index.SearchRequest{
|
req := index.SearchRequest{
|
||||||
Query: q,
|
Query: q,
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
|
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
|
||||||
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
|
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
|
||||||
|
|
||||||
|
// PROJ-50: DSGVO Löschersuchen — admin (manage) / auditor (read-only),
|
||||||
|
// role enforced inside the handlers.
|
||||||
|
s.mux.HandleFunc("POST /api/admin/dsgvo", s.auth(s.handleCreateDSGVORequest))
|
||||||
|
s.mux.HandleFunc("GET /api/admin/dsgvo", s.auth(s.handleListDSGVORequests))
|
||||||
|
s.mux.HandleFunc("GET /api/admin/dsgvo/{id}", s.auth(s.handleGetDSGVORequest))
|
||||||
|
s.mux.HandleFunc("GET /api/admin/dsgvo/{id}/export", s.auth(s.handleExportDSGVORequest))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/dsgvo/{id}/delete", s.auth(s.handleDeleteDSGVOMails))
|
||||||
|
|
||||||
// SMTP-Out Relay Konfiguration — superadmin only
|
// SMTP-Out Relay Konfiguration — superadmin only
|
||||||
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))
|
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))
|
||||||
s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut)))
|
s.mux.HandleFunc("PUT /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSaveSMTPOut)))
|
||||||
@@ -292,6 +300,8 @@ func (s *Server) routes() {
|
|||||||
// PROJ-25: Profile routes (password & email change)
|
// PROJ-25: Profile routes (password & email change)
|
||||||
s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword))
|
s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword))
|
||||||
s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail))
|
s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail))
|
||||||
|
// PROJ-53: konfigurierbare Listenanzahl pro Seite
|
||||||
|
s.mux.HandleFunc("PATCH /api/auth/preferences", s.auth(s.handleChangePreferences))
|
||||||
|
|
||||||
// PROJ-24: TOTP 2FA routes
|
// PROJ-24: TOTP 2FA routes
|
||||||
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (s *Server) importRawMessage(ctx context.Context, raw []byte, tenantID *int
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, " "),
|
To: strings.Join(pm.To, " "),
|
||||||
|
CC: strings.Join(pm.CC, " "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody + " " + pm.HTMLBody,
|
Body: pm.TextBody + " " + pm.HTMLBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
EventExport = "export"
|
EventExport = "export"
|
||||||
EventUserMgmt = "user_mgmt"
|
EventUserMgmt = "user_mgmt"
|
||||||
EventOCRDownload = "mail:ocr_download" // PROJ-44: extracted OCR text downloaded
|
EventOCRDownload = "mail:ocr_download" // PROJ-44: extracted OCR text downloaded
|
||||||
|
EventDSGVORequest = "dsgvo_request" // PROJ-50: DSGVO Löschersuchen erfasst/bearbeitet
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry is a single audit log record.
|
// Entry is a single audit log record.
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, log *slog.Logger
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, ", "),
|
To: strings.Join(pm.To, ", "),
|
||||||
|
CC: strings.Join(pm.CC, ", "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody,
|
Body: pm.TextBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type MailDocument struct {
|
|||||||
ID string
|
ID string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
|
CC string // PROJ-50: CC addresses (space-joined) for DSGVO address search
|
||||||
|
BCC string // PROJ-50: BCC addresses (space-joined) for DSGVO address search
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
AttachNames string
|
AttachNames string
|
||||||
@@ -26,6 +28,7 @@ type SearchRequest struct {
|
|||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
OwnEmail string
|
OwnEmail string
|
||||||
|
AnyAddress string // PROJ-50: match address in from/to/cc/bcc (DSGVO Löschersuchen)
|
||||||
DateFrom *time.Time
|
DateFrom *time.Time
|
||||||
DateTo *time.Time
|
DateTo *time.Time
|
||||||
HasAttachment *bool // nil=no filter, true=only with, false=only without
|
HasAttachment *bool // nil=no filter, true=only with, false=only without
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ func (idx *manticoreIndex) ensureTable() error {
|
|||||||
subject text,
|
subject text,
|
||||||
from_addr text,
|
from_addr text,
|
||||||
to_addr text,
|
to_addr text,
|
||||||
|
cc_addr text,
|
||||||
|
bcc_addr text,
|
||||||
body text,
|
body text,
|
||||||
attachment_names text,
|
attachment_names text,
|
||||||
attachment_text text,
|
attachment_text text,
|
||||||
@@ -128,6 +130,15 @@ func (idx *manticoreIndex) ensureTable() error {
|
|||||||
if err := idx.ensureColumn("attachment_text", "text"); err != nil {
|
if err := idx.ensureColumn("attachment_text", "text"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// PROJ-50: ALTER existing tables to add cc_addr/bcc_addr so DSGVO address
|
||||||
|
// searches can match CC/BCC recipients. Existing mails need `archivmail
|
||||||
|
// reindex` to backfill these fields.
|
||||||
|
if err := idx.ensureColumn("cc_addr", "text"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := idx.ensureColumn("bcc_addr", "text"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +183,15 @@ func (idx *manticoreIndex) IndexSync(doc MailDocument) error {
|
|||||||
|
|
||||||
_, err := idx.db.Exec(
|
_, err := idx.db.Exec(
|
||||||
fmt.Sprintf(`REPLACE INTO %s
|
fmt.Sprintf(`REPLACE INTO %s
|
||||||
(id, mail_id, subject, from_addr, to_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes)
|
(id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`, idx.table),
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table),
|
||||||
rowID,
|
rowID,
|
||||||
doc.ID,
|
doc.ID,
|
||||||
doc.Subject,
|
doc.Subject,
|
||||||
doc.From,
|
doc.From,
|
||||||
doc.To,
|
doc.To,
|
||||||
|
doc.CC,
|
||||||
|
doc.BCC,
|
||||||
doc.Body,
|
doc.Body,
|
||||||
doc.AttachNames,
|
doc.AttachNames,
|
||||||
doc.AttachmentText,
|
doc.AttachmentText,
|
||||||
@@ -202,24 +215,24 @@ func (idx *manticoreIndex) IndexSync(doc MailDocument) error {
|
|||||||
func (idx *manticoreIndex) UpdateAttachmentText(mailID, text string) error {
|
func (idx *manticoreIndex) UpdateAttachmentText(mailID, text string) error {
|
||||||
rowID := hashMailID(mailID)
|
rowID := hashMailID(mailID)
|
||||||
row := idx.db.QueryRow(fmt.Sprintf(
|
row := idx.db.QueryRow(fmt.Sprintf(
|
||||||
`SELECT mail_id, subject, from_addr, to_addr, body, attachment_names,
|
`SELECT mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names,
|
||||||
has_attachment, date_ts, size_bytes
|
has_attachment, date_ts, size_bytes
|
||||||
FROM %s WHERE id = ? LIMIT 1`, idx.table),
|
FROM %s WHERE id = ? LIMIT 1`, idx.table),
|
||||||
rowID,
|
rowID,
|
||||||
)
|
)
|
||||||
var (
|
var (
|
||||||
mid, subj, from, to, body, attachNames string
|
mid, subj, from, to, cc, bcc, body, attachNames string
|
||||||
hasAttach uint64
|
hasAttach uint64
|
||||||
dateTS, sizeBytes int64
|
dateTS, sizeBytes int64
|
||||||
)
|
)
|
||||||
if err := row.Scan(&mid, &subj, &from, &to, &body, &attachNames, &hasAttach, &dateTS, &sizeBytes); err != nil {
|
if err := row.Scan(&mid, &subj, &from, &to, &cc, &bcc, &body, &attachNames, &hasAttach, &dateTS, &sizeBytes); err != nil {
|
||||||
return fmt.Errorf("manticore UpdateAttachmentText %s: load row: %w", idx.table, err)
|
return fmt.Errorf("manticore UpdateAttachmentText %s: load row: %w", idx.table, err)
|
||||||
}
|
}
|
||||||
if _, err := idx.db.Exec(
|
if _, err := idx.db.Exec(
|
||||||
fmt.Sprintf(`REPLACE INTO %s
|
fmt.Sprintf(`REPLACE INTO %s
|
||||||
(id, mail_id, subject, from_addr, to_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes)
|
(id, mail_id, subject, from_addr, to_addr, cc_addr, bcc_addr, body, attachment_names, attachment_text, has_attachment, date_ts, size_bytes)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`, idx.table),
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, idx.table),
|
||||||
rowID, mid, subj, from, to, body, attachNames, text, hasAttach, dateTS, sizeBytes,
|
rowID, mid, subj, from, to, cc, bcc, body, attachNames, text, hasAttach, dateTS, sizeBytes,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("manticore UpdateAttachmentText %s: replace: %w", idx.table, err)
|
return fmt.Errorf("manticore UpdateAttachmentText %s: replace: %w", idx.table, err)
|
||||||
}
|
}
|
||||||
@@ -276,6 +289,9 @@ func (idx *manticoreIndex) Search(req SearchRequest) (*SearchResult, error) {
|
|||||||
if req.OwnEmail != "" {
|
if req.OwnEmail != "" {
|
||||||
matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr) %s", escapeManticoreMatch(req.OwnEmail)))
|
matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr) %s", escapeManticoreMatch(req.OwnEmail)))
|
||||||
}
|
}
|
||||||
|
if req.AnyAddress != "" {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr,cc_addr,bcc_addr) %s", escapeManticoreMatch(req.AnyAddress)))
|
||||||
|
}
|
||||||
|
|
||||||
hasMatch := len(matchParts) > 0
|
hasMatch := len(matchParts) > 0
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ func (imp *Importer) storeAndIndex(raw []byte, log *slog.Logger) error {
|
|||||||
ID: id,
|
ID: id,
|
||||||
From: pm.From,
|
From: pm.From,
|
||||||
To: strings.Join(pm.To, ", "),
|
To: strings.Join(pm.To, ", "),
|
||||||
|
CC: strings.Join(pm.CC, ", "),
|
||||||
Subject: pm.Subject,
|
Subject: pm.Subject,
|
||||||
Body: pm.TextBody,
|
Body: pm.TextBody,
|
||||||
AttachNames: strings.Join(attachNames, " "),
|
AttachNames: strings.Join(attachNames, " "),
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DSGVO request status values (PROJ-50).
|
||||||
|
const (
|
||||||
|
DSGVOStatusOpen = "open" // angelegt, noch nicht verarbeitet
|
||||||
|
DSGVOStatusPartial = "partial" // teilweise abgelehnt (Mischung)
|
||||||
|
DSGVOStatusCompleted = "completed" // abgeschlossen (0 Treffer, alle löschbar/abgelehnt, oder gelöscht)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DSGVOAffectedMail is a single mail affected by a DSGVO erasure request.
|
||||||
|
type DSGVOAffectedMail struct {
|
||||||
|
MailID string `json:"mail_id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date *time.Time `json:"date,omitempty"`
|
||||||
|
RetainUntil *time.Time `json:"retain_until,omitempty"`
|
||||||
|
Deletable bool `json:"deletable"` // true wenn retain_until abgelaufen/nicht gesetzt
|
||||||
|
Deleted bool `json:"deleted"` // true wenn bereits über Löschmechanismus entfernt
|
||||||
|
Reason string `json:"reason,omitempty"` // z.B. "Aufbewahrungspflicht bis 2030-12-31"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVOResultSummary is the aggregated outcome stored as JSON on the request.
|
||||||
|
type DSGVOResultSummary struct {
|
||||||
|
TotalHits int `json:"total_hits"`
|
||||||
|
Rejected int `json:"rejected"` // gesetzlich gesperrt
|
||||||
|
Deletable int `json:"deletable"` // löschbar
|
||||||
|
Deleted int `json:"deleted"` // bereits gelöscht
|
||||||
|
Truncated bool `json:"truncated"` // Treffer über Limit, Ergebnis unvollständig
|
||||||
|
Mails []DSGVOAffectedMail `json:"mails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVORequest represents a documented DSGVO erasure request (Art. 17).
|
||||||
|
type DSGVORequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TenantID *int64 `json:"tenant_id"`
|
||||||
|
RequestedAddress string `json:"requested_address"`
|
||||||
|
RequestedBy string `json:"requested_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResultSummary *DSGVOResultSummary `json:"result_summary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDSGVOSchema creates the dsgvo_requests table. Safe to call repeatedly.
|
||||||
|
func (s *Store) initDSGVOSchema(ctx context.Context) {
|
||||||
|
_, _ = s.db.Exec(ctx, `CREATE TABLE IF NOT EXISTS dsgvo_requests (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_id BIGINT,
|
||||||
|
requested_address TEXT NOT NULL,
|
||||||
|
requested_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
result_summary JSONB
|
||||||
|
)`)
|
||||||
|
_, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_dsgvo_requests_tenant ON dsgvo_requests (tenant_id)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDSGVORequest inserts a new request and returns its generated ID.
|
||||||
|
func (s *Store) CreateDSGVORequest(ctx context.Context, tenantID *int64, address, requestedBy string) (*DSGVORequest, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, fmt.Errorf("storage: no db")
|
||||||
|
}
|
||||||
|
req := &DSGVORequest{
|
||||||
|
TenantID: tenantID,
|
||||||
|
RequestedAddress: address,
|
||||||
|
RequestedBy: requestedBy,
|
||||||
|
Status: DSGVOStatusOpen,
|
||||||
|
}
|
||||||
|
err := s.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO dsgvo_requests (tenant_id, requested_address, requested_by, status)
|
||||||
|
VALUES ($1,$2,$3,$4) RETURNING id, created_at`,
|
||||||
|
tenantID, address, requestedBy, DSGVOStatusOpen,
|
||||||
|
).Scan(&req.ID, &req.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: create dsgvo request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDSGVOResult persists the computed result summary and status.
|
||||||
|
func (s *Store) UpdateDSGVOResult(ctx context.Context, id int64, status string, summary *DSGVOResultSummary) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return fmt.Errorf("storage: no db")
|
||||||
|
}
|
||||||
|
js, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: marshal dsgvo summary: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(ctx,
|
||||||
|
`UPDATE dsgvo_requests SET status=$1, result_summary=$2 WHERE id=$3`,
|
||||||
|
status, js, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: update dsgvo result: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDSGVORequests returns all requests in the given tenant scope, newest first.
|
||||||
|
// A nil tenantID returns requests with NULL tenant_id (global/superadmin scope).
|
||||||
|
func (s *Store) ListDSGVORequests(ctx context.Context, tenantID *int64) ([]DSGVORequest, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, fmt.Errorf("storage: no db")
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
query string
|
||||||
|
args []interface{}
|
||||||
|
)
|
||||||
|
if tenantID == nil {
|
||||||
|
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
|
||||||
|
FROM dsgvo_requests WHERE tenant_id IS NULL ORDER BY created_at DESC`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
|
||||||
|
FROM dsgvo_requests WHERE tenant_id = $1 ORDER BY created_at DESC`
|
||||||
|
args = append(args, *tenantID)
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: list dsgvo requests: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DSGVORequest
|
||||||
|
for rows.Next() {
|
||||||
|
req, err := scanDSGVORow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *req)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDSGVORequest returns a single request by ID within the given tenant scope.
|
||||||
|
// Returns an error if the request does not exist or belongs to another tenant.
|
||||||
|
func (s *Store) GetDSGVORequest(ctx context.Context, id int64, tenantID *int64) (*DSGVORequest, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, fmt.Errorf("storage: no db")
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
query string
|
||||||
|
args []interface{}
|
||||||
|
)
|
||||||
|
if tenantID == nil {
|
||||||
|
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
|
||||||
|
FROM dsgvo_requests WHERE id=$1 AND tenant_id IS NULL`
|
||||||
|
args = append(args, id)
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, tenant_id, requested_address, requested_by, created_at, status, result_summary
|
||||||
|
FROM dsgvo_requests WHERE id=$1 AND tenant_id=$2`
|
||||||
|
args = append(args, id, *tenantID)
|
||||||
|
}
|
||||||
|
row := s.db.QueryRow(ctx, query, args...)
|
||||||
|
return scanDSGVORow(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVOMailMeta holds the metadata needed to evaluate a DSGVO request per mail.
|
||||||
|
type DSGVOMailMeta struct {
|
||||||
|
Subject string
|
||||||
|
ReceivedAt time.Time
|
||||||
|
RetainUntil *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDSGVOMailMeta batch-loads subject, received_at and retain_until for the
|
||||||
|
// given mail IDs. Missing IDs are omitted. Used by the DSGVO workflow to avoid
|
||||||
|
// loading and parsing every raw mail file.
|
||||||
|
func (s *Store) GetDSGVOMailMeta(ctx context.Context, ids []string) (map[string]DSGVOMailMeta, error) {
|
||||||
|
if s.db == nil || len(ids) == 0 {
|
||||||
|
return map[string]DSGVOMailMeta{}, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(ctx,
|
||||||
|
`SELECT id, subject, received_at, retain_until FROM emails WHERE id = ANY($1)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: dsgvo mail meta: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]DSGVOMailMeta, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
subject *string
|
||||||
|
recv time.Time
|
||||||
|
until *time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &subject, &recv, &until); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: scan dsgvo mail meta: %w", err)
|
||||||
|
}
|
||||||
|
m := DSGVOMailMeta{ReceivedAt: recv, RetainUntil: until}
|
||||||
|
if subject != nil {
|
||||||
|
m.Subject = *subject
|
||||||
|
}
|
||||||
|
out[id] = m
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type dsgvoScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDSGVORow(row dsgvoScanner) (*DSGVORequest, error) {
|
||||||
|
var (
|
||||||
|
req DSGVORequest
|
||||||
|
js []byte
|
||||||
|
)
|
||||||
|
if err := row.Scan(&req.ID, &req.TenantID, &req.RequestedAddress, &req.RequestedBy,
|
||||||
|
&req.CreatedAt, &req.Status, &js); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: scan dsgvo request: %w", err)
|
||||||
|
}
|
||||||
|
if len(js) > 0 {
|
||||||
|
var sum DSGVOResultSummary
|
||||||
|
if err := json.Unmarshal(js, &sum); err == nil {
|
||||||
|
req.ResultSummary = &sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
@@ -107,6 +107,8 @@ func New(cfg Config) (*Store, error) {
|
|||||||
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`)
|
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`)
|
||||||
// PROJ-51: archiving_rules table + retain_until_source column
|
// PROJ-51: archiving_rules table + retain_until_source column
|
||||||
s.initRetentionRulesSchema(ctx)
|
s.initRetentionRulesSchema(ctx)
|
||||||
|
// PROJ-50: DSGVO Löschersuchen
|
||||||
|
s.initDSGVOSchema(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type User struct {
|
|||||||
TOTPEnabled bool `json:"totp_enabled"`
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
TOTPResetAt *time.Time `json:"totp_reset_at,omitempty"`
|
TOTPResetAt *time.Time `json:"totp_reset_at,omitempty"`
|
||||||
TOTPResetBy *string `json:"totp_reset_by,omitempty"`
|
TOTPResetBy *string `json:"totp_reset_by,omitempty"`
|
||||||
|
ListPageSize int `json:"list_page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUserRequest holds parameters for creating a new user.
|
// CreateUserRequest holds parameters for creating a new user.
|
||||||
@@ -106,6 +107,14 @@ func (s *Store) initSchema(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// PROJ-46: login_attempts.username auf VARCHAR(255) erweitern (passend zu users.email),
|
||||||
|
// damit lange E-Mail-Adressen als Login-Identifier nicht abgeschnitten werden.
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
ALTER TABLE login_attempts ALTER COLUMN username TYPE VARCHAR(255);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// PROJ-24: TOTP 2FA columns
|
// PROJ-24: TOTP 2FA columns
|
||||||
_, err = s.pool.Exec(ctx, `
|
_, err = s.pool.Exec(ctx, `
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret BYTEA;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret BYTEA;
|
||||||
@@ -113,6 +122,13 @@ func (s *Store) initSchema(ctx context.Context) error {
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_at TIMESTAMPTZ;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_at TIMESTAMPTZ;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_by TEXT;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_by TEXT;
|
||||||
`)
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// PROJ-53: konfigurierbare Listenanzahl pro Seite (25/50/100/200, Default 25)
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS list_page_size INT NOT NULL DEFAULT 25;
|
||||||
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +196,7 @@ func (s *Store) Activate(ctx context.Context, id int64) error {
|
|||||||
// GetByEmail retrieves a user by email address.
|
// GetByEmail retrieves a user by email address.
|
||||||
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
|
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE email = $1`, email,
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE email = $1`, email,
|
||||||
)
|
)
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
@@ -199,7 +215,7 @@ func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) e
|
|||||||
func (s *Store) GetByID(id int64) (*User, error) {
|
func (s *Store) GetByID(id int64) (*User, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE id = $1`, id,
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE id = $1`, id,
|
||||||
)
|
)
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
@@ -208,7 +224,7 @@ func (s *Store) GetByID(id int64) (*User, error) {
|
|||||||
func (s *Store) GetByUsername(username string) (*User, error) {
|
func (s *Store) GetByUsername(username string) (*User, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE username = $1`, username,
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE username = $1`, username,
|
||||||
)
|
)
|
||||||
return scanUser(row)
|
return scanUser(row)
|
||||||
}
|
}
|
||||||
@@ -218,13 +234,13 @@ func (s *Store) GetByUsername(username string) (*User, error) {
|
|||||||
func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, password_hash FROM users WHERE username = $1`,
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash FROM users WHERE username = $1`,
|
||||||
username,
|
username,
|
||||||
)
|
)
|
||||||
|
|
||||||
var u User
|
var u User
|
||||||
var hash string
|
var hash string
|
||||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &hash)
|
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize, &hash)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errors.New("userstore: user not found")
|
return nil, errors.New("userstore: user not found")
|
||||||
}
|
}
|
||||||
@@ -296,10 +312,10 @@ func (s *Store) List(role string) ([]*User, error) {
|
|||||||
|
|
||||||
if role == "" {
|
if role == "" {
|
||||||
rows, err = s.pool.Query(ctx,
|
rows, err = s.pool.Query(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users ORDER BY id`)
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users ORDER BY id`)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.pool.Query(ctx,
|
rows, err = s.pool.Query(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE role = $1 ORDER BY id`, role)
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE role = $1 ORDER BY id`, role)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("userstore: list: %w", err)
|
return nil, fmt.Errorf("userstore: list: %w", err)
|
||||||
@@ -320,7 +336,7 @@ func (s *Store) List(role string) ([]*User, error) {
|
|||||||
// ListByTenant returns all users belonging to a specific tenant.
|
// ListByTenant returns all users belonging to a specific tenant.
|
||||||
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
|
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE tenant_id = $1 ORDER BY id`,
|
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size FROM users WHERE tenant_id = $1 ORDER BY id`,
|
||||||
tenantID,
|
tenantID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -438,10 +454,10 @@ func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*
|
|||||||
active = true,
|
active = true,
|
||||||
tenant_id = COALESCE($3, tenant_id)
|
tenant_id = COALESCE($3, tenant_id)
|
||||||
WHERE email = $4
|
WHERE email = $4
|
||||||
RETURNING id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by
|
RETURNING id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size
|
||||||
`, username, role, tenantID, email).Scan(
|
`, username, role, tenantID, email).Scan(
|
||||||
&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active,
|
&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active,
|
||||||
&u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy,
|
&u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &u, nil
|
return &u, nil
|
||||||
@@ -466,7 +482,7 @@ func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*
|
|||||||
|
|
||||||
func scanUser(row pgx.Row) (*User, error) {
|
func scanUser(row pgx.Row) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy)
|
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("userstore: not found")
|
return nil, fmt.Errorf("userstore: not found")
|
||||||
}
|
}
|
||||||
@@ -478,7 +494,7 @@ func scanUser(row pgx.Row) (*User, error) {
|
|||||||
|
|
||||||
func scanUserRow(rows pgx.Rows) (*User, error) {
|
func scanUserRow(rows pgx.Rows) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy); err != nil {
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize); err != nil {
|
||||||
return nil, fmt.Errorf("userstore: scan row: %w", err)
|
return nil, fmt.Errorf("userstore: scan row: %w", err)
|
||||||
}
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
@@ -555,6 +571,16 @@ func (s *Store) UpdateEmail(ctx context.Context, userID int64, email string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateListPageSize sets the number of list entries per page for the given user.
|
||||||
|
// Validation of allowed values (25/50/100/200) happens in the API handler.
|
||||||
|
func (s *Store) UpdateListPageSize(ctx context.Context, userID int64, pageSize int) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE users SET list_page_size = $1 WHERE id = $2`, pageSize, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("userstore: update list page size: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
|
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
|
||||||
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
|
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
|
||||||
err = s.pool.QueryRow(ctx,
|
err = s.pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
|
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -86,6 +86,8 @@ export default function SearchPage() {
|
|||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pageSize = user?.list_page_size ?? DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [fromFilter, setFromFilter] = useState("");
|
const [fromFilter, setFromFilter] = useState("");
|
||||||
const [toFilter, setToFilter] = useState("");
|
const [toFilter, setToFilter] = useState("");
|
||||||
@@ -143,7 +145,7 @@ export default function SearchPage() {
|
|||||||
sort: sort !== "date_desc" ? sort : undefined,
|
sort: sort !== "date_desc" ? sort : undefined,
|
||||||
has_attachment: hasAttachment,
|
has_attachment: hasAttachment,
|
||||||
page: p,
|
page: p,
|
||||||
page_size: PAGE_SIZE,
|
page_size: pageSize,
|
||||||
});
|
});
|
||||||
setResults(res.hits || []);
|
setResults(res.hits || []);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
@@ -156,7 +158,7 @@ export default function SearchPage() {
|
|||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
|
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Superadmin has no mail access — redirect to admin dashboard
|
// Superadmin has no mail access — redirect to admin dashboard
|
||||||
@@ -170,7 +172,7 @@ export default function SearchPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === "superadmin") return;
|
if (!user || user.role === "superadmin") return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
searchEmails({ page: 1, page_size: PAGE_SIZE })
|
searchEmails({ page: 1, page_size: pageSize })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setResults(res.hits || []);
|
setResults(res.hits || []);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
@@ -182,7 +184,7 @@ export default function SearchPage() {
|
|||||||
setTotal(0);
|
setTotal(0);
|
||||||
})
|
})
|
||||||
.finally(() => setSearching(false));
|
.finally(() => setSearching(false));
|
||||||
}, [user]);
|
}, [user, pageSize]);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -339,7 +341,7 @@ export default function SearchPage() {
|
|||||||
date_to: s.query.date_to || undefined,
|
date_to: s.query.date_to || undefined,
|
||||||
has_attachment: s.query.has_attachment === "true" ? true : undefined,
|
has_attachment: s.query.has_attachment === "true" ? true : undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: PAGE_SIZE,
|
page_size: pageSize,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setResults(res.hits || []);
|
setResults(res.hits || []);
|
||||||
@@ -364,7 +366,7 @@ export default function SearchPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Server, Lock, Info } from "lucide-react";
|
import { Server, Lock, Info } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
changeEmail,
|
changeEmail,
|
||||||
|
updatePreferences,
|
||||||
getTOTPSetup,
|
getTOTPSetup,
|
||||||
confirmTOTPSetup,
|
confirmTOTPSetup,
|
||||||
disableTOTP,
|
disableTOTP,
|
||||||
@@ -28,7 +36,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading, refresh } = useAuth();
|
||||||
|
|
||||||
// ── Password state ────────────────────────────────────────────────────
|
// ── Password state ────────────────────────────────────────────────────
|
||||||
const [currentPw, setCurrentPw] = useState("");
|
const [currentPw, setCurrentPw] = useState("");
|
||||||
@@ -59,6 +67,13 @@ export default function SettingsPage() {
|
|||||||
const [disableError, setDisableError] = useState("");
|
const [disableError, setDisableError] = useState("");
|
||||||
const [disableLoading, setDisableLoading] = useState(false);
|
const [disableLoading, setDisableLoading] = useState(false);
|
||||||
|
|
||||||
|
// ── Listenanzahl (Eintraege pro Seite) ────────────────────────────────
|
||||||
|
const [listPageSize, setListPageSize] = useState("25");
|
||||||
|
const [listPageSizeInitialized, setListPageSizeInitialized] = useState(false);
|
||||||
|
const [listPageSizeError, setListPageSizeError] = useState("");
|
||||||
|
const [listPageSizeSuccess, setListPageSizeSuccess] = useState("");
|
||||||
|
const [listPageSizeLoading, setListPageSizeLoading] = useState(false);
|
||||||
|
|
||||||
// ── System info (FQDN + IMAP-Ports) ───────────────────────────────────
|
// ── System info (FQDN + IMAP-Ports) ───────────────────────────────────
|
||||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||||
const [systemInfoLoading, setSystemInfoLoading] = useState(true);
|
const [systemInfoLoading, setSystemInfoLoading] = useState(true);
|
||||||
@@ -87,6 +102,12 @@ export default function SettingsPage() {
|
|||||||
setEmailInitialized(true);
|
setEmailInitialized(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize list page size from user data once available
|
||||||
|
if (user && !listPageSizeInitialized) {
|
||||||
|
setListPageSize(String(user.list_page_size || 25));
|
||||||
|
setListPageSizeInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleChangePassword(e: React.FormEvent) {
|
async function handleChangePassword(e: React.FormEvent) {
|
||||||
@@ -139,6 +160,24 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleChangeListPageSize(value: string) {
|
||||||
|
setListPageSizeError("");
|
||||||
|
setListPageSizeSuccess("");
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
setListPageSizeLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await updatePreferences(parsed);
|
||||||
|
setListPageSize(String(result.list_page_size));
|
||||||
|
setListPageSizeSuccess("Eintraege pro Seite wurden gespeichert.");
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setListPageSizeError(err instanceof Error ? err.message : "Fehler beim Speichern");
|
||||||
|
} finally {
|
||||||
|
setListPageSizeLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSetupTOTP() {
|
async function handleSetupTOTP() {
|
||||||
setTotpError("");
|
setTotpError("");
|
||||||
setTotpSuccess("");
|
setTotpSuccess("");
|
||||||
@@ -312,6 +351,43 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Card 2b: Listenanzahl ─────────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Listenansicht</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="list-page-size">Eintraege pro Seite</Label>
|
||||||
|
<Select
|
||||||
|
value={listPageSize}
|
||||||
|
onValueChange={handleChangeListPageSize}
|
||||||
|
disabled={listPageSizeLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="list-page-size" className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
<SelectItem value="200">200</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{listPageSizeError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{listPageSizeError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{listPageSizeSuccess && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{listPageSizeSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */}
|
{/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
+11
-1
@@ -58,5 +58,15 @@ export function useAuth(requireRole?: "admin" | "domain_admin" | "superadmin" |
|
|||||||
checkAuth();
|
checkAuth();
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
return { user, loading };
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await getMe();
|
||||||
|
setCachedUser(me);
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
// ignore refresh errors, keep existing cached user
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export {
|
|||||||
logout,
|
logout,
|
||||||
changePassword,
|
changePassword,
|
||||||
changeEmail,
|
changeEmail,
|
||||||
|
updatePreferences,
|
||||||
getUsers,
|
getUsers,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface MeResponse {
|
|||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
list_page_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
@@ -82,6 +83,15 @@ export async function changeEmail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePreferences(
|
||||||
|
listPageSize: number
|
||||||
|
): Promise<{ ok: boolean; list_page_size: number }> {
|
||||||
|
return request<{ ok: boolean; list_page_size: number }>("/api/auth/preferences", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ list_page_size: listPageSize }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── User management functions ─────────────────────────────────────────────────
|
// ── User management functions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getUsers(): Promise<User[]> {
|
export async function getUsers(): Promise<User[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user