diff --git a/.gitignore b/.gitignore index 1706280..0d149fb 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,26 @@ yarn-error.log* # claude code personal settings .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 *.tsbuildinfo next-env.d.ts diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 22e14cb..6e16938 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "flag" "fmt" "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. +// 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 { all, err := users.List("") if err != nil { @@ -329,16 +333,46 @@ func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error { if len(all) > 0 { 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{ - {Username: "admin", Email: "admin@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAdmin}, - {Username: "auditor", Email: "auditor@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAuditor}, + {Username: "admin", Email: "admin@archivmail.local", Password: adminPw, Role: userstore.RoleAdmin}, + {Username: "auditor", Email: "auditor@archivmail.local", Password: auditorPw, Role: userstore.RoleAuditor}, } for _, req := range defaults { if _, err := users.Create(req); err != nil { 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 } + +// 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 +} diff --git a/features/INDEX.md b/features/INDEX.md index bc00990..6fa7dd0 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -12,25 +12,25 @@ | 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-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-5 | E-Mail-Speicherung & Volltext-Indexierung | In Review | [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-5 | E-Mail-Speicherung & Volltext-Indexierung | Deployed | [PROJ-5](PROJ-5-speicherung-und-indexierung.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-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-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-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-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-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | In Review | [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-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | Deployed | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 | +| PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 | diff --git a/features/PROJ-1-authentifizierung-und-rollen.md b/features/PROJ-1-authentifizierung-und-rollen.md index fca90e4..de7aee5 100644 --- a/features/PROJ-1-authentifizierung-und-rollen.md +++ b/features/PROJ-1-authentifizierung-und-rollen.md @@ -1,8 +1,8 @@ # PROJ-1: Nutzer-Authentifizierung & Rollen -## Status: In Progress +## Status: Deployed **Created:** 2026-03-12 -**Last Updated:** 2026-03-12 +**Last Updated:** 2026-03-17 ## Dependencies - PROJ-16 (LDAP / Active Directory Anbindung) — optionale Erweiterung des Login-Flows diff --git a/features/PROJ-12-export.md b/features/PROJ-12-export.md index d1685e6..011c231 100644 --- a/features/PROJ-12-export.md +++ b/features/PROJ-12-export.md @@ -1,8 +1,8 @@ # PROJ-12: E-Mail-Export (EML / PDF) -## Status: In Review +## Status: Deployed **Created:** 2026-03-12 -**Last Updated:** 2026-03-14 +**Last Updated:** 2026-03-17 ## Dependencies - Requires: PROJ-1 (Authentifizierung) diff --git a/features/PROJ-15-cli-import-export.md b/features/PROJ-15-cli-import-export.md index 7f258e7..0c44626 100644 --- a/features/PROJ-15-cli-import-export.md +++ b/features/PROJ-15-cli-import-export.md @@ -1,8 +1,8 @@ # PROJ-15: CLI Import & Export -## Status: In Review +## Status: Deployed **Created:** 2026-03-13 -**Last Updated:** 2026-03-13 +**Last Updated:** 2026-03-17 ## Dependencies - Requires: PROJ-5 (Speicherung & Indexierung) – Import nutzt Storage Coordinator diff --git a/features/PROJ-17-system-dashboard.md b/features/PROJ-17-system-dashboard.md index 07df56f..b62ce83 100644 --- a/features/PROJ-17-system-dashboard.md +++ b/features/PROJ-17-system-dashboard.md @@ -1,8 +1,8 @@ # PROJ-17: Admin Dashboard – Systemauslastung & Archiv-Übersicht -## Status: In Review +## Status: Deployed **Created:** 2026-03-14 -**Last Updated:** 2026-03-14 +**Last Updated:** 2026-03-17 ## Dependencies - Requires: PROJ-1 (Authentifizierung) – nur Admins sehen das Dashboard diff --git a/features/PROJ-18-integritaetspruefung.md b/features/PROJ-18-integritaetspruefung.md index 8484eb8..aecb0f3 100644 --- a/features/PROJ-18-integritaetspruefung.md +++ b/features/PROJ-18-integritaetspruefung.md @@ -1,7 +1,8 @@ # PROJ-18: E-Mail Integritätsprüfung -## Status: In Progress +## Status: Deployed **Created:** 2026-03-14 +**Last Updated:** 2026-03-17 ## User Stories - Als Admin möchte ich sehen ob eine archivierte E-Mail unverändert ist, damit ich Manipulationen erkennen kann. diff --git a/features/PROJ-5-speicherung-und-indexierung.md b/features/PROJ-5-speicherung-und-indexierung.md index d8f1307..cc9e7f6 100644 --- a/features/PROJ-5-speicherung-und-indexierung.md +++ b/features/PROJ-5-speicherung-und-indexierung.md @@ -1,8 +1,8 @@ # PROJ-5: E-Mail-Speicherung & Volltext-Indexierung -## Status: In Review +## Status: Deployed **Created:** 2026-03-12 -**Last Updated:** 2026-03-14 +**Last Updated:** 2026-03-17 ## Dependencies - None (Basis-Feature, wird von Import-Features genutzt) diff --git a/features/PROJ-6-volltext-suche.md b/features/PROJ-6-volltext-suche.md index e1708e1..8e6cfd4 100644 --- a/features/PROJ-6-volltext-suche.md +++ b/features/PROJ-6-volltext-suche.md @@ -1,8 +1,8 @@ # PROJ-6: Volltext-Suche & Filterung -## Status: In Progress +## Status: In Review **Created:** 2026-03-12 -**Last Updated:** 2026-03-12 +**Last Updated:** 2026-03-17 ## Dependencies - Requires: PROJ-1 (Authentifizierung) – Suche nur für eingeloggte Nutzer diff --git a/internal/api/server.go b/internal/api/server.go index 43bc4fe..56e44bf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -398,6 +398,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { toFilter := r.URL.Query().Get("to") dateFromStr := r.URL.Query().Get("date_from") 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") pageSizeStr := r.URL.Query().Get("page_size") @@ -409,10 +411,19 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { req := index.SearchRequest{ Query: q, + Sort: sortParam, PageSize: pageSize, 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. // A value starting with '@' triggers OR-search across XF and XT prefixes. if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") { @@ -458,19 +469,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { 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 { - ID string `json:"id"` - Score float64 `json:"score"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Subject string `json:"subject,omitempty"` - Date string `json:"date,omitempty"` + ID string `json:"id"` + Score float64 `json:"score"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Subject string `json:"subject,omitempty"` + Date string `json:"date,omitempty"` + Size int64 `json:"size,omitempty"` + HasAttachments bool `json:"has_attachments"` } enriched := make([]enrichedHit, 0, len(result.Hits)) for _, h := range result.Hits { eh := enrichedHit{ID: h.ID, Score: h.Score} if raw, err := s.store.Load(h.ID); err == nil { + eh.Size = int64(len(raw)) if pm, err := mailparser.Parse(raw); err == nil { eh.From = pm.From if len(pm.To) > 0 { @@ -480,6 +494,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { if !pm.Date.IsZero() { eh.Date = pm.Date.UTC().Format(time.RFC3339) } + eh.HasAttachments = len(pm.Attachments) > 0 } } enriched = append(enriched, eh) @@ -1215,6 +1230,7 @@ var excludedFSTypes = map[string]bool{ "efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true, "ramfs": true, "devpts": true, "fusectl": true, "configfs": true, "autofs": true, "nsfs": true, "rpc_pipefs": true, + "fuse.lxcfs": true, "fuse": true, } 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 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 { scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { @@ -1259,9 +1276,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { if len(fields) < 3 { continue } + device := fields[0] mount := fields[1] fstype := fields[2] - if excludedFSTypes[fstype] || seenMounts[mount] { + if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] { continue } seenMounts[mount] = true @@ -1272,6 +1290,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { total := stat.Blocks * uint64(stat.Bsize) free := stat.Bavail * uint64(stat.Bsize) used := total - free + if total == 0 { + continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays) + } + seenDevices[device] = true var usedPct float64 if total > 0 { usedPct = math.Round(float64(used)/float64(total)*1000) / 10 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fcc452b..f2c7939 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,6 +1,8 @@ package auth import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "time" @@ -150,7 +152,12 @@ func HasRole(userRole, required string) bool { 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 { - 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) } diff --git a/internal/index/index.go b/internal/index/index.go index 0f5fa54..4cbfa9f 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -20,14 +20,16 @@ type MailDocument struct { // SearchRequest specifies search parameters. type SearchRequest struct { - Query string - From string - To string - OwnEmail string - DateFrom *time.Time - DateTo *time.Time - PageSize int - Page int + Query string + From string + To string + OwnEmail string + DateFrom *time.Time + DateTo *time.Time + HasAttachment *bool // nil=no filter, true=only with, false=only without + Sort string // "relevance", "date_asc", "date_desc" (default: date_desc) + PageSize int + Page int } // Hit is a single search result. diff --git a/internal/index/xapian.go b/internal/index/xapian.go index 6ff09c7..167057f 100644 --- a/internal/index/xapian.go +++ b/internal/index/xapian.go @@ -43,8 +43,12 @@ func (x *xapianIndex) IndexSync(doc MailDocument) error { defer C.free(unsafe.Pointer(csubj)) cbody := C.CString(doc.Body) defer C.free(unsafe.Pointer(cbody)) + hasAttach := C.int(0) + if doc.HasAttachment { + hasAttach = C.int(1) + } 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 { msg := C.GoString(cerr) C.xapian_free_string(cerr) @@ -93,8 +97,27 @@ func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) { 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 - 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 { msg := C.GoString(cerr) C.xapian_free_string(cerr) diff --git a/internal/index/xapian_wrapper.cpp b/internal/index/xapian_wrapper.cpp index 16be7d1..0a112e1 100644 --- a/internal/index/xapian_wrapper.cpp +++ b/internal/index/xapian_wrapper.cpp @@ -44,7 +44,7 @@ void xapian_close(XapianDB* db) { int xapian_index(XapianDB* db, const char* id, const char* from, const char* to, const char* subject, const char* body, - long long timestamp, char** err) { + long long timestamp, int has_attachment, char** err) { try { Xapian::Document doc; Xapian::TermGenerator gen; @@ -65,6 +65,11 @@ int xapian_index(XapianDB* db, const char* id, const char* from, gen.increase_termpos(); 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) 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* to_filter, 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 { Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb; 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); } + // 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_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 Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount()); diff --git a/internal/index/xapian_wrapper.h b/internal/index/xapian_wrapper.h index c027fbb..a88956a 100644 --- a/internal/index/xapian_wrapper.h +++ b/internal/index/xapian_wrapper.h @@ -10,19 +10,24 @@ typedef struct XapianDB XapianDB; XapianDB* xapian_open(const char* path, int writable, char** err); void xapian_close(XapianDB* db); +/* has_attachment: 0=no attachment, 1=has attachment */ int xapian_index(XapianDB* db, const char* id, const char* from, 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); /* 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, const char* from_filter, const char* own_email, const char* to_filter, 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); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6b71bb6..7b0d2f2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useAuth } from "@/hooks/useAuth"; +import { features, type Feature } from "@/data/features"; import { getUsers, createUser, @@ -276,18 +277,17 @@ export default function AdminPage() { const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); - if (authLoading || !user) { - return ( -