Compare commits

...

2 Commits

Author SHA1 Message Date
m
118bae1ae3 feat: HL tenant setup + email domain auto-assignment
- Create pre-configured Hogan Lovells tenant with demo flag and
  auto_assign_domains: ["hoganlovells.com"]
- Add POST /api/tenants/auto-assign endpoint: checks email domain
  against tenant settings, auto-assigns user as associate if match
- Add AutoAssignByDomain to TenantService
- Update registration flow: after signup, check auto-assign before
  showing tenant creation form. Skip tenant creation if auto-assigned.
- Add DemoBanner component shown when tenant.settings.demo is true
- Extend GET /api/me to return is_demo flag from tenant settings
2026-03-30 11:24:52 +02:00
m
34dcbb74fe fix: resolve merge conflicts from role-based permissions + audit trail branches
Combines auth context keys (user role, IP, user-agent), tenant resolver
(GetUserRole-based access verification), middleware (deferred tenant
resolution + request info capture), and router (audit log + notifications
+ assignments).
2026-03-30 11:24:43 +02:00
13 changed files with 235 additions and 153 deletions

View File

@@ -9,19 +9,11 @@ import (
type contextKey string type contextKey string
const ( const (
<<<<<<< HEAD
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address" ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent" userAgentKey contextKey = "user_agent"
||||||| 82878df
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
=======
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
>>>>>>> mai/pike/p0-role-based
) )
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -41,7 +33,15 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID) id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok return id, ok
} }
<<<<<<< HEAD
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip) ctx = context.WithValue(ctx, ipKey, ip)
@@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string {
} }
return nil return nil
} }
||||||| 82878df
=======
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
>>>>>>> mai/pike/p0-role-based

View File

@@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
} }
ctx := ContextWithUserID(r.Context(), userID) ctx := ContextWithUserID(r.Context(), userID)
<<<<<<< HEAD
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
||||||| 82878df
// Resolve tenant and role from user_tenants
var membership struct {
TenantID uuid.UUID `db:"tenant_id"`
Role string `db:"role"`
}
err = m.db.GetContext(r.Context(), &membership,
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
=======
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
// Capture IP and user-agent for audit logging // Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get("X-Forwarded-For")
@@ -73,7 +43,8 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
} }
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
>>>>>>> mai/knuth/p0-audit-trail-append // Tenant/role resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -12,12 +12,7 @@ import (
// Defined as an interface to avoid circular dependency with services. // Defined as an interface to avoid circular dependency with services.
type TenantLookup interface { type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
||||||| 82878df
=======
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
>>>>>>> mai/pike/p0-role-based
} }
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -46,38 +41,22 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
<<<<<<< HEAD // Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
// Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
if err != nil { if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if !hasAccess { if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return return
} }
||||||| 82878df
=======
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
return
}
>>>>>>> mai/pike/p0-role-based
tenantID = parsed tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role)) r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else { } else {
// Default to user's first tenant (role already set by middleware) // Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil { if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
@@ -89,6 +68,15 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return return
} }
tenantID = *first tenantID = *first
// Resolve role for default tenant
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to resolve role for default tenant", "error", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} }
ctx := ContextWithTenantID(r.Context(), tenantID) ctx := ContextWithTenantID(r.Context(), tenantID)

View File

@@ -10,49 +10,26 @@ import (
) )
type mockTenantLookup struct { type mockTenantLookup struct {
<<<<<<< HEAD
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
||||||| 82878df
tenantID *uuid.UUID
err error
=======
tenantID *uuid.UUID tenantID *uuid.UUID
role string role string
roleSet bool // true means role was explicitly set (even if empty)
err error err error
>>>>>>> mai/pike/p0-role-based
} }
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err return m.tenantID, m.err
} }
<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
||||||| 82878df
=======
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" { if m.roleSet {
return m.role, m.err return m.role, m.err
} }
return "associate", m.err return "associate", m.err
} }
>>>>>>> mai/pike/p0-role-based
func TestTenantResolver_FromHeader(t *testing.T) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
<<<<<<< HEAD
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
||||||| 82878df
tr := NewTenantResolver(&mockTenantLookup{})
=======
tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
>>>>>>> mai/pike/p0-role-based
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +58,7 @@ func TestTenantResolver_FromHeader(t *testing.T) {
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false}) tr := NewTenantResolver(&mockTenantLookup{role: "", roleSet: true})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called") t.Fatal("next should not be called")
@@ -101,7 +78,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
func TestTenantResolver_DefaultsToFirst(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
<<<<<<< HEAD if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
writeError(w, http.StatusNotFound, "deadline not found") writeError(w, http.StatusNotFound, "deadline not found")
||||||| 82878df
err = h.deadlines.Delete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
=======
err = h.deadlines.Delete(r.Context(), tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
>>>>>>> mai/knuth/p0-audit-trail-append
return return
} }

View File

