security: Zufallspasswörter beim Erststart, kryptographisch sichere JTI-Generierung
- seedDefaultUsers: generiert kryptographisch zufällige Passwörter (crypto/rand) statt hartkodiertes "archivmailrockz" — Passwörter werden einmalig im Terminal angezeigt und können danach nicht wiederhergestellt werden - generateJTI: verwendet crypto/rand (16 Byte, hex) statt time.UnixNano XOR deadbeef Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -34,6 +34,26 @@ yarn-error.log*
|
|||||||
# claude code personal settings
|
# claude code personal settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# AI Coding Starter Kit – Workflow-Dateien (nicht Teil des Projekts)
|
||||||
|
.claude/settings.json
|
||||||
|
.claude/skills/
|
||||||
|
.claude/agents/
|
||||||
|
.claude/rules/
|
||||||
|
|
||||||
|
# AI Coding Starter Kit – Template-Docs
|
||||||
|
docs/production/
|
||||||
|
features/README.md
|
||||||
|
|
||||||
|
# AI Coding Starter Kit – Supabase Template (nicht verwendet)
|
||||||
|
src/lib/supabase.ts
|
||||||
|
|
||||||
|
# Next.js Default-Assets (AI Coding Starter Kit)
|
||||||
|
public/file.svg
|
||||||
|
public/globe.svg
|
||||||
|
public/next.svg
|
||||||
|
public/vercel.svg
|
||||||
|
public/window.svg
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|||||||
+38
-4
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -321,6 +323,8 @@ func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.L
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
|
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
|
||||||
|
// Passwords are randomly generated and printed once to stdout — there is no way to
|
||||||
|
// recover them afterwards; they must be changed immediately after the first login.
|
||||||
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
||||||
all, err := users.List("")
|
all, err := users.List("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,16 +333,46 @@ func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
|||||||
if len(all) > 0 {
|
if len(all) > 0 {
|
||||||
return nil // already seeded
|
return nil // already seeded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adminPw, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate admin password: %w", err)
|
||||||
|
}
|
||||||
|
auditorPw, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate auditor password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
defaults := []userstore.CreateUserRequest{
|
defaults := []userstore.CreateUserRequest{
|
||||||
{Username: "admin", Email: "admin@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAdmin},
|
{Username: "admin", Email: "admin@archivmail.local", Password: adminPw, Role: userstore.RoleAdmin},
|
||||||
{Username: "auditor", Email: "auditor@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAuditor},
|
{Username: "auditor", Email: "auditor@archivmail.local", Password: auditorPw, Role: userstore.RoleAuditor},
|
||||||
}
|
}
|
||||||
for _, req := range defaults {
|
for _, req := range defaults {
|
||||||
if _, err := users.Create(req); err != nil {
|
if _, err := users.Create(req); err != nil {
|
||||||
return fmt.Errorf("create default user %s: %w", req.Username, err)
|
return fmt.Errorf("create default user %s: %w", req.Username, err)
|
||||||
}
|
}
|
||||||
logger.Info("created default user", "username", req.Username, "role", req.Role)
|
|
||||||
}
|
}
|
||||||
logger.Warn("default users created — change passwords immediately!", "admin", "admin", "auditor", "auditor")
|
|
||||||
|
// Print credentials prominently — this is the only time they are visible.
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("╔══════════════════════════════════════════════════════════════╗")
|
||||||
|
fmt.Println("║ ARCHIVMAIL — ERSTMALIGE EINRICHTUNG ║")
|
||||||
|
fmt.Println("║ Initiale Zugangsdaten (NUR EINMAL ANGEZEIGT): ║")
|
||||||
|
fmt.Printf( "║ admin : %-50s ║\n", adminPw)
|
||||||
|
fmt.Printf( "║ auditor : %-50s ║\n", auditorPw)
|
||||||
|
fmt.Println("║ Passwörter sofort nach dem ersten Login ändern! ║")
|
||||||
|
fmt.Println("╚══════════════════════════════════════════════════════════════╝")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
logger.Warn("default users created — change passwords immediately!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// randomPassword generates a cryptographically random 16-byte hex password.
|
||||||
|
func randomPassword() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|||||||
+7
-7
@@ -12,25 +12,25 @@
|
|||||||
|
|
||||||
| ID | Feature | Status | Spec | Created |
|
| ID | Feature | Status | Spec | Created |
|
||||||
|----|---------|--------|------|---------|
|
|----|---------|--------|------|---------|
|
||||||
| PROJ-1 | Nutzer-Authentifizierung & Rollen (User/Admin) | In Progress | [PROJ-1](PROJ-1-authentifizierung-und-rollen.md) | 2026-03-12 |
|
| PROJ-1 | Nutzer-Authentifizierung & Rollen (User/Admin) | Deployed | [PROJ-1](PROJ-1-authentifizierung-und-rollen.md) | 2026-03-12 |
|
||||||
| PROJ-2 | E-Mail-Import: EML/MBOX Upload | In Progress | [PROJ-2](PROJ-2-import-eml-mbox.md) | 2026-03-12 |
|
| PROJ-2 | E-Mail-Import: EML/MBOX Upload | In Progress | [PROJ-2](PROJ-2-import-eml-mbox.md) | 2026-03-12 |
|
||||||
| PROJ-3 | E-Mail-Import: IMAP-Verbindung | In Progress | [PROJ-3](PROJ-3-import-imap.md) | 2026-03-12 |
|
| PROJ-3 | E-Mail-Import: IMAP-Verbindung | In Progress | [PROJ-3](PROJ-3-import-imap.md) | 2026-03-12 |
|
||||||
| PROJ-4 | E-Mail-Import: SMTP-Eingang via BCC (primär) | In Progress | [PROJ-4](PROJ-4-import-smtp.md) | 2026-03-12 |
|
| PROJ-4 | E-Mail-Import: SMTP-Eingang via BCC (primär) | In Progress | [PROJ-4](PROJ-4-import-smtp.md) | 2026-03-12 |
|
||||||
| PROJ-5 | E-Mail-Speicherung & Volltext-Indexierung | In Review | [PROJ-5](PROJ-5-speicherung-und-indexierung.md) | 2026-03-12 |
|
| PROJ-5 | E-Mail-Speicherung & Volltext-Indexierung | Deployed | [PROJ-5](PROJ-5-speicherung-und-indexierung.md) | 2026-03-12 |
|
||||||
| PROJ-6 | Volltext-Suche & Filterung | In Progress | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 |
|
| PROJ-6 | Volltext-Suche & Filterung | In Review | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 |
|
||||||
| PROJ-7 | E-Mail-Ansicht (Lesen & Anhänge) | In Progress | [PROJ-7](PROJ-7-email-ansicht.md) | 2026-03-12 |
|
| PROJ-7 | E-Mail-Ansicht (Lesen & Anhänge) | In Progress | [PROJ-7](PROJ-7-email-ansicht.md) | 2026-03-12 |
|
||||||
| PROJ-8 | Automatischer IMAP-Sync (Cron-Job) | In Progress | [PROJ-8](PROJ-8-imap-auto-sync.md) | 2026-03-12 |
|
| PROJ-8 | Automatischer IMAP-Sync (Cron-Job) | In Progress | [PROJ-8](PROJ-8-imap-auto-sync.md) | 2026-03-12 |
|
||||||
| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 |
|
| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 |
|
||||||
| PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | In Progress | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 |
|
| PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | In Progress | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 |
|
||||||
| PROJ-11 | Audit-Log & Compliance-Berichte | In Progress | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 |
|
| PROJ-11 | Audit-Log & Compliance-Berichte | In Progress | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 |
|
||||||
| PROJ-12 | E-Mail-Export (EML/PDF) | In Review | [PROJ-12](PROJ-12-export.md) | 2026-03-12 |
|
| PROJ-12 | E-Mail-Export (EML/PDF) | Deployed | [PROJ-12](PROJ-12-export.md) | 2026-03-12 |
|
||||||
| PROJ-13 | REST API für externe CRM-Anbindung | In Progress | [PROJ-13](PROJ-13-rest-api-crm.md) | 2026-03-13 |
|
| PROJ-13 | REST API für externe CRM-Anbindung | In Progress | [PROJ-13](PROJ-13-rest-api-crm.md) | 2026-03-13 |
|
||||||
| PROJ-14 | E-Mail-Import: POP3-Verbindung | In Progress | [PROJ-14](PROJ-14-import-pop3.md) | 2026-03-13 |
|
| PROJ-14 | E-Mail-Import: POP3-Verbindung | In Progress | [PROJ-14](PROJ-14-import-pop3.md) | 2026-03-13 |
|
||||||
| PROJ-15 | CLI Import & Export (archivmail-User) | In Review | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 |
|
| PROJ-15 | CLI Import & Export (archivmail-User) | Deployed | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 |
|
||||||
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
|
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
|
||||||
|
|
||||||
| PROJ-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | In Review | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 |
|
| PROJ-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | Deployed | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 |
|
||||||
| PROJ-18 | E-Mail Integritätsprüfung | In Progress | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
|
| PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-1: Nutzer-Authentifizierung & Rollen
|
# PROJ-1: Nutzer-Authentifizierung & Rollen
|
||||||
|
|
||||||
## Status: In Progress
|
## Status: Deployed
|
||||||
**Created:** 2026-03-12
|
**Created:** 2026-03-12
|
||||||
**Last Updated:** 2026-03-12
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- PROJ-16 (LDAP / Active Directory Anbindung) — optionale Erweiterung des Login-Flows
|
- PROJ-16 (LDAP / Active Directory Anbindung) — optionale Erweiterung des Login-Flows
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-12: E-Mail-Export (EML / PDF)
|
# PROJ-12: E-Mail-Export (EML / PDF)
|
||||||
|
|
||||||
## Status: In Review
|
## Status: Deployed
|
||||||
**Created:** 2026-03-12
|
**Created:** 2026-03-12
|
||||||
**Last Updated:** 2026-03-14
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-1 (Authentifizierung)
|
- Requires: PROJ-1 (Authentifizierung)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-15: CLI Import & Export
|
# PROJ-15: CLI Import & Export
|
||||||
|
|
||||||
## Status: In Review
|
## Status: Deployed
|
||||||
**Created:** 2026-03-13
|
**Created:** 2026-03-13
|
||||||
**Last Updated:** 2026-03-13
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-5 (Speicherung & Indexierung) – Import nutzt Storage Coordinator
|
- Requires: PROJ-5 (Speicherung & Indexierung) – Import nutzt Storage Coordinator
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-17: Admin Dashboard – Systemauslastung & Archiv-Übersicht
|
# PROJ-17: Admin Dashboard – Systemauslastung & Archiv-Übersicht
|
||||||
|
|
||||||
## Status: In Review
|
## Status: Deployed
|
||||||
**Created:** 2026-03-14
|
**Created:** 2026-03-14
|
||||||
**Last Updated:** 2026-03-14
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-1 (Authentifizierung) – nur Admins sehen das Dashboard
|
- Requires: PROJ-1 (Authentifizierung) – nur Admins sehen das Dashboard
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# PROJ-18: E-Mail Integritätsprüfung
|
# PROJ-18: E-Mail Integritätsprüfung
|
||||||
|
|
||||||
## Status: In Progress
|
## Status: Deployed
|
||||||
**Created:** 2026-03-14
|
**Created:** 2026-03-14
|
||||||
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
- Als Admin möchte ich sehen ob eine archivierte E-Mail unverändert ist, damit ich Manipulationen erkennen kann.
|
- Als Admin möchte ich sehen ob eine archivierte E-Mail unverändert ist, damit ich Manipulationen erkennen kann.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung
|
# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung
|
||||||
|
|
||||||
## Status: In Review
|
## Status: Deployed
|
||||||
**Created:** 2026-03-12
|
**Created:** 2026-03-12
|
||||||
**Last Updated:** 2026-03-14
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- None (Basis-Feature, wird von Import-Features genutzt)
|
- None (Basis-Feature, wird von Import-Features genutzt)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-6: Volltext-Suche & Filterung
|
# PROJ-6: Volltext-Suche & Filterung
|
||||||
|
|
||||||
## Status: In Progress
|
## Status: In Review
|
||||||
**Created:** 2026-03-12
|
**Created:** 2026-03-12
|
||||||
**Last Updated:** 2026-03-12
|
**Last Updated:** 2026-03-17
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-1 (Authentifizierung) – Suche nur für eingeloggte Nutzer
|
- Requires: PROJ-1 (Authentifizierung) – Suche nur für eingeloggte Nutzer
|
||||||
|
|||||||
+31
-9
@@ -398,6 +398,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
toFilter := r.URL.Query().Get("to")
|
toFilter := r.URL.Query().Get("to")
|
||||||
dateFromStr := r.URL.Query().Get("date_from")
|
dateFromStr := r.URL.Query().Get("date_from")
|
||||||
dateToStr := r.URL.Query().Get("date_to")
|
dateToStr := r.URL.Query().Get("date_to")
|
||||||
|
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
|
||||||
|
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
|
||||||
pageStr := r.URL.Query().Get("page")
|
pageStr := r.URL.Query().Get("page")
|
||||||
pageSizeStr := r.URL.Query().Get("page_size")
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
|
||||||
@@ -409,10 +411,19 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
req := index.SearchRequest{
|
req := index.SearchRequest{
|
||||||
Query: q,
|
Query: q,
|
||||||
|
Sort: sortParam,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
Page: page,
|
Page: page,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasAttachStr == "true" {
|
||||||
|
v := true
|
||||||
|
req.HasAttachment = &v
|
||||||
|
} else if hasAttachStr == "false" {
|
||||||
|
v := false
|
||||||
|
req.HasAttachment = &v
|
||||||
|
}
|
||||||
|
|
||||||
// Domain search: @domain.de matches both From AND To fields.
|
// Domain search: @domain.de matches both From AND To fields.
|
||||||
// A value starting with '@' triggers OR-search across XF and XT prefixes.
|
// A value starting with '@' triggers OR-search across XF and XT prefixes.
|
||||||
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
|
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
|
||||||
@@ -458,19 +469,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Enrich hits with metadata (from, subject, date) by parsing each mail.
|
// Enrich hits with metadata (from, subject, date, size, attachments).
|
||||||
type enrichedHit struct {
|
type enrichedHit struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
HasAttachments bool `json:"has_attachments"`
|
||||||
}
|
}
|
||||||
enriched := make([]enrichedHit, 0, len(result.Hits))
|
enriched := make([]enrichedHit, 0, len(result.Hits))
|
||||||
for _, h := range result.Hits {
|
for _, h := range result.Hits {
|
||||||
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
||||||
if raw, err := s.store.Load(h.ID); err == nil {
|
if raw, err := s.store.Load(h.ID); err == nil {
|
||||||
|
eh.Size = int64(len(raw))
|
||||||
if pm, err := mailparser.Parse(raw); err == nil {
|
if pm, err := mailparser.Parse(raw); err == nil {
|
||||||
eh.From = pm.From
|
eh.From = pm.From
|
||||||
if len(pm.To) > 0 {
|
if len(pm.To) > 0 {
|
||||||
@@ -480,6 +494,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !pm.Date.IsZero() {
|
if !pm.Date.IsZero() {
|
||||||
eh.Date = pm.Date.UTC().Format(time.RFC3339)
|
eh.Date = pm.Date.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
eh.HasAttachments = len(pm.Attachments) > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enriched = append(enriched, eh)
|
enriched = append(enriched, eh)
|
||||||
@@ -1215,6 +1230,7 @@ var excludedFSTypes = map[string]bool{
|
|||||||
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
|
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
|
||||||
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
|
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
|
||||||
"autofs": true, "nsfs": true, "rpc_pipefs": true,
|
"autofs": true, "nsfs": true, "rpc_pipefs": true,
|
||||||
|
"fuse.lxcfs": true, "fuse": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1251,7 +1267,8 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Disks: /proc/mounts + syscall.Statfs
|
// Disks: /proc/mounts + syscall.Statfs
|
||||||
var disks []diskStat
|
var disks []diskStat
|
||||||
seenMounts := map[string]bool{}
|
seenMounts := map[string]bool{} // deduplicate by mountpoint
|
||||||
|
seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts)
|
||||||
if data, err := os.ReadFile("/proc/mounts"); err == nil {
|
if data, err := os.ReadFile("/proc/mounts"); err == nil {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -1259,9 +1276,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(fields) < 3 {
|
if len(fields) < 3 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
device := fields[0]
|
||||||
mount := fields[1]
|
mount := fields[1]
|
||||||
fstype := fields[2]
|
fstype := fields[2]
|
||||||
if excludedFSTypes[fstype] || seenMounts[mount] {
|
if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenMounts[mount] = true
|
seenMounts[mount] = true
|
||||||
@@ -1272,6 +1290,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
total := stat.Blocks * uint64(stat.Bsize)
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
free := stat.Bavail * uint64(stat.Bsize)
|
free := stat.Bavail * uint64(stat.Bsize)
|
||||||
used := total - free
|
used := total - free
|
||||||
|
if total == 0 {
|
||||||
|
continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays)
|
||||||
|
}
|
||||||
|
seenDevices[device] = true
|
||||||
var usedPct float64
|
var usedPct float64
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
|
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -150,7 +152,12 @@ func HasRole(userRole, required string) bool {
|
|||||||
return levels[userRole] >= levels[required]
|
return levels[userRole] >= levels[required]
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateJTI returns a pseudo-unique identifier for a JWT.
|
// generateJTI returns a cryptographically random identifier for a JWT.
|
||||||
func generateJTI() string {
|
func generateJTI() string {
|
||||||
return fmt.Sprintf("%d-%x", time.Now().UnixNano(), time.Now().UnixNano()^0xdeadbeef)
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// fallback: should never happen on a healthy system
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -20,14 +20,16 @@ type MailDocument struct {
|
|||||||
|
|
||||||
// SearchRequest specifies search parameters.
|
// SearchRequest specifies search parameters.
|
||||||
type SearchRequest struct {
|
type SearchRequest struct {
|
||||||
Query string
|
Query string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
OwnEmail string
|
OwnEmail string
|
||||||
DateFrom *time.Time
|
DateFrom *time.Time
|
||||||
DateTo *time.Time
|
DateTo *time.Time
|
||||||
PageSize int
|
HasAttachment *bool // nil=no filter, true=only with, false=only without
|
||||||
Page int
|
Sort string // "relevance", "date_asc", "date_desc" (default: date_desc)
|
||||||
|
PageSize int
|
||||||
|
Page int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hit is a single search result.
|
// Hit is a single search result.
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ func (x *xapianIndex) IndexSync(doc MailDocument) error {
|
|||||||
defer C.free(unsafe.Pointer(csubj))
|
defer C.free(unsafe.Pointer(csubj))
|
||||||
cbody := C.CString(doc.Body)
|
cbody := C.CString(doc.Body)
|
||||||
defer C.free(unsafe.Pointer(cbody))
|
defer C.free(unsafe.Pointer(cbody))
|
||||||
|
hasAttach := C.int(0)
|
||||||
|
if doc.HasAttachment {
|
||||||
|
hasAttach = C.int(1)
|
||||||
|
}
|
||||||
var cerr *C.char
|
var cerr *C.char
|
||||||
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), &cerr)
|
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), hasAttach, &cerr)
|
||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
msg := C.GoString(cerr)
|
msg := C.GoString(cerr)
|
||||||
C.xapian_free_string(cerr)
|
C.xapian_free_string(cerr)
|
||||||
@@ -93,8 +97,27 @@ func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) {
|
|||||||
limit = 25
|
limit = 25
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort mode: 0=relevance, 1=date_desc (default), 2=date_asc
|
||||||
|
sortMode := C.int(1)
|
||||||
|
switch req.Sort {
|
||||||
|
case "relevance":
|
||||||
|
sortMode = C.int(0)
|
||||||
|
case "date_asc":
|
||||||
|
sortMode = C.int(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment filter: 0=all, 1=only with, -1=only without
|
||||||
|
attachFilter := C.int(0)
|
||||||
|
if req.HasAttachment != nil {
|
||||||
|
if *req.HasAttachment {
|
||||||
|
attachFilter = C.int(1)
|
||||||
|
} else {
|
||||||
|
attachFilter = C.int(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var cerr *C.char
|
var cerr *C.char
|
||||||
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, &cerr)
|
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, sortMode, attachFilter, &cerr)
|
||||||
if cresult == nil {
|
if cresult == nil {
|
||||||
msg := C.GoString(cerr)
|
msg := C.GoString(cerr)
|
||||||
C.xapian_free_string(cerr)
|
C.xapian_free_string(cerr)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ void xapian_close(XapianDB* db) {
|
|||||||
|
|
||||||
int xapian_index(XapianDB* db, const char* id, const char* from,
|
int xapian_index(XapianDB* db, const char* id, const char* from,
|
||||||
const char* to, const char* subject, const char* body,
|
const char* to, const char* subject, const char* body,
|
||||||
long long timestamp, char** err) {
|
long long timestamp, int has_attachment, char** err) {
|
||||||
try {
|
try {
|
||||||
Xapian::Document doc;
|
Xapian::Document doc;
|
||||||
Xapian::TermGenerator gen;
|
Xapian::TermGenerator gen;
|
||||||
@@ -65,6 +65,11 @@ int xapian_index(XapianDB* db, const char* id, const char* from,
|
|||||||
gen.increase_termpos();
|
gen.increase_termpos();
|
||||||
gen.index_text(to);
|
gen.index_text(to);
|
||||||
|
|
||||||
|
// Boolean term for attachment filter
|
||||||
|
if (has_attachment) {
|
||||||
|
doc.add_boolean_term("XHA");
|
||||||
|
}
|
||||||
|
|
||||||
// Store timestamp for date range queries (value slot 0)
|
// Store timestamp for date range queries (value slot 0)
|
||||||
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
|
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
|
||||||
|
|
||||||
@@ -96,7 +101,9 @@ char* xapian_search(XapianDB* db, const char* query_str,
|
|||||||
const char* from_filter, const char* own_email,
|
const char* from_filter, const char* own_email,
|
||||||
const char* to_filter,
|
const char* to_filter,
|
||||||
long long date_from, long long date_to,
|
long long date_from, long long date_to,
|
||||||
int offset, int limit, char** err) {
|
int offset, int limit,
|
||||||
|
int sort_mode, int has_attachment,
|
||||||
|
char** err) {
|
||||||
try {
|
try {
|
||||||
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
|
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
|
||||||
Xapian::Enquire enquire(xdb);
|
Xapian::Enquire enquire(xdb);
|
||||||
@@ -159,8 +166,25 @@ char* xapian_search(XapianDB* db, const char* query_str,
|
|||||||
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
|
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment filter
|
||||||
|
if (has_attachment == 1) {
|
||||||
|
Xapian::Query aq("XHA");
|
||||||
|
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, aq);
|
||||||
|
} else if (has_attachment == -1) {
|
||||||
|
Xapian::Query aq("XHA");
|
||||||
|
main_query = Xapian::Query(Xapian::Query::OP_AND_NOT, main_query, aq);
|
||||||
|
}
|
||||||
|
|
||||||
enquire.set_query(main_query);
|
enquire.set_query(main_query);
|
||||||
enquire.set_sort_by_value(0, true); // sort by date desc
|
|
||||||
|
// Sort mode: 0=relevance, 1=date_desc, 2=date_asc
|
||||||
|
if (sort_mode == 2) {
|
||||||
|
enquire.set_sort_by_value(0, false); // date ascending
|
||||||
|
} else if (sort_mode == 0 && query_str && query_str[0] != '\0') {
|
||||||
|
// relevance: default BM25 ranking (no explicit sort)
|
||||||
|
} else {
|
||||||
|
enquire.set_sort_by_value(0, true); // date descending (default)
|
||||||
|
}
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
|
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
|
||||||
|
|||||||
@@ -10,19 +10,24 @@ typedef struct XapianDB XapianDB;
|
|||||||
XapianDB* xapian_open(const char* path, int writable, char** err);
|
XapianDB* xapian_open(const char* path, int writable, char** err);
|
||||||
void xapian_close(XapianDB* db);
|
void xapian_close(XapianDB* db);
|
||||||
|
|
||||||
|
/* has_attachment: 0=no attachment, 1=has attachment */
|
||||||
int xapian_index(XapianDB* db, const char* id, const char* from,
|
int xapian_index(XapianDB* db, const char* id, const char* from,
|
||||||
const char* to, const char* subject, const char* body,
|
const char* to, const char* subject, const char* body,
|
||||||
long long timestamp, char** err);
|
long long timestamp, int has_attachment, char** err);
|
||||||
|
|
||||||
int xapian_delete(XapianDB* db, const char* id, char** err);
|
int xapian_delete(XapianDB* db, const char* id, char** err);
|
||||||
|
|
||||||
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
|
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
|
||||||
Returns NULL on error, sets *err. Caller must free with xapian_free_string. */
|
Returns NULL on error, sets *err. Caller must free with xapian_free_string.
|
||||||
|
sort_mode: 0=relevance, 1=date_desc, 2=date_asc
|
||||||
|
has_attachment: 0=all, 1=only with attachment, -1=only without */
|
||||||
char* xapian_search(XapianDB* db, const char* query,
|
char* xapian_search(XapianDB* db, const char* query,
|
||||||
const char* from_filter, const char* own_email,
|
const char* from_filter, const char* own_email,
|
||||||
const char* to_filter,
|
const char* to_filter,
|
||||||
long long date_from, long long date_to,
|
long long date_from, long long date_to,
|
||||||
int offset, int limit, char** err);
|
int offset, int limit,
|
||||||
|
int sort_mode, int has_attachment,
|
||||||
|
char** err);
|
||||||
|
|
||||||
void xapian_free_string(char* s);
|
void xapian_free_string(char* s);
|
||||||
|
|
||||||
|
|||||||
+106
-9
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { features, type Feature } from "@/data/features";
|
||||||
import {
|
import {
|
||||||
getUsers,
|
getUsers,
|
||||||
createUser,
|
createUser,
|
||||||
@@ -276,18 +277,17 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||||
|
|
||||||
if (authLoading || !user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Navbar username={user.username} role={user.role} />
|
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
{(authLoading || !user) ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-full max-w-sm" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="dashboard">
|
<Tabs defaultValue="dashboard">
|
||||||
@@ -296,6 +296,7 @@ export default function AdminPage() {
|
|||||||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||||
|
<TabsTrigger value="modules">Module</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── Dashboard ── */}
|
{/* ── Dashboard ── */}
|
||||||
@@ -1018,7 +1019,11 @@ export default function AdminPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="modules" className="mt-4">
|
||||||
|
<ModulesTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</>)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Passwort-Reset Dialog */}
|
{/* Passwort-Reset Dialog */}
|
||||||
@@ -1060,3 +1065,95 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Module Tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
"Planned": "bg-gray-100 text-gray-700",
|
||||||
|
"In Progress": "bg-yellow-100 text-yellow-800",
|
||||||
|
"In Review": "bg-blue-100 text-blue-800",
|
||||||
|
"Deployed": "bg-green-100 text-green-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCounts = (list: Feature[]) => ({
|
||||||
|
total: list.length,
|
||||||
|
planned: list.filter((f) => f.status === "Planned").length,
|
||||||
|
inProgress: list.filter((f) => f.status === "In Progress").length,
|
||||||
|
inReview: list.filter((f) => f.status === "In Review").length,
|
||||||
|
deployed: list.filter((f) => f.status === "Deployed").length,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ModulesTab() {
|
||||||
|
const counts = statusCounts(features);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Modulübersicht</h2>
|
||||||
|
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
|
||||||
|
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
|
||||||
|
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
|
||||||
|
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
|
||||||
|
].map((s) => (
|
||||||
|
<Card key={s.label}>
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{s.label}</span>
|
||||||
|
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-20">ID</TableHead>
|
||||||
|
<TableHead>Feature</TableHead>
|
||||||
|
<TableHead className="w-32">Status</TableHead>
|
||||||
|
<TableHead className="w-24 text-center">Frontend</TableHead>
|
||||||
|
<TableHead className="w-24 text-center">Backend</TableHead>
|
||||||
|
<TableHead className="w-32">Aktualisiert</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{features.map((f) => (
|
||||||
|
<TableRow key={f.id}>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{f.id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{f.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
|
||||||
|
{f.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{f.frontend
|
||||||
|
? <span className="text-green-600 font-bold">✓</span>
|
||||||
|
: <span className="text-muted-foreground">–</span>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{f.backend
|
||||||
|
? <span className="text-green-600 font-bold">✓</span>
|
||||||
|
: <span className="text-muted-foreground">–</span>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{f.lastUpdated}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,18 +229,16 @@ export default function ImapPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading || !user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Navbar username={user.username} role={user.role} />
|
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||||
|
{(authLoading || !user) ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">IMAP Import</h1>
|
<h1 className="text-2xl font-bold">IMAP Import</h1>
|
||||||
<Button
|
<Button
|
||||||
@@ -512,6 +510,7 @@ export default function ImapPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -290,18 +290,17 @@ export default function MailViewPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading || !user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Navbar username={user.username} role={user.role} />
|
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||||
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
|
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
|
||||||
|
{(authLoading || !user) ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
|
||||||
{/* Back + Actions */}
|
{/* Back + Actions */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -394,6 +393,7 @@ export default function MailViewPage({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-4
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { login } from "@/lib/api";
|
import { login } from "@/lib/api";
|
||||||
|
import { getCachedUser } from "@/lib/auth-cache";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -16,10 +17,10 @@ export default function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if already logged in via session cookie
|
// Only redirect if we have a cached session — no API call, no loop risk
|
||||||
import("@/lib/api").then(({ getMe }) =>
|
if (getCachedUser() !== null) {
|
||||||
getMe().then(() => router.replace("/search")).catch(() => {})
|
router.replace("/search");
|
||||||
);
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
|||||||
+64
-10
@@ -17,6 +17,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -31,6 +38,12 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -40,6 +53,8 @@ export default function SearchPage() {
|
|||||||
const [toFilter, setToFilter] = useState("");
|
const [toFilter, setToFilter] = useState("");
|
||||||
const [dateFrom, setDateFrom] = useState("");
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const [sort, setSort] = useState("date_desc");
|
||||||
|
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
const [results, setResults] = useState<SearchHit[]>([]);
|
const [results, setResults] = useState<SearchHit[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -68,6 +83,8 @@ export default function SearchPage() {
|
|||||||
to: toFilter || undefined,
|
to: toFilter || undefined,
|
||||||
date_from: dateFrom || undefined,
|
date_from: dateFrom || undefined,
|
||||||
date_to: dateTo || undefined,
|
date_to: dateTo || undefined,
|
||||||
|
sort: sort !== "date_desc" ? sort : undefined,
|
||||||
|
has_attachment: hasAttachment,
|
||||||
page: p,
|
page: p,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
@@ -82,7 +99,7 @@ export default function SearchPage() {
|
|||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[query, fromFilter, toFilter, dateFrom, dateTo]
|
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
||||||
@@ -130,18 +147,18 @@ export default function SearchPage() {
|
|||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||||
|
|
||||||
if (authLoading || !user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Navbar username={user.username} role={user.role} />
|
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
{(authLoading || !user) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!authLoading && user && (<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -206,6 +223,34 @@ export default function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
|
||||||
|
<Select value={sort} onValueChange={setSort}>
|
||||||
|
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="date_desc">Datum (neu → alt)</SelectItem>
|
||||||
|
<SelectItem value="date_asc">Datum (alt → neu)</SelectItem>
|
||||||
|
<SelectItem value="relevance">Relevanz</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="attach-toggle"
|
||||||
|
checked={hasAttachment === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setHasAttachment(checked ? true : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
|
||||||
|
Nur mit Anhang
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -257,6 +302,8 @@ export default function SearchPage() {
|
|||||||
<TableHead className="w-56">Von</TableHead>
|
<TableHead className="w-56">Von</TableHead>
|
||||||
<TableHead>Betreff</TableHead>
|
<TableHead>Betreff</TableHead>
|
||||||
<TableHead className="w-48">An</TableHead>
|
<TableHead className="w-48">An</TableHead>
|
||||||
|
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
|
||||||
|
<TableHead className="w-20 text-right">Größe</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -294,6 +341,12 @@ export default function SearchPage() {
|
|||||||
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
||||||
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
||||||
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">
|
||||||
|
{hit.has_attachments ? "📎" : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{hit.size ? formatBytes(hit.size) : ""}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -353,6 +406,7 @@ export default function SearchPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page loading skeleton that matches the Navbar + content layout.
|
||||||
|
* Prevents layout shift (flicker) while useAuth checks the session.
|
||||||
|
*/
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Fake Navbar */}
|
||||||
|
<div className="border-b bg-background">
|
||||||
|
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-6 w-12 rounded-full" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-6 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-16
@@ -2,35 +2,48 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { getMe, type MeResponse } from "@/lib/api";
|
import { getMe } from "@/lib/api";
|
||||||
|
import { getCachedUser, setCachedUser } from "@/lib/auth-cache";
|
||||||
|
|
||||||
interface AuthState {
|
export { clearAuthCache } from "@/lib/auth-cache";
|
||||||
user: MeResponse | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(requireRole?: "admin" | "auditor") {
|
export function useAuth(requireRole?: "admin" | "auditor") {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [state, setState] = useState<AuthState>({
|
const cached = getCachedUser();
|
||||||
user: null,
|
const [user, setUser] = useState(cached);
|
||||||
loading: true,
|
const [loading, setLoading] = useState(cached === null);
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkAuth = useCallback(async () => {
|
const checkAuth = useCallback(async () => {
|
||||||
|
const cached = getCachedUser();
|
||||||
|
if (cached !== null) {
|
||||||
|
if (requireRole === "admin" && cached.role !== "admin") {
|
||||||
|
router.replace("/search");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireRole === "auditor" && cached.role !== "auditor" && cached.role !== "admin") {
|
||||||
|
router.replace("/search");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUser(cached);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getMe();
|
const me = await getMe();
|
||||||
if (requireRole === "admin" && user.role !== "admin") {
|
setCachedUser(me);
|
||||||
|
if (requireRole === "admin" && me.role !== "admin") {
|
||||||
router.replace("/search");
|
router.replace("/search");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requireRole === "auditor" && user.role !== "auditor" && user.role !== "admin") {
|
if (requireRole === "auditor" && me.role !== "auditor" && me.role !== "admin") {
|
||||||
router.replace("/search");
|
router.replace("/search");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState({ user, loading: false, error: null });
|
setUser(me);
|
||||||
|
setLoading(false);
|
||||||
} catch {
|
} catch {
|
||||||
|
setCachedUser(null);
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [router, requireRole]);
|
}, [router, requireRole]);
|
||||||
@@ -39,5 +52,5 @@ export function useAuth(requireRole?: "admin" | "auditor") {
|
|||||||
checkAuth();
|
checkAuth();
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
return state;
|
return { user, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import { clearAuthCache } from "@/lib/auth-cache";
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
@@ -16,9 +18,7 @@ async function request<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
if (typeof window !== "undefined") {
|
clearAuthCache();
|
||||||
window.location.href = "/";
|
|
||||||
}
|
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +81,8 @@ export interface SearchHit {
|
|||||||
to?: string;
|
to?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
size?: number;
|
||||||
|
has_attachments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
@@ -155,6 +157,7 @@ export async function getMe(): Promise<MeResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
|
clearAuthCache();
|
||||||
await request<void>("/api/auth/logout", { method: "POST" });
|
await request<void>("/api/auth/logout", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +167,8 @@ export async function searchEmails(params: {
|
|||||||
to?: string;
|
to?: string;
|
||||||
date_from?: string;
|
date_from?: string;
|
||||||
date_to?: string;
|
date_to?: string;
|
||||||
|
sort?: string;
|
||||||
|
has_attachment?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
}): Promise<SearchResponse> {
|
}): Promise<SearchResponse> {
|
||||||
@@ -173,6 +178,8 @@ export async function searchEmails(params: {
|
|||||||
if (params.to) sp.set("to", params.to);
|
if (params.to) sp.set("to", params.to);
|
||||||
if (params.date_from) sp.set("date_from", params.date_from);
|
if (params.date_from) sp.set("date_from", params.date_from);
|
||||||
if (params.date_to) sp.set("date_to", params.date_to);
|
if (params.date_to) sp.set("date_to", params.date_to);
|
||||||
|
if (params.sort) sp.set("sort", params.sort);
|
||||||
|
if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment));
|
||||||
if (params.page) sp.set("page", String(params.page));
|
if (params.page) sp.set("page", String(params.page));
|
||||||
if (params.page_size) sp.set("page_size", String(params.page_size));
|
if (params.page_size) sp.set("page_size", String(params.page_size));
|
||||||
return request<SearchResponse>(`/api/search?${sp.toString()}`);
|
return request<SearchResponse>(`/api/search?${sp.toString()}`);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MeResponse } from "@/lib/api";
|
||||||
|
|
||||||
|
let cachedUser: MeResponse | null = null;
|
||||||
|
|
||||||
|
export function getCachedUser(): MeResponse | null {
|
||||||
|
return cachedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedUser(user: MeResponse | null): void {
|
||||||
|
cachedUser = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCache(): void {
|
||||||
|
cachedUser = null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user