From f627caaacbfef43886a59ae49ac3de906a8706b2 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:34:36 +0200 Subject: [PATCH] IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01) --- internal/auth/changepassword.go | 65 +++++ internal/auth/changepassword_test.go | 76 ++++++ internal/auth/profile.go | 44 ++++ internal/auth/profile_test.go | 48 ++++ internal/authtoken/handler.go | 109 +++++++++ internal/authtoken/handler_test.go | 94 ++++++++ internal/totp/handler.go | 164 +++++++++++++ internal/totp/handler_test.go | 171 +++++++++++++ internal/user/tenant_store.go | 20 ++ web/account/app/layout.tsx | 30 +++ web/account/app/login/page.tsx | 87 +++++++ web/account/app/page.tsx | 5 + .../app/password-reset/complete/page.tsx | 71 ++++++ .../app/password-reset/request/page.tsx | 53 ++++ web/account/app/profile/page.tsx | 228 ++++++++++++++++++ web/account/lib/api.ts | 92 +++++++ web/account/next.config.mjs | 7 + web/account/package.json | 22 ++ web/account/tsconfig.json | 21 ++ 19 files changed, 1407 insertions(+) create mode 100644 internal/auth/changepassword.go create mode 100644 internal/auth/changepassword_test.go create mode 100644 internal/auth/profile.go create mode 100644 internal/auth/profile_test.go create mode 100644 internal/authtoken/handler.go create mode 100644 internal/authtoken/handler_test.go create mode 100644 internal/totp/handler.go create mode 100644 internal/totp/handler_test.go create mode 100644 web/account/app/layout.tsx create mode 100644 web/account/app/login/page.tsx create mode 100644 web/account/app/page.tsx create mode 100644 web/account/app/password-reset/complete/page.tsx create mode 100644 web/account/app/password-reset/request/page.tsx create mode 100644 web/account/app/profile/page.tsx create mode 100644 web/account/lib/api.ts create mode 100644 web/account/next.config.mjs create mode 100644 web/account/package.json create mode 100644 web/account/tsconfig.json diff --git a/internal/auth/changepassword.go b/internal/auth/changepassword.go new file mode 100644 index 0000000..81c611f --- /dev/null +++ b/internal/auth/changepassword.go @@ -0,0 +1,65 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits +// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von +// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das +// Aktualisieren des Hashes gebraucht wird. +type ChangePasswordHandler struct { + users *user.TenantUserStore +} + +func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler { + return &ChangePasswordHandler{users: users} +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird +// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie +// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte +// Session unbrauchbar machen). Muss hinter RequireAuth haengen. +func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID) + if err != nil { + http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest) + return + } + if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) { + http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest) + return + } + + newHash, err := HashPassword(req.NewPassword) + if err != nil { + http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest) + return + } + if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil { + http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/auth/changepassword_test.go b/internal/auth/changepassword_test.go new file mode 100644 index 0000000..f60c0a9 --- /dev/null +++ b/internal/auth/changepassword_test.go @@ -0,0 +1,76 @@ +package auth + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort. +func TestChangePassword_RequiresCurrentPassword(t *testing.T) { + pool := setupTenantDB(t, "changepw_test_wrong") + store := user.NewTenantUserStore(pool) + u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort") + + issuer := NewTokenIssuer("test-session-secret") + sessionToken, err := issuer.Issue(u.ID, "acme") + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + + h := NewChangePasswordHandler(store) + body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body)) + req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken}) + rec := httptest.NewRecorder() + + RequireAuth(issuer, h.ChangePassword)(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code) + } + + creds, err := store.GetByIDForAuth(req.Context(), u.ID) + if err != nil { + t.Fatalf("credentials laden: %v", err) + } + if !VerifyPassword(creds.PasswordHash, "altes-passwort") { + t.Fatal("passwort haette NICHT geaendert werden duerfen") + } +} + +func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) { + pool := setupTenantDB(t, "changepw_test_ok") + store := user.NewTenantUserStore(pool) + u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort") + + issuer := NewTokenIssuer("test-session-secret") + sessionToken, err := issuer.Issue(u.ID, "acme") + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + + h := NewChangePasswordHandler(store) + body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body)) + req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken}) + rec := httptest.NewRecorder() + + RequireAuth(issuer, h.ChangePassword)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } + + creds, err := store.GetByIDForAuth(req.Context(), u.ID) + if err != nil { + t.Fatalf("credentials laden: %v", err) + } + if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") { + t.Fatal("neues passwort wurde nicht uebernommen") + } +} diff --git a/internal/auth/profile.go b/internal/auth/profile.go new file mode 100644 index 0000000..47ce336 --- /dev/null +++ b/internal/auth/profile.go @@ -0,0 +1,44 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite +// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche +// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt). +type ProfileHandler struct { + users *user.TenantUserStore +} + +func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler { + return &ProfileHandler{users: users} +} + +type meResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter +// RequireAuth haengen. +func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + u, err := h.users.Get(r.Context(), claims.UserID) + if err != nil { + http.Error(w, "benutzer nicht gefunden", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name}) +} diff --git a/internal/auth/profile_test.go b/internal/auth/profile_test.go new file mode 100644 index 0000000..2bb518e --- /dev/null +++ b/internal/auth/profile_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +func TestProfileMe_ReturnsOwnData(t *testing.T) { + pool := setupTenantDB(t, "profile_test_me") + store := user.NewTenantUserStore(pool) + u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort") + + issuer := NewTokenIssuer("test-session-secret") + sessionToken, err := issuer.Issue(u.ID, "acme") + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + + h := NewProfileHandler(store) + req := httptest.NewRequest(http.MethodGet, "/account/me", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken}) + rec := httptest.NewRecorder() + + RequireAuth(issuer, h.Me)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } +} + +func TestProfileMe_RejectsWithoutSession(t *testing.T) { + pool := setupTenantDB(t, "profile_test_noauth") + store := user.NewTenantUserStore(pool) + + issuer := NewTokenIssuer("test-session-secret") + h := NewProfileHandler(store) + req := httptest.NewRequest(http.MethodGet, "/account/me", nil) + rec := httptest.NewRecorder() + + RequireAuth(issuer, h.Me)(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code) + } +} diff --git a/internal/authtoken/handler.go b/internal/authtoken/handler.go new file mode 100644 index 0000000..9f1bbd9 --- /dev/null +++ b/internal/authtoken/handler.go @@ -0,0 +1,109 @@ +package authtoken + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit +// und Angriffsflaeche fuer Passwort-Reset-Links. +const ResetTokenTTL = time.Hour + +// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02 +// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar +// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft +// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis +// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail +// zu verschicken. Produktionsreif ist erst der Austausch gegen einen +// CFG-02-Client, keine neue eigene Versandlogik hier. +type Notifier interface { + NotifyPasswordReset(ctx context.Context, email, token string) error +} + +type LogNotifier struct{} + +func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error { + // Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create). + slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email)) + return nil +} + +func domainHint(email string) string { + for i := len(email) - 1; i >= 0; i-- { + if email[i] == '@' { + return email[i:] + } + } + return "" +} + +// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08). +type Handler struct { + tokens *Store + users *user.TenantUserStore + notifier Notifier +} + +func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler { + if notifier == nil { + notifier = LogNotifier{} + } + return &Handler{tokens: tokens, users: users, notifier: notifier} +} + +type requestResetRequest struct { + Email string `json:"email"` +} + +// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob +// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1, +// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login). +func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) { + var req requestResetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + // Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen + // HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich. + creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email) + if err == nil { + token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL) + if tokenErr == nil { + _ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token) + } + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.", + }) +} + +type completeResetRequest struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische +// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken). +func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) { + var req completeResetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil { + http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/authtoken/handler_test.go b/internal/authtoken/handler_test.go new file mode 100644 index 0000000..2a4824e --- /dev/null +++ b/internal/authtoken/handler_test.go @@ -0,0 +1,94 @@ +package authtoken + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde — +// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token +// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden +// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich). +type fakeNotifier struct { + calls []string // e-mail-adressen, fuer die notifiziert wurde +} + +func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error { + f.calls = append(f.calls, email) + return nil +} + +// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die +// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich. +func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) { + tokens, users, userID, cleanup := setupTest(t) + defer cleanup() + _ = userID + + u, err := users.Get(context.Background(), userID) + if err != nil { + t.Fatalf("benutzer laden: %v", err) + } + + notifier := &fakeNotifier{} + h := NewHandler(tokens, users, notifier) + + // Fall 1: existierende E-Mail. + body1, _ := json.Marshal(requestResetRequest{Email: u.Email}) + req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1)) + rec1 := httptest.NewRecorder() + h.RequestReset(rec1, req1) + + // Fall 2: nicht existierende E-Mail. + body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"}) + req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2)) + rec2 := httptest.NewRecorder() + h.RequestReset(rec2, req2) + + if rec1.Code != rec2.Code { + t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code) + } + if rec1.Body.String() != rec2.Body.String() { + t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String()) + } + + // Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt. + if len(notifier.calls) != 1 || notifier.calls[0] != u.Email { + t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls) + } +} + +// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP. +func TestCompleteReset_Works(t *testing.T) { + tokens, users, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL) + if err != nil { + t.Fatalf("token erzeugen: %v", err) + } + + h := NewHandler(tokens, users, &fakeNotifier{}) + body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.CompleteReset(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } + + // Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl. + body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"}) + req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2)) + rec2 := httptest.NewRecorder() + h.CompleteReset(rec2, req2) + if rec2.Code != http.StatusBadRequest { + t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code) + } +} diff --git a/internal/totp/handler.go b/internal/totp/handler.go new file mode 100644 index 0000000..837a20c --- /dev/null +++ b/internal/totp/handler.go @@ -0,0 +1,164 @@ +package totp + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// Handler stellt Login (inklusive optionalem zweiten Faktor) und die +// 2FA-Einrichtung als HTTP-Endpunkte bereit (IAM-08). Ersetzt auth.Handler.Login +// nicht (der bleibt fuer Faelle ohne 2FA-Bedarf nutzbar), bietet aber den +// EINEN Login-Endpunkt, den das Frontend tatsaechlich aufruft — LoginWithTOTP +// deckt beide Faelle (mit/ohne 2FA) bereits ab. +type Handler struct { + users *user.TenantUserStore + totp *Store + login *auth.LoginService + issuer string // fuer die otpauth://-Provisioning-URI (Akzeptanzkriterium 1) +} + +func NewHandler(users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, issuer string) *Handler { + return &Handler{users: users, totp: totpStore, login: login, issuer: issuer} +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + TOTPCode string `json:"totp_code"` +} + +type loginErrorResponse struct { + // Error ist bewusst IMMER dieselbe generische Meldung fuer falsches + // Passwort/Token (Akzeptanzkriterium 3 / Pruefung 1) — Code unterscheidet + // intern zwischen "second_factor_required" (Formular soll TOTP-Feld + // einblenden) und "invalid_credentials" (alles andere), ohne dem Client + // mehr ueber den tatsaechlichen Fehlgrund zu verraten. + Error string `json:"error"` + Code string `json:"code"` +} + +// Login ist der einzige Login-Endpunkt des Frontends (Akzeptanzkriterium 1). +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + token, err := LoginWithTOTP(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode) + if err != nil { + w.Header().Set("Content-Type", "application/json") + if err == ErrSecondFactorRequired { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(loginErrorResponse{ + Error: "Anmeldedaten oder Code ungültig.", + Code: "second_factor_required", + }) + return + } + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(loginErrorResponse{ + Error: "Anmeldedaten oder Code ungültig.", + Code: "invalid_credentials", + }) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(auth.AccessTokenTTL.Seconds()), + }) + w.WriteHeader(http.StatusOK) +} + +type statusResponse struct { + Enabled bool `json:"enabled"` +} + +// Status liefert, ob 2FA fuer den angemeldeten Benutzer aktiv ist — fuer die +// Profilseite (Akzeptanzkriterium 2: zeigt an, ob Einrichtung schon +// stattgefunden hat). Muss hinter auth.RequireAuth haengen. +func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + enabled, err := h.totp.IsEnabled(r.Context(), claims.UserID) + if err != nil { + http.Error(w, "status konnte nicht ermittelt werden", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(statusResponse{Enabled: enabled}) +} + +type setupBeginResponse struct { + Secret string `json:"secret"` + ProvisioningURI string `json:"provisioning_uri"` +} + +// SetupBegin startet die 2FA-Einrichtung (Akzeptanzkriterium 2) — muss hinter +// auth.RequireAuth haengen. +func (h *Handler) SetupBegin(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + u, err := h.users.Get(r.Context(), claims.UserID) + if err != nil { + http.Error(w, "benutzer nicht gefunden", http.StatusNotFound) + return + } + + secret, uri, err := h.totp.BeginSetup(r.Context(), claims.UserID, h.issuer, u.Email) + if err != nil { + http.Error(w, "einrichtung konnte nicht gestartet werden", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(setupBeginResponse{Secret: secret, ProvisioningURI: uri}) +} + +type setupConfirmRequest struct { + Code string `json:"code"` +} + +type setupConfirmResponse struct { + RecoveryCodes []string `json:"recovery_codes"` +} + +// SetupConfirm bestaetigt die Einrichtung und liefert die Wiederherstellungscodes +// EINMALIG im Klartext (Akzeptanzkriterium 2) — muss hinter auth.RequireAuth haengen. +func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + + var req setupConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + codes, err := h.totp.ConfirmSetup(r.Context(), claims.UserID, req.Code) + if err != nil { + http.Error(w, "Code ungültig.", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes}) +} diff --git a/internal/totp/handler_test.go b/internal/totp/handler_test.go new file mode 100644 index 0000000..d3aad54 --- /dev/null +++ b/internal/totp/handler_test.go @@ -0,0 +1,171 @@ +package totp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string { + t.Helper() + u, err := users.Get(context.Background(), userID) + if err != nil { + t.Fatalf("benutzer laden: %v", err) + } + return u.Email +} + +// authenticatedRequest baut einen Request mit echtem, gueltigem +// Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) — +// kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13). +func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request { + t.Helper() + token, err := issuer.Issue(userID, "acme") + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token}) + return req +} + +// Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal, +// Login mit falschem Passwort liefert die generische Fehlermeldung. +func TestHandlerLogin_WithoutTOTP(t *testing.T) { + store, users, userID, cleanup := setupTest(t) + defer cleanup() + + login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") + h := NewHandler(users, store, login, "NEXARCH") + + body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.Login(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } + if len(rec.Result().Cookies()) == 0 { + t.Fatal("erwartet gesetztes session-cookie") + } +} + +func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) { + store, users, userID, cleanup := setupTest(t) + defer cleanup() + + login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") + h := NewHandler(users, store, login, "NEXARCH") + + body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.Login(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + var resp loginErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if resp.Error != "Anmeldedaten oder Code ungültig." { + t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error) + } +} + +// Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code, +// Setup-Confirm liefert die Wiederherstellungscodes EINMALIG. +func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) { + store, users, userID, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com") + if err != nil { + t.Fatalf("begin setup: %v", err) + } + code, err := GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("code generieren: %v", err) + } + if _, err := store.ConfirmSetup(ctx, userID, code); err != nil { + t.Fatalf("confirm setup: %v", err) + } + + login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") + h := NewHandler(users, store, login, "NEXARCH") + email := emailForUser(t, users, userID) + + // Ohne Code: second_factor_required, kein Cookie. + body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.Login(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("ohne code: status = %d, want 401", rec.Code) + } + var resp loginErrorResponse + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Code != "second_factor_required" { + t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code) + } + + // Mit gueltigem Code: Login gelingt. + freshCode, _ := GenerateCode(secret, time.Now()) + body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode}) + req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2)) + rec2 := httptest.NewRecorder() + h.Login(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String()) + } +} + +func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) { + store, users, userID, cleanup := setupTest(t) + defer cleanup() + + login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") + sessionIssuer := auth.NewTokenIssuer("test-session-secret") + h := NewHandler(users, store, login, "NEXARCH") + + beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil)) + beginRec := httptest.NewRecorder() + auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq) + if beginRec.Code != http.StatusOK { + t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String()) + } + var beginResp setupBeginResponse + if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil { + t.Fatalf("begin-response dekodieren: %v", err) + } + + code, err := GenerateCode(beginResp.Secret, time.Now()) + if err != nil { + t.Fatalf("code generieren: %v", err) + } + confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code}) + confirmReq := authenticatedRequest(t, sessionIssuer, userID, + httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody))) + confirmRec := httptest.NewRecorder() + auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq) + + if confirmRec.Code != http.StatusOK { + t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String()) + } + var confirmResp setupConfirmResponse + if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil { + t.Fatalf("confirm-response dekodieren: %v", err) + } + if len(confirmResp.RecoveryCodes) != RecoveryCodeCount { + t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes)) + } +} diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go index 46e952e..f6f0bfa 100644 --- a/internal/user/tenant_store.go +++ b/internal/user/tenant_store.go @@ -126,6 +126,26 @@ type AuthCredentials struct { PasswordHash string } +// GetByIDForAuth liefert Benutzer + Passwort-Hash zu einer User-ID — fuer +// IAM-08 (Passwortaenderung: die bestehende Session liefert die ID, nicht +// die E-Mail-Adresse, gleiches Prinzip wie GetByEmailForAuth). +func (s *TenantUserStore) GetByIDForAuth(ctx context.Context, id string) (AuthCredentials, error) { + var c AuthCredentials + row := s.pool.QueryRow(ctx, ` + SELECT id, email, name, status, created_at, updated_at, password_hash + FROM users WHERE id = $1 + `, id) + + if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status, + &c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return AuthCredentials{}, ErrNotFound + } + return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err) + } + return c, nil +} + // GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse // aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit // zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich diff --git a/web/account/app/layout.tsx b/web/account/app/layout.tsx new file mode 100644 index 0000000..98d5597 --- /dev/null +++ b/web/account/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Anmeldung & Profil", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/account/app/login/page.tsx b/web/account/app/login/page.tsx new file mode 100644 index 0000000..ce5e067 --- /dev/null +++ b/web/account/app/login/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { TextField, useToast } from "@nexarch/shl"; +import { ApiError, login } from "../../lib/api"; + +export default function LoginPage() { + const { push } = useToast(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [needsSecondFactor, setNeedsSecondFactor] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(email, password, needsSecondFactor ? totpCode : undefined); + push("Anmeldung erfolgreich.", "success"); + window.location.href = "/profile"; + } catch (err) { + if (err instanceof ApiError) { + // Akzeptanzkriterium 3 / Pruefung 1: nur die generische, vom Backend + // gelieferte Meldung anzeigen — keine eigene, evtl. praezisere + // Fehlerursache im Frontend herleiten oder ergaenzen. + setError(err.message); + if (err.code === "second_factor_required") { + setNeedsSecondFactor(true); + } + } else { + setError("Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."); + } + } finally { + setSubmitting(false); + } + } + + return ( +
+