@@ -356,6 +356,71 @@ func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request)
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK) jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
} }
// AutoAssign handles POST /api/tenants/auto-assign — checks if the user's email domain
// matches any tenant's auto_assign_domains and assigns them if so.
func (h *TenantHandler) AutoAssign(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
jsonError(w, "email is required", http.StatusBadRequest)
return
}
// Extract domain from email
parts := splitEmail(req.Email)
if parts == "" {
jsonError(w, "invalid email format", http.StatusBadRequest)
return
}
result, err := h.svc.AutoAssignByDomain(r.Context(), userID, parts)
if err != nil {
slog.Error("auto-assign failed", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if result == nil {
jsonResponse(w, map[string]any{"assigned": false}, http.StatusOK)
return
}
jsonResponse(w, map[string]any{
"assigned": true,
"tenant_id": result.ID,
"name": result.Name,
"slug": result.Slug,
"role": result.Role,
"settings": result.Settings,
}, http.StatusOK)
}
// splitEmail extracts the domain part from an email address.
func splitEmail(email string) string {
at := -1
for i, c := range email {
if c == '@' {
at = i
break
}
}
if at < 0 || at >= len(email)-1 {
return ""
}
return email[at+1:]
}
// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant. // GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) { func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context()) userID, ok := auth.UserFromContext(r.Context())
@@ -370,11 +435,26 @@ func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
// Get user's permissions for frontend UI // Get user's permissions for frontend UI
perms := auth.GetRolePermissions(role) perms := auth.GetRolePermissions(role)
// Check if tenant is in demo mode
isDemo := false
if tenant, err := h.svc.GetByID(r.Context(), tenantID); err == nil && tenant != nil {
var settings map[string]json.RawMessage
if json.Unmarshal(tenant.Settings, &settings) == nil {
if demoRaw, ok := settings["demo"]; ok {
var demo bool
if json.Unmarshal(demoRaw, &demo) == nil {
isDemo = demo
}
}
}
}
jsonResponse(w, map[string]any{ jsonResponse(w, map[string]any{
"user_id": userID, "user_id": userID,
"tenant_id": tenantID, "tenant_id": tenantID,
"role": role, "role": role,
"permissions": perms, "permissions": perms,
"is_demo": isDemo,
}, http.StatusOK) }, http.StatusOK)
} }

View File

@@ -29,14 +29,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineRuleSvc := services.NewDeadlineRuleService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
<<<<<<< HEAD
documentSvc := services.NewDocumentService(db, storageCli, auditSvc) documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
||||||| 82878df
documentSvc := services.NewDocumentService(db, storageCli)
=======
documentSvc := services.NewDocumentService(db, storageCli)
assignmentSvc := services.NewCaseAssignmentService(db) assignmentSvc := services.NewCaseAssignmentService(db)
>>>>>>> mai/pike/p0-role-based
// AI service (optional — only if API key is configured) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler var aiH *handlers.AIHandler
@@ -79,6 +73,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
api := http.NewServeMux() api := http.NewServeMux()
// Tenant management (no tenant resolver — these operate across tenants) // Tenant management (no tenant resolver — these operate across tenants)
api.HandleFunc("POST /api/tenants/auto-assign", tenantH.AutoAssign)
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant) api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
api.HandleFunc("GET /api/tenants", tenantH.ListTenants) api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant) api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
@@ -162,16 +157,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Dashboard — all can view // Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
<<<<<<< HEAD
// Audit log // Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List) scoped.HandleFunc("GET /api/audit-log", perm(auth.PermViewAuditLog, auditH.List))
// Documents
||||||| 82878df
// Documents
=======
// Documents — all can upload, delete checked in handler (own vs all) // Documents — all can upload, delete checked in handler (own vs all)
>>>>>>> mai/pike/p0-role-based
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
@@ -185,7 +174,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
} }
<<<<<<< HEAD
// Notifications // Notifications
if notifH != nil { if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List) scoped.HandleFunc("GET /api/notifications", notifH.List)
@@ -196,12 +184,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
} }
// CalDAV sync endpoints
||||||| 82878df
// CalDAV sync endpoints
=======
// CalDAV sync endpoints — settings permission required // CalDAV sync endpoints — settings permission required
>>>>>>> mai/pike/p0-role-based
if calDAVSvc != nil { if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc) calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))

View File

