Tenant-Feature Phase 3b: Login-Redirect + Tenant-Context in Manage-UI

- reqcontext-Package: shared contextKey für httpapi und manage
- Login-Redirect: Tenant-User → /manage/<slug>, 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 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 18:00:02 +01:00
parent 0b21be6469
commit 27c4562175
5 changed files with 67 additions and 24 deletions

View file

@ -31,8 +31,14 @@ func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Redirect if already logged in. // Redirect if already logged in.
if cookie, err := r.Cookie(sessionCookieName); err == nil { if cookie, err := r.Cookie(sessionCookieName); err == nil {
if _, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil { if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
if u.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther) 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 return
} }
} }
@ -108,10 +114,13 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
case "admin": case "admin":
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
default: default:
// Tenant users Phase 3 will provide the full tenant slug; for now fall back to /admin. if user.TenantSlug != "" {
http.Redirect(w, r, "/manage/"+user.TenantSlug, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
} }
} }
}
} }
// HandleLogoutPost deletes the session and clears the cookie (POST /logout). // HandleLogoutPost deletes the session and clears the cookie (POST /logout).

View file

@ -13,6 +13,7 @@ import (
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "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" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
@ -90,7 +91,10 @@ func HandleManageUI(
return 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 { if tenant == nil {
tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"} tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
} }
@ -151,9 +155,13 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
orientation = "landscape" 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 { if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError) http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return return
} }
@ -194,9 +202,13 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
orientation = "landscape" 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 { if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError) http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return return
} }

View file

@ -5,22 +5,16 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "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, // UserFromContext returns the authenticated *store.User stored in ctx,
// or nil if none is present. // 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 { func UserFromContext(ctx context.Context) *store.User {
u, _ := ctx.Value(contextKeyUser).(*store.User) return reqcontext.UserFromContext(ctx)
return u
} }
// RequireAuth returns middleware that validates the morz_session cookie. // RequireAuth returns middleware that validates the morz_session cookie.
@ -41,7 +35,7 @@ func RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler {
return return
} }
ctx := context.WithValue(r.Context(), contextKeyUser, user) ctx := reqcontext.WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View file

@ -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
}

View file

@ -44,12 +44,14 @@ type AuthStore struct{ pool *pgxpool.Pool }
func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} } func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} }
// GetUserByUsername returns the user with the given username or pgx.ErrNoRows. // 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) { func (s *AuthStore) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.pool.QueryRow(ctx, row := s.pool.QueryRow(ctx,
`select id, tenant_id, username, password_hash, role, created_at `select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
from users from users u
where username = $1`, username) left join tenants t on t.id = u.tenant_id
return scanUser(row) where u.username = $1`, username)
return scanUserWithSlug(row)
} }
// CreateSession inserts a new session for userID with the given TTL and returns the session. // CreateSession inserts a new session for userID with the given TTL and returns the session.