Anmeldung

+
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + {needsSecondFactor && ( + setTotpCode(e.target.value)} + hint="Öffnen Sie Ihre Authenticator-App oder geben Sie einen Wiederherstellungscode ein." + /> + )} + {error && ( +

+ {error} +

+ )} + + +

+ Passwort vergessen? +

+
+ ); +} diff --git a/web/account/app/page.tsx b/web/account/app/page.tsx new file mode 100644 index 0000000..d1ecd11 --- /dev/null +++ b/web/account/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function IndexPage() { + redirect("/login"); +} diff --git a/web/account/app/password-reset/complete/page.tsx b/web/account/app/password-reset/complete/page.tsx new file mode 100644 index 0000000..b3d69f7 --- /dev/null +++ b/web/account/app/password-reset/complete/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { TextField } from "@nexarch/shl"; +import { ApiError, completePasswordReset } from "../../../lib/api"; + +export default function PasswordResetCompletePage() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await completePasswordReset(token, newPassword); + setDone(true); + } catch (err) { + // Akzeptanzkriterium 3: generische Meldung, keine interne Fehlerursache. + setError(err instanceof ApiError ? err.message : "Der Link ist ungültig oder abgelaufen."); + } finally { + setSubmitting(false); + } + } + + if (done) { + return ( +
+

Passwort geändert

+

+ Ihr Passwort wurde erfolgreich geändert. Jetzt anmelden. +

+
+ ); + } + + return ( +
+

Neues Passwort festlegen

+ {!token && ( +

+ Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an. +

+ )} +
+ setNewPassword(e.target.value)} + /> + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/web/account/app/password-reset/request/page.tsx b/web/account/app/password-reset/request/page.tsx new file mode 100644 index 0000000..994a5b8 --- /dev/null +++ b/web/account/app/password-reset/request/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { TextField } from "@nexarch/shl"; +import { requestPasswordReset } from "../../../lib/api"; + +export default function PasswordResetRequestPage() { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + try { + // Antwort ist absichtlich IMMER dieselbe (Backend-Garantie, + // Akzeptanzkriterium 3) — das Frontend erfindet keine differenzierte + // Anzeige je nachdem ob das Konto existiert. + const result = await requestPasswordReset(email); + setMessage(result.message); + } catch { + setMessage("Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Passwort zurücksetzen

+ {message ? ( +

{message}

+ ) : ( +
+ setEmail(e.target.value)} + /> + + + )} +

+ Zurück zur Anmeldung +

+
+ ); +} diff --git a/web/account/app/profile/page.tsx b/web/account/app/profile/page.tsx new file mode 100644 index 0000000..44d235d --- /dev/null +++ b/web/account/app/profile/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { TextField, useToast } from "@nexarch/shl"; +import { + ApiError, + Me, + beginTotpSetup, + changePassword, + confirmTotpSetup, + fetchMe, + fetchTotpStatus, +} from "../../lib/api"; + +export default function ProfilePage() { + const { push } = useToast(); + const [me, setMe] = useState(null); + const [totpEnabled, setTotpEnabled] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + Promise.all([fetchMe(), fetchTotpStatus()]) + .then(([meResult, statusResult]) => { + setMe(meResult); + setTotpEnabled(statusResult.enabled); + }) + .catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen.")); + }, []); + + if (loadError) { + return ( +
+

{loadError}

+ Zur Anmeldung +
+ ); + } + + return ( +
+

Mein Profil

+ {me && ( +
+

Stammdaten

+

+ {me.name} — {me.email} +

+
+ )} + + push("Passwort geändert.", "success")} /> + + setTotpEnabled(true)} + onNotice={(text) => push(text, "info")} + /> +
+ ); +} + +function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await changePassword(currentPassword, newPassword); + setCurrentPassword(""); + setNewPassword(""); + onSuccess(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Passwort ändern

+
+ setCurrentPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + /> + {error && ( +

+ {error} +

+ )} + + +
+ ); +} + +function TotpSection({ + enabled, + onEnabled, + onNotice, +}: { + enabled: boolean | null; + onEnabled: () => void; + onNotice: (text: string) => void; +}) { + const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null); + const [confirmCode, setConfirmCode] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onBeginSetup() { + setError(null); + setBusy(true); + try { + const result = await beginTotpSetup(); + setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden."); + } finally { + setBusy(false); + } + } + + async function onConfirm(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const result = await confirmTotpSetup(confirmCode); + // Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG + // im Klartext angezeigt — die Backend-API liefert sie an keiner + // anderen Stelle je wieder aus (siehe internal/totp/store.go). + setRecoveryCodes(result.recovery_codes); + setSetupData(null); + onEnabled(); + onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf."); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Code ungültig."); + } finally { + setBusy(false); + } + } + + return ( +
+

Zwei-Faktor-Authentifizierung

+ + {recoveryCodes ? ( +
+

+ Wiederherstellungscodes — jetzt notieren, sie werden nicht erneut angezeigt: +

+
    + {recoveryCodes.map((code) => ( +
  • + {code} +
  • + ))} +
+
+ ) : enabled ? ( +

Zwei-Faktor-Authentifizierung ist aktiv.

+ ) : setupData ? ( +
+

+ Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein: +

+

+ {setupData.secret} +

+

+ {setupData.provisioningUri} +

+ setConfirmCode(e.target.value)} + /> + {error && ( +

+ {error} +

+ )} + + + ) : ( + <> +

Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.

+ {error && ( +

+ {error} +

+ )} + + + )} +
+ ); +} diff --git a/web/account/lib/api.ts b/web/account/lib/api.ts new file mode 100644 index 0000000..263e5ef --- /dev/null +++ b/web/account/lib/api.ts @@ -0,0 +1,92 @@ +// Duenne fetch-Wrapper gegen die IAM-08-Backend-Endpunkte. Kein Framework, +// keine Abstraktionsschicht ueber das Notwendige hinaus (Produkt-DNA: +// schlanker Go-Dienst + schmales Frontend statt schwergewichtiger Plattform). + +const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? ""; + +export class ApiError extends Error { + code?: string; + constructor(message: string, code?: string) { + super(message); + this.code = code; + } +} + +async function postJSON(path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code); + } + return data as T; +} + +async function getJSON(path: string): Promise { + const res = await fetch(`${API_BASE}${path}`, { credentials: "include" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code); + } + return data as T; +} + +export interface LoginResult { + ok: true; +} + +export async function login(email: string, password: string, totpCode?: string): Promise { + await postJSON("/auth/login", { email, password, totp_code: totpCode ?? "" }); + return { ok: true }; +} + +export async function requestPasswordReset(email: string): Promise<{ message: string }> { + return postJSON("/auth/password-reset/request", { email }); +} + +export async function completePasswordReset(token: string, newPassword: string): Promise { + await postJSON("/auth/password-reset/complete", { token, new_password: newPassword }); +} + +export async function changePassword(currentPassword: string, newPassword: string): Promise { + await postJSON("/account/change-password", { current_password: currentPassword, new_password: newPassword }); +} + +export interface Me { + id: string; + email: string; + name: string; +} + +export async function fetchMe(): Promise { + return getJSON("/account/me"); +} + +export interface TotpStatus { + enabled: boolean; +} + +export async function fetchTotpStatus(): Promise { + return getJSON("/auth/totp/status"); +} + +export interface TotpSetupBegin { + secret: string; + provisioning_uri: string; +} + +export async function beginTotpSetup(): Promise { + return postJSON("/auth/totp/setup/begin", {}); +} + +export interface TotpSetupConfirm { + recovery_codes: string[]; +} + +export async function confirmTotpSetup(code: string): Promise { + return postJSON("/auth/totp/setup/confirm", { code }); +} diff --git a/web/account/next.config.mjs b/web/account/next.config.mjs new file mode 100644 index 0000000..fa7f635 --- /dev/null +++ b/web/account/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules — + // Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md. + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/account/package.json b/web/account/package.json new file mode 100644 index 0000000..3cb87f4 --- /dev/null +++ b/web/account/package.json @@ -0,0 +1,22 @@ +{ + "name": "nexarch-account", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/account/tsconfig.json b/web/account/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/account/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}