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:
parent
0b21be6469
commit
27c4562175
5 changed files with 67 additions and 24 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
server/backend/internal/reqcontext/reqcontext.go
Normal file
26
server/backend/internal/reqcontext/reqcontext.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue