From 27c456217500dfbcc0417986b726340cf3a58b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Mon, 23 Mar 2026 18:00:02 +0100 Subject: [PATCH] Tenant-Feature Phase 3b: Login-Redirect + Tenant-Context in Manage-UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reqcontext-Package: shared contextKey für httpapi und manage - Login-Redirect: Tenant-User → /manage/, Admin → /admin - GetUserByUsername: LEFT JOIN tenants für TenantSlug-Befüllung - manage/ui.go: reqcontext.UserFromContext statt hardcoded "morz" Co-Authored-By: Claude Sonnet 4.6 --- .../backend/internal/httpapi/manage/auth.go | 17 +++++++++--- server/backend/internal/httpapi/manage/ui.go | 22 ++++++++++++---- server/backend/internal/httpapi/middleware.go | 16 ++++-------- .../backend/internal/reqcontext/reqcontext.go | 26 +++++++++++++++++++ server/backend/internal/store/auth.go | 10 ++++--- 5 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 server/backend/internal/reqcontext/reqcontext.go diff --git a/server/backend/internal/httpapi/manage/auth.go b/server/backend/internal/httpapi/manage/auth.go index e36ce3d..4664a00 100644 --- a/server/backend/internal/httpapi/manage/auth.go +++ b/server/backend/internal/httpapi/manage/auth.go @@ -31,8 +31,14 @@ func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Redirect if already logged in. if cookie, err := r.Cookie(sessionCookieName); err == nil { - if _, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil { - http.Redirect(w, r, "/admin", http.StatusSeeOther) + if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil { + if u.Role == "admin" { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + } else if u.TenantSlug != "" { + http.Redirect(w, r, "/manage/"+u.TenantSlug, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + } return } } @@ -108,8 +114,11 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler case "admin": http.Redirect(w, r, "/admin", http.StatusSeeOther) default: - // Tenant users – Phase 3 will provide the full tenant slug; for now fall back to /admin. - http.Redirect(w, r, "/admin", http.StatusSeeOther) + if user.TenantSlug != "" { + http.Redirect(w, r, "/manage/"+user.TenantSlug, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + } } } } diff --git a/server/backend/internal/httpapi/manage/ui.go b/server/backend/internal/httpapi/manage/ui.go index 19d37bf..f88fc8d 100644 --- a/server/backend/internal/httpapi/manage/ui.go +++ b/server/backend/internal/httpapi/manage/ui.go @@ -13,6 +13,7 @@ import ( "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" + "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) @@ -90,7 +91,10 @@ func HandleManageUI( return } - tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant + var tenant *store.Tenant + if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { + tenant, _ = tenants.Get(r.Context(), u.TenantSlug) + } if tenant == nil { tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"} } @@ -151,9 +155,13 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore orientation = "landscape" } - tenant, err := tenants.Get(r.Context(), "morz") + tenantSlug := "morz" + if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { + tenantSlug = u.TenantSlug + } + tenant, err := tenants.Get(r.Context(), tenantSlug) if err != nil { - http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError) + http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError) return } @@ -194,9 +202,13 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h orientation = "landscape" } - tenant, err := tenants.Get(r.Context(), "morz") + tenantSlug := "morz" + if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { + tenantSlug = u.TenantSlug + } + tenant, err := tenants.Get(r.Context(), tenantSlug) if err != nil { - http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError) + http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError) return } diff --git a/server/backend/internal/httpapi/middleware.go b/server/backend/internal/httpapi/middleware.go index 081c668..1877202 100644 --- a/server/backend/internal/httpapi/middleware.go +++ b/server/backend/internal/httpapi/middleware.go @@ -5,22 +5,16 @@ import ( "net/http" "net/url" + "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) -// contextKey is an unexported type for context keys in this package. -// Using a named type prevents collisions with keys from other packages. -type contextKey int - -const ( - contextKeyUser contextKey = iota -) - // UserFromContext returns the authenticated *store.User stored in ctx, // or nil if none is present. +// It delegates to reqcontext so that sub-packages (e.g. manage) can share +// the same context key without creating an import cycle. func UserFromContext(ctx context.Context) *store.User { - u, _ := ctx.Value(contextKeyUser).(*store.User) - return u + return reqcontext.UserFromContext(ctx) } // RequireAuth returns middleware that validates the morz_session cookie. @@ -41,7 +35,7 @@ func RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler { return } - ctx := context.WithValue(r.Context(), contextKeyUser, user) + ctx := reqcontext.WithUser(r.Context(), user) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/server/backend/internal/reqcontext/reqcontext.go b/server/backend/internal/reqcontext/reqcontext.go new file mode 100644 index 0000000..a0cbc1b --- /dev/null +++ b/server/backend/internal/reqcontext/reqcontext.go @@ -0,0 +1,26 @@ +// Package reqcontext provides a shared context key and helpers for storing +// the authenticated user in a request context. It lives in its own package so +// that both httpapi (middleware) and httpapi/manage (handlers) can import it +// without creating an import cycle. +package reqcontext + +import ( + "context" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +type contextKey int + +const contextKeyUser contextKey = 0 + +// WithUser returns a new context that carries u. +func WithUser(ctx context.Context, u *store.User) context.Context { + return context.WithValue(ctx, contextKeyUser, u) +} + +// UserFromContext returns the *store.User stored in ctx, or nil if none. +func UserFromContext(ctx context.Context) *store.User { + u, _ := ctx.Value(contextKeyUser).(*store.User) + return u +} diff --git a/server/backend/internal/store/auth.go b/server/backend/internal/store/auth.go index 8000542..c2a56ad 100644 --- a/server/backend/internal/store/auth.go +++ b/server/backend/internal/store/auth.go @@ -44,12 +44,14 @@ type AuthStore struct{ pool *pgxpool.Pool } func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} } // GetUserByUsername returns the user with the given username or pgx.ErrNoRows. +// TenantSlug is populated via LEFT JOIN on tenants. func (s *AuthStore) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := s.pool.QueryRow(ctx, - `select id, tenant_id, username, password_hash, role, created_at - from users - where username = $1`, username) - return scanUser(row) + `select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at + from users u + left join tenants t on t.id = u.tenant_id + where u.username = $1`, username) + return scanUserWithSlug(row) } // CreateSession inserts a new session for userID with the given TTL and returns the session.