Files
paliad/internal/handlers/users.go
m b34500ad31 feat(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.

Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:

* paliad.users.job_title — free text, NULL allowed, display only.
  NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
  default 'standard'. The only thing that gates ops.

Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
  their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
  with the historic u.role IN ('partner','admin') gates simplified
  to u.global_role='global_admin' only (job_title NEVER gates)

Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
  User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
  global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
  UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
  global_role with last-admin demotion guard (ErrLastGlobalAdmin);
  IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
  department/party/note/checklist_instance): pass user.GlobalRole into
  visibility predicates; partner-or-admin gates simplified to
  global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
  admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
  -> JobTitle (*string)

Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
  'Standard'|'Global Admin' badge + dropdown editor; last-admin
  demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
  gates dropped, only global_admin grants those operations

Tests:
* user_service_test: bootstrap promotes first user to global_admin,
  subsequent default to standard; AdminUpdateUser refuses to demote
  the last global_admin; IsAdmin reads global_role

Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
2026-04-27 14:59:03 +02:00

116 lines
3.6 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/me — returns the caller's paliad.users row (or 404 if onboarding
// hasn't happened yet). The frontend uses this to gate role-specific UI
// (partner/admin-only delete, partner-only firm_wide_visible checkbox, etc.).
//
// The 404 body includes the caller's JWT email so the onboarding form can
// pre-fill the display name from the email prefix without a second request.
func handleGetMe(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
u, err := dbSvc.users.GetByID(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if u == nil {
body := map[string]string{
"error": "no paliad.users row — onboarding required",
}
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
body["email"] = claims.Email
}
writeJSON(w, http.StatusNotFound, body)
return
}
writeJSON(w, http.StatusOK, u)
}
// PATCH /api/me — mutates the caller's paliad.users row. The settings page
// sends only the fields the user touched; other fields stay as-is. Email is
// *not* updatable here — auth.users owns the email and any attempt to set it
// via this endpoint is ignored (the decode silently drops unknown fields).
func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
// Decode into a permissive map first so we can detect (and reject) a
// client that *tried* to change their email — keeping behaviour explicit
// is friendlier than a silent no-op.
var peek map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&peek); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if _, tryingEmail := peek["email"]; tryingEmail {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "email cannot be changed — contact an administrator",
})
return
}
var input services.UpdateProfileInput
// Re-serialise and decode into the typed struct so strict field mapping
// applies without a second body read.
if raw, err := json.Marshal(peek); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
} else if err := json.Unmarshal(raw, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
u, err := dbSvc.users.UpdateProfile(r.Context(), uid, input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no paliad.users row — onboarding required",
})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return
}
writeJSON(w, http.StatusOK, u)
}
// GET /api/users — minimal user list for the collaborator picker. Only callable
// by authenticated users. Response is the full models.User list (email +
// display_name + office + role).
func handleListUsers(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
users, err := dbSvc.users.List(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, users)
}
// Removed — superseded by handleListProjectEvents in projects.go.