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'
116 lines
3.6 KiB
Go
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.
|