@@ -240,6 +240,54 @@ func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID u
return nil return nil
} }
// AutoAssignByDomain finds a tenant with a matching auto_assign_domains setting
// and adds the user as a member. Returns the tenant and role, or nil if no match.
func (s *TenantService) AutoAssignByDomain(ctx context.Context, userID uuid.UUID, emailDomain string) (*models.TenantWithRole, error) {
// Find tenant where settings.auto_assign_domains contains this domain
var tenant models.Tenant
err := s.db.GetContext(ctx, &tenant,
`SELECT id, name, slug, settings, created_at, updated_at
FROM tenants
WHERE settings->'auto_assign_domains' ? $1
LIMIT 1`,
emailDomain,
)
if err != nil {
return nil, nil // no match — not an error
}
// Check if already a member
var exists bool
err = s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenant.ID,
)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if exists {
// Already a member — return the existing membership
role, err := s.GetUserRole(ctx, userID, tenant.ID)
if err != nil {
return nil, fmt.Errorf("get existing role: %w", err)
}
return &models.TenantWithRole{Tenant: tenant, Role: role}, nil
}
// Add as member (associate by default for auto-assigned users)
role := "associate"
_, err = s.db.ExecContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)`,
userID, tenant.ID, role,
)
if err != nil {
return nil, fmt.Errorf("auto-assign user: %w", err)
}
s.audit.Log(ctx, "create", "auto_membership", &tenant.ID, map[string]any{"domain": emailDomain}, map[string]any{"user_id": userID, "role": role})
return &models.TenantWithRole{Tenant: tenant, Role: role}, nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner. // RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error { func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner // Check if the user being removed is an owner

View File

@@ -1,5 +1,6 @@
import { Sidebar } from "@/components/layout/Sidebar"; import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { DemoBanner } from "@/components/layout/DemoBanner";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +13,7 @@ export default function AppLayout({
<div className="flex h-screen overflow-hidden bg-neutral-50"> <div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar /> <Sidebar />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<DemoBanner />
<Header /> <Header />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main> <main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div> </div>

View File

@@ -5,12 +5,22 @@ import { api } from "@/lib/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
interface AutoAssignResponse {
assigned: boolean;
tenant_id?: string;
name?: string;
slug?: string;
role?: string;
settings?: Record<string, unknown>;
}
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [firmName, setFirmName] = useState(""); const [firmName, setFirmName] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showFirmName, setShowFirmName] = useState(true);
const router = useRouter(); const router = useRouter();
const supabase = createClient(); const supabase = createClient();
@@ -34,8 +44,30 @@ export default function RegisterPage() {
return; return;
} }
// 2. Create tenant via backend (the backend adds the user as owner)
if (data.session) { if (data.session) {
// 2. Check if email domain matches an existing tenant for auto-assignment
try {
const result = await api.post<AutoAssignResponse>("/tenants/auto-assign", { email });
if (result.assigned && result.tenant_id) {
// Auto-assigned — store tenant and go to dashboard
localStorage.setItem("kanzlai_tenant_id", result.tenant_id);
router.push("/");
router.refresh();
return;
}
} catch {
// Auto-assign failed — fall through to manual tenant creation
}
// 3. No auto-assignment — create tenant manually
if (!firmName) {
// Show firm name field if not yet visible
setShowFirmName(true);
setError("Bitte geben Sie einen Kanzleinamen ein");
setLoading(false);
return;
}
try { try {
await api.post("/tenants", { name: firmName }); await api.post("/tenants", { name: firmName });
} catch (err: unknown) { } catch (err: unknown) {
@@ -68,23 +100,27 @@ export default function RegisterPage() {
</div> </div>
<form onSubmit={handleRegister} className="space-y-4"> <form onSubmit={handleRegister} className="space-y-4">
<div> {showFirmName && (
<label <div>
htmlFor="firm" <label
className="block text-sm font-medium text-neutral-700" htmlFor="firm"
> className="block text-sm font-medium text-neutral-700"
Kanzleiname >
</label> Kanzleiname
<input </label>
id="firm" <input
type="text" id="firm"
value={firmName} type="text"
onChange={(e) => setFirmName(e.target.value)} value={firmName}
required onChange={(e) => setFirmName(e.target.value)}
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900" className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte" placeholder="Muster & Partner Rechtsanwaelte"
/> />
</div> <p className="mt-1 text-xs text-neutral-400">
Leer lassen, falls Sie zu einer bestehenden Kanzlei eingeladen wurden
</p>
</div>
)}
<div> <div>
<label <label

View File

@@ -0,0 +1,17 @@
"use client";
import { usePermissions } from "@/lib/hooks/usePermissions";
export function DemoBanner() {
const { isDemo, isLoading } = usePermissions();
if (isLoading || !isDemo) return null;
return (
<div className="flex items-center justify-center gap-2 bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm text-amber-800">
<span className="font-medium">Demo-Modus</span>
<span className="text-amber-600">&mdash;</span>
<span>Keine echten Mandantendaten eingeben</span>
</div>
);
}

View File

@@ -25,5 +25,6 @@ export function usePermissions() {
isLoading, isLoading,
userId: data?.user_id ?? null, userId: data?.user_id ?? null,
tenantId: data?.tenant_id ?? null, tenantId: data?.tenant_id ?? null,
isDemo: data?.is_demo ?? false,
}; };
} }

View File

@@ -202,6 +202,7 @@ export interface UserInfo {
tenant_id: string; tenant_id: string;
role: UserRole; role: UserRole;
permissions: string[]; permissions: string[];
is_demo: boolean;
} }
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary"; export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";