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'
This commit is contained in:
@@ -61,9 +61,9 @@ We have one permission today and m's brief says "possibly more later, design wit
|
||||
|
||||
Considered: keep `role` free-text, just normalize so `job_title='global_admin'` means both the title and the permission. Rejected because (a) the title `Counsel Knowledge Lawyer` and the permission `global_admin` are independent — a user can have one without the other (m wants both); (b) the admin-team UI restriction (`ErrAdminBootstrapOnly`) is exactly the symptom of trying to overload one column for two concerns. We're solving the conflation, not preserving it under a new name.
|
||||
|
||||
### 4. The "partner" gate problem (out of scope, flagged)
|
||||
### 4. The "partner" gate — DROPPED entirely (m's three-axis principle)
|
||||
|
||||
Several places gate on `user.Role IN ('partner','admin')`:
|
||||
Mid-implementation m clarified the principle: "firm roles are not project roles are not tool roles". Several places gated on `user.Role IN ('partner','admin')`:
|
||||
|
||||
- `internal/services/party_service.go:100` — delete party
|
||||
- `internal/services/note_service.go:195` — note ops
|
||||
@@ -71,22 +71,24 @@ Several places gate on `user.Role IN ('partner','admin')`:
|
||||
- `internal/services/project_service.go:617` — project ops
|
||||
- `internal/services/checklist_instance_service.go:301` — checklist ops
|
||||
- `internal/services/deadline_service.go:437` — delete deadline
|
||||
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects`, `project_teams`, `notes` deletes
|
||||
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:190,193,207` — UI gates
|
||||
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects_delete`, `project_teams_delete`
|
||||
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:194,208`, `deadlines.ts:69`, `notes.ts:104` — UI gates
|
||||
|
||||
These conflate `partner` (job title) with "partner-level permissions". Today this is **already broken** in production: the onboarding form's datalist suggests "Partner" with a capital P, but the gate checks lowercase `'partner'`, so a user typing "Partner" stores `role='Partner'` and silently fails the gate. No prod row currently uses `role='partner'`, so nothing trips over it.
|
||||
These conflate "Partner" (a firm role / job title) with permission-to-mutate (a tool role). m: firm role and tool role must be orthogonal; the firm role is **display only** and **must never gate ops**.
|
||||
|
||||
**Decision for t-paliad-051:** out of scope per m's brief. Migrate the `'admin'` half of these gates to `global_role='global_admin'`; leave the `'partner'` half pointing at `job_title='partner'` (still broken, still unused). Document as a known limitation, file follow-up. Cleaning up "partner" is its own design (likely a `permissions` array or a second enum value). NOT shipping that now.
|
||||
**Decision:** drop the partner half of every gate. Each of these checks becomes "global_admin only". Production impact is zero — no prod row has `role='partner'` today, so nobody loses a capability they actually had.
|
||||
|
||||
After-rename gate shape:
|
||||
```go
|
||||
// before
|
||||
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
|
||||
// after
|
||||
if user.JobTitle != "partner" && user.GlobalRole != "global_admin" { return ErrForbidden }
|
||||
if user.GlobalRole != "global_admin" { return ErrForbidden }
|
||||
```
|
||||
|
||||
This preserves prod behavior 1:1: nothing in prod has `role='partner'`, nothing in prod will have `job_title='partner'`, only the `global_admin` branch matters in practice. m and tester both keep full access.
|
||||
If a future tool-role system grants partner-level mutations to specific users, it adds a fresh dimension cleanly (text[] permissions or named enums) without touching `job_title`. YAGNI for now — there are no rows that need it.
|
||||
|
||||
**Helper deleted:** the design's first draft kept an `IsPartnerOrGlobalAdmin(u)` helper. It's gone — the gate is just `user.GlobalRole == "global_admin"` everywhere.
|
||||
|
||||
### 5. Data migration
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ export function renderAdminTeam(): string {
|
||||
<th data-i18n="admin.team.col.name">Name</th>
|
||||
<th data-i18n="admin.team.col.email">E-Mail</th>
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.role">Rolle</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.dezernat">Dezernat</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
@@ -80,7 +81,7 @@ export function renderAdminTeam(): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -118,8 +119,8 @@ export function renderAdminTeam(): string {
|
||||
<select id="admin-da-office" name="office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.role">Rolle</label>
|
||||
<input type="text" id="admin-da-role" name="role" placeholder="Associate" />
|
||||
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-dezernat" data-i18n="admin.team.direct_add.dezernat">Dezernat (optional)</label>
|
||||
|
||||
@@ -7,7 +7,8 @@ interface User {
|
||||
display_name: string;
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
dezernat?: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
@@ -29,7 +30,19 @@ interface Unonboarded {
|
||||
}
|
||||
|
||||
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
|
||||
const ROLE_SUGGESTIONS = ["Partner", "Associate", "PA", "Of Counsel", "Referendar/in", "Trainee", "wiss. Mitarbeiter/in", "Sekretariat"];
|
||||
const JOB_TITLE_SUGGESTIONS = [
|
||||
"Partner",
|
||||
"Associate",
|
||||
"PA",
|
||||
"Of Counsel",
|
||||
"Counsel",
|
||||
"Counsel Knowledge Lawyer",
|
||||
"Knowledge Lawyer",
|
||||
"Referendar/in",
|
||||
"Trainee",
|
||||
"wiss. Mitarbeiter/in",
|
||||
"Sekretariat",
|
||||
];
|
||||
|
||||
let users: User[] = [];
|
||||
let offices: Office[] = [];
|
||||
@@ -57,6 +70,11 @@ function fmtDate(iso: string): string {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function permissionLabel(globalRole: string): string {
|
||||
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
|
||||
return t("admin.team.permission.standard") || "Standard";
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersResp, officesResp] = await Promise.all([
|
||||
fetch("/api/admin/users"),
|
||||
@@ -114,7 +132,7 @@ function userMatchesSearch(u: User): boolean {
|
||||
u.display_name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.dezernat ?? "").toLowerCase().includes(q) ||
|
||||
u.role.toLowerCase().includes(q)
|
||||
(u.job_title ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,15 +163,36 @@ function langOptions(selected: string): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function globalAdminCount(): number {
|
||||
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function permissionCell(u: User): string {
|
||||
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
|
||||
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
|
||||
}
|
||||
|
||||
function permissionEditor(u: User): string {
|
||||
// Disable demoting the only remaining global_admin.
|
||||
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
|
||||
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
|
||||
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
|
||||
const disabled = isLastAdmin ? " disabled" : "";
|
||||
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
|
||||
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
||||
}
|
||||
|
||||
function renderRow(u: User): string {
|
||||
if (editingId === u.id) return renderEditRow(u);
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
const jobTitle = u.job_title ?? "";
|
||||
return `
|
||||
<tr data-user-id="${esc(u.id)}">
|
||||
<td class="akten-col-title">${esc(u.display_name)}</td>
|
||||
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
||||
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${u.role === "admin" ? `<strong>${esc(u.role)}</strong>` : esc(u.role)}</td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${esc(u.dezernat ?? "")}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
@@ -167,18 +206,18 @@ function renderRow(u: User): string {
|
||||
|
||||
function renderEditRow(u: User): string {
|
||||
const additional = u.additional_offices ?? [];
|
||||
const roleList = ROLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
|
||||
const jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
|
||||
const jobTitle = u.job_title ?? "";
|
||||
return `
|
||||
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
|
||||
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
|
||||
<td><span class="admin-team-muted" title="E-Mail kann nicht geändert werden">${esc(u.email)}</span></td>
|
||||
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
|
||||
<td>
|
||||
${u.role === "admin"
|
||||
? `<span class="admin-team-muted">admin</span>`
|
||||
: `<input type="text" class="admin-team-input" data-field="role" value="${esc(u.role)}" list="admin-team-role-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-role-suggest-${esc(u.id)}">${roleList}</datalist>`}
|
||||
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td><input type="text" class="admin-team-input" data-field="dezernat" value="${esc(u.dezernat ?? "")}" /></td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
@@ -215,10 +254,12 @@ function render() {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
// Stable sort: admins first, then by display_name.
|
||||
// Stable sort: global admins first, then by display_name.
|
||||
const sorted = filtered.slice().sort((a, b) => {
|
||||
if (a.role === "admin" && b.role !== "admin") return -1;
|
||||
if (b.role === "admin" && a.role !== "admin") return 1;
|
||||
const aAdmin = a.global_role === "global_admin";
|
||||
const bAdmin = b.global_role === "global_admin";
|
||||
if (aAdmin && !bAdmin) return -1;
|
||||
if (bAdmin && !aAdmin) return 1;
|
||||
return a.display_name.localeCompare(b.display_name);
|
||||
});
|
||||
tbody.innerHTML = sorted.map(renderRow).join("");
|
||||
@@ -306,12 +347,12 @@ function openDirectAddModal() {
|
||||
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
|
||||
const fb = document.getElementById("admin-da-feedback")!;
|
||||
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
|
||||
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
nameField.value = "";
|
||||
roleField.value = "";
|
||||
jobTitleField.value = "";
|
||||
dezField.value = "";
|
||||
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
@@ -362,14 +403,14 @@ function initDirectAddModal() {
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
|
||||
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailSel.value,
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
role: roleField.value.trim() || "Associate",
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
lang: "de",
|
||||
};
|
||||
if (dezField.value.trim()) payload.dezernat = dezField.value.trim();
|
||||
|
||||
@@ -31,7 +31,8 @@ interface DeadlineRule {
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
let deadline: Deadline | null = null;
|
||||
@@ -190,7 +191,7 @@ function render() {
|
||||
// global admin/partner role won't see the inline button — they get a 403
|
||||
// only if they try, but the button itself stays hidden. They can still
|
||||
// PATCH the endpoint directly.
|
||||
if (me && (me.role === "admin" || me.role === "partner")) {
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
reopenBtn.style.display = "";
|
||||
reopenBtn.disabled = false;
|
||||
} else {
|
||||
@@ -204,7 +205,7 @@ function render() {
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
||||
if (me && (me.role === "partner" || me.role === "admin")) {
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
|
||||
@@ -30,7 +30,8 @@ interface Summary {
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
let allDeadlines: Deadline[] = [];
|
||||
@@ -66,7 +67,7 @@ function canReopen(): boolean {
|
||||
// Server enforces global-admin OR project-lead. Client mirrors a subset:
|
||||
// global admins/partners see the inline reopen icon. Project leads without
|
||||
// a global admin/partner role can still reopen via the detail page.
|
||||
return !!me && (me.role === "admin" || me.role === "partner");
|
||||
return !!me && (me.global_role === "global_admin");
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
|
||||
@@ -737,15 +737,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.display_name.placeholder": "Vor- und Nachname",
|
||||
"onboarding.office": "B\u00fcro",
|
||||
"onboarding.office.placeholder": "Bitte ausw\u00e4hlen",
|
||||
"onboarding.role": "Rolle",
|
||||
"onboarding.role.placeholder": "z.B. Associate, Partner, PA",
|
||||
"onboarding.job_title": "Berufsbezeichnung",
|
||||
"onboarding.job_title.placeholder": "z.B. Associate, Partner, PA",
|
||||
"onboarding.dezernat": "Dezernat / Partner",
|
||||
"onboarding.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Profil anlegen",
|
||||
"onboarding.error.display_name": "Bitte Anzeigename eingeben.",
|
||||
"onboarding.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
|
||||
"onboarding.error.role": "Bitte Rolle eingeben.",
|
||||
"onboarding.error.job_title": "Bitte Berufsbezeichnung eingeben.",
|
||||
"onboarding.error.generic": "Profil konnte nicht angelegt werden.",
|
||||
"onboarding.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
|
||||
|
||||
@@ -975,8 +975,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.display_name": "Anzeigename",
|
||||
"einstellungen.profil.display_name.placeholder": "Vor- und Nachname",
|
||||
"einstellungen.profil.office": "B\u00fcro",
|
||||
"einstellungen.profil.role": "Rolle",
|
||||
"einstellungen.profil.role.placeholder": "z.B. Associate, Partner, PA",
|
||||
"einstellungen.profil.job_title": "Berufsbezeichnung",
|
||||
"einstellungen.profil.job_title.placeholder": "z.B. Associate, Partner, PA",
|
||||
"einstellungen.profil.dezernat": "Dezernat / Partner",
|
||||
"einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
|
||||
"einstellungen.profil.lang": "Sprache",
|
||||
@@ -985,7 +985,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.lang.hint": "Wird f\u00fcr Oberfl\u00e4che und Benachrichtigungs-E-Mails verwendet.",
|
||||
"einstellungen.profil.error.display_name": "Bitte Anzeigename eingeben.",
|
||||
"einstellungen.profil.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
|
||||
"einstellungen.profil.error.role": "Bitte Rolle eingeben.",
|
||||
"einstellungen.profil.error.job_title": "Bitte Berufsbezeichnung eingeben.",
|
||||
"einstellungen.prefs.reminders.heading": "Frist-Erinnerungen",
|
||||
"einstellungen.prefs.reminders.hint": "Paliad sendet Erinnerungen an Ihre E-Mail, wenn Fristen f\u00e4llig werden.",
|
||||
"einstellungen.prefs.reminders.master": "Frist-Erinnerungen aktiv",
|
||||
@@ -1193,7 +1193,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.name": "Name",
|
||||
"admin.team.col.email": "E-Mail",
|
||||
"admin.team.col.office": "Standort",
|
||||
"admin.team.col.role": "Rolle",
|
||||
"admin.team.col.job_title": "Berufsbezeichnung",
|
||||
"admin.team.col.permission": "Berechtigung",
|
||||
"admin.team.col.dezernat": "Dezernat",
|
||||
"admin.team.col.additional": "Weitere Standorte",
|
||||
"admin.team.col.lang": "Sprache",
|
||||
@@ -1214,8 +1215,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.direct_add.empty": "Keine offenen Konten.",
|
||||
"admin.team.direct_add.name": "Anzeigename",
|
||||
"admin.team.direct_add.office": "Standort",
|
||||
"admin.team.direct_add.role": "Rolle",
|
||||
"admin.team.direct_add.job_title": "Berufsbezeichnung",
|
||||
"admin.team.direct_add.dezernat": "Dezernat (optional)",
|
||||
"admin.team.permission.standard": "Standard",
|
||||
"admin.team.permission.global_admin": "Global Admin",
|
||||
"admin.team.permission.last_admin": "Der letzte Global Admin kann nicht degradiert werden.",
|
||||
"admin.team.direct_add.cancel": "Abbrechen",
|
||||
"admin.team.direct_add.submit": "Anlegen",
|
||||
|
||||
@@ -1953,15 +1957,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"onboarding.display_name.placeholder": "First and last name",
|
||||
"onboarding.office": "Office",
|
||||
"onboarding.office.placeholder": "Please select",
|
||||
"onboarding.role": "Role",
|
||||
"onboarding.role.placeholder": "e.g. Associate, Partner, PA",
|
||||
"onboarding.job_title": "Job title",
|
||||
"onboarding.job_title.placeholder": "e.g. Associate, Partner, PA",
|
||||
"onboarding.dezernat": "Department / Partner",
|
||||
"onboarding.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
|
||||
"onboarding.optional": "(optional)",
|
||||
"onboarding.submit": "Create profile",
|
||||
"onboarding.error.display_name": "Please enter a display name.",
|
||||
"onboarding.error.office": "Please select an office.",
|
||||
"onboarding.error.role": "Please enter a role.",
|
||||
"onboarding.error.job_title": "Please enter a job title.",
|
||||
"onboarding.error.generic": "Could not create profile.",
|
||||
"onboarding.error.connection": "Connection error. Please try again.",
|
||||
|
||||
@@ -2191,8 +2195,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.display_name": "Display name",
|
||||
"einstellungen.profil.display_name.placeholder": "First and last name",
|
||||
"einstellungen.profil.office": "Office",
|
||||
"einstellungen.profil.role": "Role",
|
||||
"einstellungen.profil.role.placeholder": "e.g. Associate, Partner, PA",
|
||||
"einstellungen.profil.job_title": "Job title",
|
||||
"einstellungen.profil.job_title.placeholder": "e.g. Associate, Partner, PA",
|
||||
"einstellungen.profil.dezernat": "Department / Partner",
|
||||
"einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
|
||||
"einstellungen.profil.lang": "Language",
|
||||
@@ -2201,7 +2205,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.profil.lang.hint": "Used for the UI and notification emails.",
|
||||
"einstellungen.profil.error.display_name": "Please enter a display name.",
|
||||
"einstellungen.profil.error.office": "Please select an office.",
|
||||
"einstellungen.profil.error.role": "Please enter a role.",
|
||||
"einstellungen.profil.error.job_title": "Please enter a job title.",
|
||||
"einstellungen.prefs.reminders.heading": "Deadline reminders",
|
||||
"einstellungen.prefs.reminders.hint": "Paliad emails you when deadlines approach.",
|
||||
"einstellungen.prefs.reminders.master": "Deadline reminders enabled",
|
||||
@@ -2409,7 +2413,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.col.name": "Name",
|
||||
"admin.team.col.email": "Email",
|
||||
"admin.team.col.office": "Office",
|
||||
"admin.team.col.role": "Role",
|
||||
"admin.team.col.job_title": "Job title",
|
||||
"admin.team.col.permission": "Permission",
|
||||
"admin.team.col.dezernat": "Department",
|
||||
"admin.team.col.additional": "Additional offices",
|
||||
"admin.team.col.lang": "Lang",
|
||||
@@ -2430,8 +2435,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.direct_add.empty": "No pending accounts.",
|
||||
"admin.team.direct_add.name": "Display name",
|
||||
"admin.team.direct_add.office": "Office",
|
||||
"admin.team.direct_add.role": "Role",
|
||||
"admin.team.direct_add.job_title": "Job title",
|
||||
"admin.team.direct_add.dezernat": "Department (optional)",
|
||||
"admin.team.permission.standard": "Standard",
|
||||
"admin.team.permission.global_admin": "Global Admin",
|
||||
"admin.team.permission.last_admin": "The last global admin cannot be demoted.",
|
||||
"admin.team.direct_add.cancel": "Cancel",
|
||||
"admin.team.direct_add.submit": "Create",
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ export interface Note {
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
interface NotesState {
|
||||
@@ -101,7 +102,7 @@ function canEdit(state: NotesState, n: Note): boolean {
|
||||
function canDelete(state: NotesState, n: Note): boolean {
|
||||
if (!state.me) return false;
|
||||
if (n.created_by === state.me.id) return true;
|
||||
return state.me.role === "partner" || state.me.role === "admin";
|
||||
return state.me.global_role === "global_admin";
|
||||
}
|
||||
|
||||
function render(state: NotesState) {
|
||||
|
||||
@@ -85,7 +85,7 @@ async function submitForm(e: Event): Promise<void> {
|
||||
|
||||
const displayName = (data.get("display_name") as string || "").trim();
|
||||
const office = (data.get("office") as string || "").trim();
|
||||
const role = (data.get("role") as string || "").trim();
|
||||
const jobTitle = (data.get("job_title") as string || "").trim();
|
||||
const dezernat = (data.get("dezernat") as string || "").trim();
|
||||
|
||||
if (!displayName) {
|
||||
@@ -96,15 +96,15 @@ async function submitForm(e: Event): Promise<void> {
|
||||
showMessage(t("onboarding.error.office"), "login-error");
|
||||
return;
|
||||
}
|
||||
if (!role) {
|
||||
showMessage(t("onboarding.error.role"), "login-error");
|
||||
if (!jobTitle) {
|
||||
showMessage(t("onboarding.error.job_title"), "login-error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
office,
|
||||
role,
|
||||
job_title: jobTitle,
|
||||
};
|
||||
if (dezernat) payload.dezernat = dezernat;
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ interface Appointment {
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
office: string;
|
||||
}
|
||||
|
||||
@@ -552,7 +553,7 @@ function renderHeader() {
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.role === "partner" || me.role === "admin")) {
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
@@ -1203,7 +1204,7 @@ function renderTeam() {
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
return me.role === "partner" || me.role === "admin";
|
||||
return me.global_role === "global_admin";
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
|
||||
@@ -16,7 +16,8 @@ interface Me {
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
dezernat?: string;
|
||||
lang: Lang;
|
||||
email_preferences: Record<string, unknown>;
|
||||
@@ -184,7 +185,7 @@ function fillProfilForm() {
|
||||
(document.getElementById("profil-email") as HTMLInputElement).value = me.email;
|
||||
(document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name;
|
||||
(document.getElementById("profil-office") as HTMLSelectElement).value = me.office;
|
||||
(document.getElementById("profil-role") as HTMLInputElement).value = me.role;
|
||||
(document.getElementById("profil-role") as HTMLInputElement).value = me.job_title ?? "";
|
||||
(document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? "";
|
||||
(document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de";
|
||||
}
|
||||
@@ -197,7 +198,7 @@ async function saveProfil(ev: Event) {
|
||||
|
||||
const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim();
|
||||
const office = (document.getElementById("profil-office") as HTMLSelectElement).value;
|
||||
const role = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
|
||||
const jobTitle = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
|
||||
const dezernat = (document.getElementById("profil-dezernat") as HTMLInputElement).value.trim();
|
||||
const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang;
|
||||
|
||||
@@ -211,8 +212,8 @@ async function saveProfil(ev: Event) {
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!role) {
|
||||
msg.textContent = t("einstellungen.profil.error.role");
|
||||
if (!jobTitle) {
|
||||
msg.textContent = t("einstellungen.profil.error.job_title");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +221,7 @@ async function saveProfil(ev: Event) {
|
||||
const payload = {
|
||||
display_name: displayName,
|
||||
office,
|
||||
role,
|
||||
job_title: jobTitle,
|
||||
dezernat,
|
||||
lang,
|
||||
};
|
||||
@@ -563,7 +564,7 @@ interface DezernatMember {
|
||||
display_name: string;
|
||||
email: string;
|
||||
office: string;
|
||||
role: string;
|
||||
job_title: string | null;
|
||||
}
|
||||
|
||||
let allDezernate: Dezernat[] = [];
|
||||
@@ -579,7 +580,7 @@ async function loadDezernatTab(): Promise<void> {
|
||||
}
|
||||
}
|
||||
const adminSection = document.getElementById("dezernat-admin-section")!;
|
||||
if (me && me.role === "admin") {
|
||||
if (me && me.global_role === "global_admin") {
|
||||
adminSection.style.display = "";
|
||||
}
|
||||
|
||||
|
||||
@@ -296,23 +296,23 @@ function initChangelogBadge(): void {
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms role='admin'. The markup is in the DOM with
|
||||
// display:none for everyone — flipping it on after the fetch lands keeps
|
||||
// non-admin pageloads cheap (no flash, no second render) and avoids a
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
// keeps non-admin pageloads cheap (no flash, no second render) and avoids a
|
||||
// privilege flash for admins on cached pages.
|
||||
function initAdminGroup(): void {
|
||||
const group = document.getElementById("sidebar-admin-group") as HTMLElement | null;
|
||||
if (!group) return;
|
||||
fetch("/api/me", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me: { role?: string } | null) => {
|
||||
if (me && me.role === "admin") {
|
||||
.then((me: { global_role?: string } | null) => {
|
||||
if (me && me.global_role === "global_admin") {
|
||||
group.style.display = "";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// silent: not being able to check role just means we keep the section
|
||||
// hidden, which fails closed.
|
||||
// silent: not being able to check the permission just means we keep
|
||||
// the section hidden, which fails closed.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,23 +45,26 @@ export function renderOnboarding(): string {
|
||||
{/* Options populated from /api/offices at init. */}
|
||||
</select>
|
||||
|
||||
<label htmlFor="onb-role" className="login-label" data-i18n="onboarding.role">Rolle</label>
|
||||
<label htmlFor="onb-job-title" className="login-label" data-i18n="onboarding.job_title">Berufsbezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
id="onb-role"
|
||||
name="role"
|
||||
list="onb-role-suggestions"
|
||||
id="onb-job-title"
|
||||
name="job_title"
|
||||
list="onb-job-title-suggestions"
|
||||
required
|
||||
autocomplete="off"
|
||||
className="login-input"
|
||||
data-i18n-placeholder="onboarding.role.placeholder"
|
||||
data-i18n-placeholder="onboarding.job_title.placeholder"
|
||||
placeholder="z.B. Associate, Partner, PA"
|
||||
/>
|
||||
<datalist id="onb-role-suggestions">
|
||||
<datalist id="onb-job-title-suggestions">
|
||||
<option value="Partner"></option>
|
||||
<option value="Associate"></option>
|
||||
<option value="PA"></option>
|
||||
<option value="Of Counsel"></option>
|
||||
<option value="Counsel"></option>
|
||||
<option value="Counsel Knowledge Lawyer"></option>
|
||||
<option value="Knowledge Lawyer"></option>
|
||||
<option value="Referendar/in"></option>
|
||||
<option value="Trainee"></option>
|
||||
<option value="wiss. Mitarbeiter/in"></option>
|
||||
|
||||
@@ -75,14 +75,14 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="profil-role" data-i18n="einstellungen.profil.role">Rolle</label>
|
||||
<label htmlFor="profil-role" data-i18n="einstellungen.profil.job_title">Berufsbezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
id="profil-role"
|
||||
list="profil-role-suggestions"
|
||||
required
|
||||
autocomplete="off"
|
||||
data-i18n-placeholder="einstellungen.profil.role.placeholder"
|
||||
data-i18n-placeholder="einstellungen.profil.job_title.placeholder"
|
||||
placeholder="z.B. Associate, Partner, PA"
|
||||
/>
|
||||
<datalist id="profil-role-suggestions">
|
||||
@@ -90,6 +90,9 @@ export function renderSettings(): string {
|
||||
<option value="Associate"></option>
|
||||
<option value="PA"></option>
|
||||
<option value="Of Counsel"></option>
|
||||
<option value="Counsel"></option>
|
||||
<option value="Counsel Knowledge Lawyer"></option>
|
||||
<option value="Knowledge Lawyer"></option>
|
||||
<option value="Referendar/in"></option>
|
||||
<option value="Trainee"></option>
|
||||
<option value="wiss. Mitarbeiter/in"></option>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
-- Symmetric reverse of 023.up.sql. Restores paliad.users.role + drops
|
||||
-- global_role + restores can_see_project + RLS to the migration-021 shape.
|
||||
--
|
||||
-- WARNING: this loses any job_title that was NULL after the up migration
|
||||
-- (admins lose their NULL slot — they get role='admin' restored from
|
||||
-- global_role='global_admin'). Real job titles from non-admins (e.g.
|
||||
-- "Counsel Knowledge Lawyer") are preserved by the role rename. tester
|
||||
-- whose job_title is NULL gets role='admin' back from global_role.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_job_title_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
RENAME COLUMN job_title TO role;
|
||||
|
||||
-- Restore role='admin' for global_admins whose role is currently NULL.
|
||||
UPDATE paliad.users
|
||||
SET role = 'admin'
|
||||
WHERE global_role = 'global_admin' AND role IS NULL;
|
||||
|
||||
-- Re-add the original CHECK (role <> '') from migration 015.
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_role_check CHECK (role <> '');
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_global_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS global_role;
|
||||
|
||||
-- Restore the migration-021 visibility functions + RLS policies.
|
||||
DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE;
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
|
||||
|
||||
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.role = 'admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = auth.uid()
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION paliad.note_is_visible(
|
||||
_project_id uuid,
|
||||
_deadline_id uuid,
|
||||
_appointment_id uuid,
|
||||
_project_event_id uuid
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id)
|
||||
WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id))
|
||||
WHEN _appointment_id IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL
|
||||
THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid()
|
||||
ELSE paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.appointments WHERE id = _appointment_id))
|
||||
END
|
||||
WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.project_events WHERE id = _project_event_id))
|
||||
ELSE false
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- projects ------------------------------------------------------------------
|
||||
CREATE POLICY projects_select ON paliad.projects
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(id));
|
||||
|
||||
CREATE POLICY projects_insert ON paliad.projects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
parent_id IS NULL
|
||||
OR paliad.can_see_project(parent_id)
|
||||
);
|
||||
|
||||
CREATE POLICY projects_update ON paliad.projects
|
||||
FOR UPDATE TO authenticated
|
||||
USING (paliad.can_see_project(id))
|
||||
WITH CHECK (paliad.can_see_project(id));
|
||||
|
||||
CREATE POLICY projects_delete ON paliad.projects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
paliad.can_see_project(id)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- project_teams -------------------------------------------------------------
|
||||
CREATE POLICY project_teams_select ON paliad.project_teams
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_teams_insert ON paliad.project_teams
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
CREATE POLICY project_teams_update ON paliad.project_teams
|
||||
FOR UPDATE TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_teams_delete ON paliad.project_teams
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.role IN ('partner','admin')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY parties_all ON paliad.parties
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY deadlines_all ON paliad.deadlines
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY documents_all ON paliad.documents
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_events_all ON paliad.project_events
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY appointments_select ON paliad.appointments
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_insert ON paliad.appointments
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_update ON paliad.appointments
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
)
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_delete ON paliad.appointments
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY notes_all ON paliad.notes
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id))
|
||||
WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id));
|
||||
|
||||
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
created_by = auth.uid()
|
||||
AND (project_id IS NULL OR paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
)
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
@@ -0,0 +1,300 @@
|
||||
-- t-paliad-051: separate job title from global permissions.
|
||||
--
|
||||
-- Until now, paliad.users.role conflated three concepts: free-text job title
|
||||
-- (Partner / Counsel / PA / Trainee / Sekretariat / "Counsel Knowledge
|
||||
-- Lawyer"), the global-admin permission ('admin'), and — by accident —
|
||||
-- a half-broken "partner" capability gate. The collision broke as soon as
|
||||
-- m wanted to record his real job title without losing admin access: the
|
||||
-- only way to be a global admin was to set role='admin', which by definition
|
||||
-- destroyed the job-title slot. The /admin/team UI from t-paliad-050 made
|
||||
-- the bug visible by hard-rejecting role='admin' on edit.
|
||||
--
|
||||
-- This migration splits the column:
|
||||
-- * paliad.users.job_title — free text, NULL allowed, display only
|
||||
-- * paliad.users.global_role — 'standard' | 'global_admin', drives every
|
||||
-- permission check that used to look at
|
||||
-- role='admin'
|
||||
--
|
||||
-- Per-project paliad.project_teams.role is unrelated and untouched.
|
||||
--
|
||||
-- Idempotent on a fresh DB: the ADD COLUMN IF NOT EXISTS / DROP CONSTRAINT
|
||||
-- IF EXISTS guards mean re-running this on a partially-applied DB doesn't
|
||||
-- explode, and the UPDATEs are no-ops if global_role is already populated.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Add global_role with a default so existing rows pick up 'standard'.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS global_role text NOT NULL DEFAULT 'standard';
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_global_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_global_role_check
|
||||
CHECK (global_role IN ('standard', 'global_admin'));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. Drop the legacy NOT NULL + DEFAULT 'associate' on role. The new model
|
||||
-- allows NULL job_title (admins without a real firm role legitimately
|
||||
-- store NULL), so the column-level constraint must go before the data
|
||||
-- UPDATEs that NULL out admin rows; otherwise those UPDATEs would fail
|
||||
-- silently under psql auto-commit.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.users
|
||||
ALTER COLUMN role DROP NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ALTER COLUMN role DROP DEFAULT;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Promote anyone with the legacy role='admin' to global_admin, then
|
||||
-- clear out their role text so it stops masquerading as a job title.
|
||||
-- m gets a real job title; tester stays NULL (synthetic admin, no title).
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin';
|
||||
|
||||
UPDATE paliad.users SET role = NULL WHERE role = 'admin';
|
||||
|
||||
UPDATE paliad.users
|
||||
SET role = 'Counsel Knowledge Lawyer'
|
||||
WHERE email = 'matthias.siebels@hoganlovells.com';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Rename role -> job_title and adjust the non-empty CHECK so NULL is
|
||||
-- allowed (admins without a job title legitimately store NULL now).
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
RENAME COLUMN role TO job_title;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_job_title_check
|
||||
CHECK (job_title IS NULL OR job_title <> '');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. Rebuild can_see_project() with the global_role predicate. CASCADE
|
||||
-- drops the dependent RLS policies; we recreate them below to match
|
||||
-- the canonical English set from migration 021 — with two changes:
|
||||
-- * every u.role='admin' becomes u.global_role='global_admin'
|
||||
-- * the historic u.role IN ('partner','admin') gate (projects_delete,
|
||||
-- project_teams_delete) becomes u.global_role='global_admin' only.
|
||||
-- Per m: firm roles ≠ project roles ≠ tool roles. job_title (a
|
||||
-- firm role / display) must NEVER gate ops. Only tool-level
|
||||
-- permissions (here: global_admin) authorise mutations. No prod
|
||||
-- rows have job_title='partner', so this drops zero capability.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE;
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
|
||||
|
||||
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = auth.uid()
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_project(uuid) IS
|
||||
'Team-based visibility predicate for paliad.projects. Direct or inherited '
|
||||
'(ancestor) membership grants access. Global admins see all.';
|
||||
|
||||
CREATE FUNCTION paliad.note_is_visible(
|
||||
_project_id uuid,
|
||||
_deadline_id uuid,
|
||||
_appointment_id uuid,
|
||||
_project_event_id uuid
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id)
|
||||
WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id))
|
||||
WHEN _appointment_id IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL
|
||||
THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid()
|
||||
ELSE paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.appointments WHERE id = _appointment_id))
|
||||
END
|
||||
WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.project_events WHERE id = _project_event_id))
|
||||
ELSE false
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. Rebuild every RLS policy CASCADE just dropped. Identical shape to
|
||||
-- migration 021, with the two predicate swaps noted above.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- projects ------------------------------------------------------------------
|
||||
CREATE POLICY projects_select ON paliad.projects
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(id));
|
||||
|
||||
CREATE POLICY projects_insert ON paliad.projects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
parent_id IS NULL
|
||||
OR paliad.can_see_project(parent_id)
|
||||
);
|
||||
|
||||
CREATE POLICY projects_update ON paliad.projects
|
||||
FOR UPDATE TO authenticated
|
||||
USING (paliad.can_see_project(id))
|
||||
WITH CHECK (paliad.can_see_project(id));
|
||||
|
||||
CREATE POLICY projects_delete ON paliad.projects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
paliad.can_see_project(id)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- project_teams -------------------------------------------------------------
|
||||
CREATE POLICY project_teams_select ON paliad.project_teams
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_teams_insert ON paliad.project_teams
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
CREATE POLICY project_teams_update ON paliad.project_teams
|
||||
FOR UPDATE TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_teams_delete ON paliad.project_teams
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- parties / deadlines / documents / project_events --------------------------
|
||||
CREATE POLICY parties_all ON paliad.parties
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY deadlines_all ON paliad.deadlines
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY documents_all ON paliad.documents
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY project_events_all ON paliad.project_events
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
-- appointments (project_id nullable; personal appts stay creator-only) -----
|
||||
CREATE POLICY appointments_select ON paliad.appointments
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_insert ON paliad.appointments
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_update ON paliad.appointments
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
)
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY appointments_delete ON paliad.appointments
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
-- notes (polymorphic parent dispatch) --------------------------------------
|
||||
CREATE POLICY notes_all ON paliad.notes
|
||||
FOR ALL TO authenticated
|
||||
USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id))
|
||||
WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id));
|
||||
|
||||
-- checklist_instances (project_id nullable; personal stays creator-only) ---
|
||||
CREATE POLICY checklist_instances_select ON paliad.checklist_instances
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_insert ON paliad.checklist_instances
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
created_by = auth.uid()
|
||||
AND (project_id IS NULL OR paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_update ON paliad.checklist_instances
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
)
|
||||
WITH CHECK (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
CREATE POLICY checklist_instances_delete ON paliad.checklist_instances
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND created_by = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
@@ -70,10 +70,6 @@ func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "user already onboarded",
|
||||
})
|
||||
case errors.Is(err, services.ErrAdminBootstrapOnly):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "admin role cannot be assigned via the admin UI",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
// AdminCreateUser uses ErrInvalidInput for both bad-shape inputs
|
||||
// and the "no auth.users row for this email" case. Surfacing the
|
||||
@@ -108,9 +104,9 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUserNotOnboarded):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
case errors.Is(err, services.ErrAdminBootstrapOnly):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "admin role cannot be assigned via the admin UI",
|
||||
case errors.Is(err, services.ErrLastGlobalAdmin):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "cannot demote the last remaining global admin",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
@@ -138,6 +134,10 @@ func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUserNotOnboarded):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
case errors.Is(err, services.ErrLastGlobalAdmin):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "cannot delete the last remaining global admin",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
|
||||
@@ -58,14 +58,10 @@ func handleCreateOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "onboarding already completed",
|
||||
})
|
||||
case errors.Is(err, services.ErrAdminBootstrapOnly):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "admin role cannot be self-assigned — please choose another role",
|
||||
})
|
||||
default:
|
||||
// Validation errors from the service (bad office, bad role, empty
|
||||
// display_name) come through as generic errors; surface them so
|
||||
// the form can show a precise message.
|
||||
// Validation errors from the service (bad office, missing
|
||||
// job_title, empty display_name) come through as generic errors;
|
||||
// surface them so the form can show a precise message.
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
|
||||
@@ -86,10 +86,6 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "no paliad.users row — onboarding required",
|
||||
})
|
||||
case errors.Is(err, services.ErrAdminBootstrapOnly):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "admin role cannot be self-assigned",
|
||||
})
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -23,8 +23,16 @@ type User struct {
|
||||
// data model (t-paliad-024).
|
||||
AdditionalOffices pq.StringArray `db:"additional_offices" json:"additional_offices"`
|
||||
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
||||
// JobTitle is free-text display only ("Partner", "Counsel", "PA",
|
||||
// "Counsel Knowledge Lawyer", …). NULL is allowed for users who never
|
||||
// picked a title — typically global admins promoted via SQL.
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
// GlobalRole is the global-permissions enum: 'standard' | 'global_admin'.
|
||||
// Drives every permission gate that used to look at the legacy
|
||||
// role='admin'. Per-project authority is on paliad.project_teams.role and
|
||||
// is unrelated.
|
||||
GlobalRole string `db:"global_role" json:"global_role"`
|
||||
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
|
||||
// ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and
|
||||
|
||||
@@ -81,14 +81,14 @@ func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilt
|
||||
items := make([]AgendaItem, 0, 64)
|
||||
|
||||
if f.IncludeDeadlines {
|
||||
rows, err := s.loadDeadlines(ctx, userID, user.Role, f.From, f.To)
|
||||
rows, err := s.loadDeadlines(ctx, userID, user.GlobalRole, f.From, f.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, rows...)
|
||||
}
|
||||
if f.IncludeAppointments {
|
||||
rows, err := s.loadAppointments(ctx, userID, user.Role, f.From, f.To)
|
||||
rows, err := s.loadAppointments(ctx, userID, user.GlobalRole, f.From, f.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -132,7 +132,7 @@ SELECT f.id,
|
||||
WHERE f.status = 'pending'
|
||||
AND f.due_date >= $3::date
|
||||
AND f.due_date < $4::date
|
||||
AND ($2 = 'admin' OR EXISTS (
|
||||
AND ($2 = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
@@ -198,7 +198,7 @@ SELECT t.id,
|
||||
AND t.start_at < $4
|
||||
AND (
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
|
||||
conds := []string{visibility}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
}
|
||||
|
||||
if filter.ProjectID != nil {
|
||||
@@ -196,7 +196,7 @@ func (s *AppointmentService) requireMutationRole(ctx context.Context, userID uui
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can modify Appointments on a Project", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
@@ -448,7 +448,7 @@ func (s *AppointmentService) SummaryCounts(ctx context.Context, userID uuid.UUID
|
||||
"tomorrow": tomorrow,
|
||||
"endweek": endOfWeek,
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("appointment summary: %w", err)
|
||||
}
|
||||
@@ -484,12 +484,12 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
)))`
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, user.GlobalRole); err != nil {
|
||||
return nil, fmt.Errorf("all appointments for user: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
@@ -75,7 +75,7 @@ func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID u
|
||||
args := map[string]any{
|
||||
"slug": slug,
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
}
|
||||
return s.listWithProjekt(ctx, query, args)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.U
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || (user.Role != "partner" && user.Role != "admin") {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only the creator or a partner/admin can delete a checklist instance", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ type DashboardUser struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role"`
|
||||
JobTitle *string `json:"job_title"`
|
||||
GlobalRole string `json:"global_role"`
|
||||
}
|
||||
|
||||
type DeadlineSummary struct {
|
||||
@@ -117,7 +118,8 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
Office: user.Office,
|
||||
Role: user.Role,
|
||||
JobTitle: user.JobTitle,
|
||||
GlobalRole: user.GlobalRole,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -151,7 +153,7 @@ func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData,
|
||||
WITH visible_projekte AS (
|
||||
SELECT p.id, p.status
|
||||
FROM paliad.projects p
|
||||
WHERE $2 = 'admin'
|
||||
WHERE $2 = 'global_admin'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
@@ -183,7 +185,7 @@ SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
|
||||
MatterSummary
|
||||
}
|
||||
err := s.db.GetContext(ctx, &row, query,
|
||||
user.ID, user.Role, today, endOfWeek, sevenDaysAgo)
|
||||
user.ID, user.GlobalRole, today, endOfWeek, sevenDaysAgo)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
@@ -208,7 +210,7 @@ SELECT f.id,
|
||||
WHERE f.status = 'pending'
|
||||
AND f.due_date >= $3::date
|
||||
AND f.due_date <= $4::date
|
||||
AND ($2 = 'admin' OR EXISTS (
|
||||
AND ($2 = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
@@ -216,7 +218,7 @@ SELECT f.id,
|
||||
ORDER BY f.due_date ASC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
||||
user.ID, user.Role, today, endOfWeek); err != nil {
|
||||
user.ID, user.GlobalRole, today, endOfWeek); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -238,7 +240,7 @@ SELECT t.id,
|
||||
AND t.start_at < ($3 + interval '7 days')
|
||||
AND (
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
|
||||
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
@@ -247,7 +249,7 @@ SELECT t.id,
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
user.ID, user.Role, now); err != nil {
|
||||
user.ID, user.GlobalRole, now); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -267,7 +269,7 @@ SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
|
||||
FROM paliad.project_events e
|
||||
JOIN paliad.projects p ON p.id = e.project_id
|
||||
LEFT JOIN paliad.users u ON u.id = e.created_by
|
||||
WHERE $2 = 'admin'
|
||||
WHERE $2 = 'global_admin'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
@@ -276,7 +278,7 @@ SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
|
||||
ORDER BY COALESCE(e.event_date, e.created_at) DESC
|
||||
LIMIT 10`
|
||||
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
|
||||
user.ID, user.Role); err != nil {
|
||||
user.ID, user.GlobalRole); err != nil {
|
||||
return fmt.Errorf("dashboard recent activity: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
conds := []string{visibilityPredicate("p")}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
conds = append(conds, `f.project_id = :project_id`)
|
||||
@@ -402,7 +402,7 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return nil
|
||||
}
|
||||
var ok bool
|
||||
@@ -434,7 +434,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID)
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden)
|
||||
}
|
||||
current, err := s.GetByID(ctx, userID, fristID)
|
||||
@@ -488,7 +488,7 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
|
||||
conds := []string{visibilityPredicate("p")}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
"today": today,
|
||||
"tomorrow": tomorrow,
|
||||
"endweek": endWeek,
|
||||
|
||||
@@ -183,7 +183,7 @@ type DepartmentMember struct {
|
||||
Email string `db:"email" json:"email"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Office string `db:"office" json:"office"`
|
||||
Role string `db:"role" json:"role"`
|
||||
JobTitle *string `db:"job_title" json:"job_title"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func (s *DepartmentService) ListMembers(ctx context.Context, departmentID uuid.U
|
||||
var rows []DepartmentMember
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT dm.user_id, dm.created_at,
|
||||
u.email, u.display_name, u.office, u.role
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.department_members dm
|
||||
JOIN paliad.users u ON u.id = dm.user_id
|
||||
WHERE dm.department_id = $1
|
||||
@@ -250,7 +250,7 @@ func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWi
|
||||
// (auth row present, paliad.users row missing) must be excluded.
|
||||
err = s.db.SelectContext(ctx, &members,
|
||||
`SELECT dm.department_id, dm.user_id, dm.created_at,
|
||||
u.email, u.display_name, u.office, u.role
|
||||
u.email, u.display_name, u.office, u.job_title
|
||||
FROM paliad.department_members dm
|
||||
JOIN paliad.users u ON u.id = dm.user_id
|
||||
ORDER BY u.display_name`)
|
||||
@@ -301,8 +301,8 @@ func (s *DepartmentService) requireAdmin(ctx context.Context, userID uuid.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil || u.Role != "admin" {
|
||||
return fmt.Errorf("%w: admin required", ErrForbidden)
|
||||
if u == nil || u.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: global admin required", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func (s *NoteService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || (user.Role != "partner" && user.Role != "admin") {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only the author or a partner/admin can delete a Note", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *PartyService) Delete(ctx context.Context, userID, parteiID uuid.UUID) e
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can delete Parties", ErrForbidden)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi
|
||||
conds := []string{visibilityPredicate("p")}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"role": user.GlobalRole,
|
||||
}
|
||||
|
||||
if f.Type != "" {
|
||||
@@ -212,7 +212,7 @@ func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*mo
|
||||
var p models.Project
|
||||
query := `SELECT ` + projektColumns + ` FROM paliad.projects p
|
||||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2, 3)
|
||||
err = s.db.GetContext(ctx, &p, query, id, userID, user.Role)
|
||||
err = s.db.GetContext(ctx, &p, query, id, userID, user.GlobalRole)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID
|
||||
idStrs[i] = u.String()
|
||||
}
|
||||
rows := []models.Project{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.Role); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID, user.GlobalRole); err != nil {
|
||||
return nil, fmt.Errorf("list ancestors: %w", err)
|
||||
}
|
||||
// Re-order to match path order (root first).
|
||||
@@ -310,7 +310,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
|
||||
WHERE ` + visibilityPredicatePositional("p", 1, 2) + `
|
||||
ORDER BY p.path`
|
||||
rows := []models.Project{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, user.Role); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, user.GlobalRole); err != nil {
|
||||
return nil, fmt.Errorf("build tree list: %w", err)
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
|
||||
FROM paliad.deadlines f
|
||||
JOIN paliad.projects p ON p.id = f.project_id
|
||||
WHERE `+visibilityPredicatePositional("p", 1, 2)+`
|
||||
GROUP BY f.project_id`, userID, user.Role, today); err != nil {
|
||||
GROUP BY f.project_id`, userID, user.GlobalRole, today); err != nil {
|
||||
return nil, fmt.Errorf("build tree deadline counts: %w", err)
|
||||
}
|
||||
countByID := make(map[uuid.UUID]deadlineCount, len(counts))
|
||||
@@ -402,7 +402,7 @@ func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m
|
||||
AND ` + visibilityPredicatePositional("p", 3, 4) + `
|
||||
ORDER BY p.path`
|
||||
rows := []models.Project{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.Role); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID, user.GlobalRole); err != nil {
|
||||
return nil, fmt.Errorf("get tree: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
@@ -614,7 +614,7 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
if user == nil {
|
||||
return ErrNotVisible
|
||||
}
|
||||
if user.Role != "partner" && user.Role != "admin" {
|
||||
if user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: only partners/admins can archive Projects", ErrForbidden)
|
||||
}
|
||||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||||
@@ -722,7 +722,7 @@ func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uui
|
||||
// `:user_id`/`:role` placeholders, producing invalid SQL `:uuid[]` that
|
||||
// Postgres rejects with `syntax error at or near ":"`.
|
||||
func visibilityPredicate(alias string) string {
|
||||
return `(:role = 'admin' OR EXISTS (
|
||||
return `(:role = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = :user_id
|
||||
AND pt.project_id = ANY(CAST(string_to_array(` + alias + `.path, '.') AS uuid[]))
|
||||
@@ -744,7 +744,7 @@ func visibilityPredicatePositional(alias string, userArg, roleArg int) string {
|
||||
// (for sqlx.In-compatible queries). The caller appends (userID, role) to
|
||||
// args in that order.
|
||||
func visibilityPredicatePlaceholder(alias string) string {
|
||||
return `(? = 'admin' OR EXISTS (
|
||||
return `(? = 'global_admin' OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = ?
|
||||
AND pt.project_id = ANY(string_to_array(` + alias + `.path, '.')::uuid[])
|
||||
|
||||
@@ -38,10 +38,16 @@ var (
|
||||
// ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called
|
||||
// for a paliad.users row that already exists (409 Conflict on the wire).
|
||||
ErrUserAlreadyOnboarded = errors.New("user already onboarded")
|
||||
// ErrAdminBootstrapOnly signals an attempt to self-assign the 'admin' role
|
||||
// when other paliad.users rows already exist. Only the very first user can
|
||||
// bootstrap themselves as admin (403 Forbidden on the wire).
|
||||
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
|
||||
// ErrLastGlobalAdmin guards demoting / deleting the last global_admin so
|
||||
// the firm can't lock itself out of its own admin UI.
|
||||
ErrLastGlobalAdmin = errors.New("cannot remove the last remaining global admin")
|
||||
// ErrGlobalAdminAssignment signals a non-global-admin trying to write
|
||||
// global_role through a path that doesn't permit it (e.g. /api/onboarding,
|
||||
// /api/me, the create form on /admin/team). Promotion to global_admin is
|
||||
// only legal via PATCH /api/admin/users/{id} from an existing global_admin
|
||||
// — and the bootstrap path, where the first paliad.users row may flip
|
||||
// itself.
|
||||
ErrGlobalAdminAssignment = errors.New("global_admin must be granted by an existing global admin")
|
||||
// ErrUserNotOnboarded is returned when an endpoint that requires an
|
||||
// existing paliad.users row is called by a user who hasn't onboarded yet
|
||||
// (404 Not Found on the wire — callers should redirect to /onboarding).
|
||||
@@ -59,7 +65,8 @@ func NewUserService(db *sqlx.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
const userColumns = `id, email, display_name, office, additional_offices, practice_group, role, dezernat,
|
||||
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
|
||||
job_title, global_role, dezernat,
|
||||
lang, email_preferences,
|
||||
reminder_morning_time::text AS reminder_morning_time,
|
||||
reminder_evening_time::text AS reminder_evening_time,
|
||||
@@ -85,7 +92,7 @@ func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User,
|
||||
type CreateUserInput struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role"`
|
||||
JobTitle string `json:"job_title"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
}
|
||||
|
||||
@@ -94,11 +101,14 @@ type CreateUserInput struct {
|
||||
// from the request body, which prevents a user from creating a row for a
|
||||
// different auth.uid().
|
||||
//
|
||||
// Role is free-form text (German firms have many titles beyond the original
|
||||
// four-value enum). The DB CHECK only requires non-empty. The one exception
|
||||
// is 'admin', which is reserved: only allowed when the paliad.users table is
|
||||
// empty (bootstrap admin). Subsequent users who ask for 'admin' are rejected
|
||||
// — an existing admin must promote them via SQL / future admin UI.
|
||||
// JobTitle is free-form text (Partner / Counsel / PA / Trainee / Sekretariat /
|
||||
// "Counsel Knowledge Lawyer" / …). The DB CHECK only requires non-empty.
|
||||
//
|
||||
// global_role is decided server-side: every new row defaults to 'standard',
|
||||
// EXCEPT the bootstrap path — when paliad.users is otherwise empty, the
|
||||
// inserter is promoted to 'global_admin' so the firm has at least one admin.
|
||||
// The pg_advisory_xact_lock serialises concurrent first-logins so only one
|
||||
// can win the bootstrap; the lock auto-releases on commit/rollback.
|
||||
//
|
||||
// Returns ErrUserAlreadyOnboarded if the row exists (callers map to 409).
|
||||
func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, input CreateUserInput) (*models.User, error) {
|
||||
@@ -109,9 +119,9 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("invalid office %q", input.Office)
|
||||
}
|
||||
role := strings.TrimSpace(input.Role)
|
||||
if role == "" {
|
||||
return nil, fmt.Errorf("role is required")
|
||||
jobTitle := strings.TrimSpace(input.JobTitle)
|
||||
if jobTitle == "" {
|
||||
return nil, fmt.Errorf("job_title is required")
|
||||
}
|
||||
|
||||
var dezernat *string
|
||||
@@ -139,35 +149,34 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
return nil, ErrUserAlreadyOnboarded
|
||||
}
|
||||
|
||||
// Admin bootstrap gate: only allow 'admin' when no other users exist yet.
|
||||
// Under Postgres' default READ COMMITTED isolation, two concurrent
|
||||
// first-logins both asking for 'admin' could both see count=0 and both
|
||||
// succeed. A transaction-scoped advisory lock serialises the check+insert
|
||||
// so only one bootstrap can win; the lock is auto-released on commit or
|
||||
// rollback. The constant is arbitrary but stable — every admin-bootstrap
|
||||
// tx takes the same lock.
|
||||
if role == "admin" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
|
||||
return nil, fmt.Errorf("lock for admin bootstrap: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := tx.GetContext(ctx, &count,
|
||||
`SELECT count(*) FROM paliad.users`); err != nil {
|
||||
return nil, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, ErrAdminBootstrapOnly
|
||||
}
|
||||
// Bootstrap gate: the very first paliad.users row is promoted to
|
||||
// global_admin so the firm has at least one admin from day one. Under
|
||||
// Postgres' default READ COMMITTED isolation two concurrent first-logins
|
||||
// could both see count=0; the advisory lock serialises the check + insert
|
||||
// so only one bootstrap can win. The lock auto-releases on commit/rollback.
|
||||
// The constant is arbitrary but stable — every bootstrap tx takes the
|
||||
// same lock.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT pg_advisory_xact_lock(7346298141)`); err != nil {
|
||||
return nil, fmt.Errorf("lock for bootstrap: %w", err)
|
||||
}
|
||||
var existingCount int
|
||||
if err := tx.GetContext(ctx, &existingCount,
|
||||
`SELECT count(*) FROM paliad.users`); err != nil {
|
||||
return nil, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
globalRole := "standard"
|
||||
if existingCount == 0 {
|
||||
globalRole = "global_admin"
|
||||
}
|
||||
|
||||
// practice_group is intentionally left NULL — the column is retained for
|
||||
// future use but no longer collected at onboarding (m, 2026-04-18: every
|
||||
// Paliad user is in patent practice, so the field carried no signal).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
id, email, displayName, input.Office, role, dezernat,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
id, email, displayName, input.Office, jobTitle, globalRole, dezernat,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
@@ -183,11 +192,13 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
|
||||
// pointer so callers can omit keys they don't want to touch — the settings
|
||||
// page sends only the fields the user changed. Email is deliberately absent:
|
||||
// auth.users.email is the source of truth and the handler rejects any attempt
|
||||
// to mutate it via this endpoint.
|
||||
// to mutate it via this endpoint. global_role is also deliberately absent:
|
||||
// promotion/demotion is a privileged operation that goes through
|
||||
// PATCH /api/admin/users/{id}, never through self-service settings.
|
||||
type UpdateProfileInput struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
|
||||
@@ -199,9 +210,7 @@ type UpdateProfileInput struct {
|
||||
// UpdateProfile mutates the paliad.users row for the authenticated user.
|
||||
// Returns the fresh row.
|
||||
//
|
||||
// The 'admin' role is never assignable via this endpoint — role changes
|
||||
// downgrading an admin, or promoting a non-admin to admin, must go through
|
||||
// SQL / a future admin UI (mirrors the onboarding restriction).
|
||||
// global_role is intentionally NOT writable here — see UpdateProfileInput.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input UpdateProfileInput) (*models.User, error) {
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -224,16 +233,13 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd
|
||||
args = append(args, *input.Office)
|
||||
i++
|
||||
}
|
||||
if input.Role != nil {
|
||||
role := strings.TrimSpace(*input.Role)
|
||||
if role == "" {
|
||||
return nil, fmt.Errorf("role cannot be empty")
|
||||
if input.JobTitle != nil {
|
||||
jt := strings.TrimSpace(*input.JobTitle)
|
||||
if jt == "" {
|
||||
return nil, fmt.Errorf("job_title cannot be empty")
|
||||
}
|
||||
if role == "admin" {
|
||||
return nil, ErrAdminBootstrapOnly
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("role = $%d", i))
|
||||
args = append(args, role)
|
||||
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
|
||||
args = append(args, jt)
|
||||
i++
|
||||
}
|
||||
if input.Dezernat != nil {
|
||||
@@ -334,16 +340,16 @@ func (s *UserService) List(ctx context.Context) ([]models.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the given user is an admin. Implements
|
||||
// auth.AdminLookup so the requireAdmin middleware can stay in package auth
|
||||
// without importing services. Returns (false, nil) for an unknown / unonboarded
|
||||
// user — which is what we want: a missing paliad.users row is not an admin.
|
||||
// IsAdmin reports whether the given user has the global_admin permission.
|
||||
// Implements auth.AdminLookup so the requireAdmin middleware can stay in
|
||||
// package auth without importing services. Returns (false, nil) for an
|
||||
// unknown / unonboarded user — a missing paliad.users row is not an admin.
|
||||
func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
u, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return u != nil && u.Role == "admin", nil
|
||||
return u != nil && u.GlobalRole == "global_admin", nil
|
||||
}
|
||||
|
||||
// AdminCreateInput is the payload an admin uses to onboard a colleague who
|
||||
@@ -353,7 +359,7 @@ type AdminCreateInput struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Office string `json:"office"`
|
||||
Role string `json:"role,omitempty"` // defaults to 'associate'
|
||||
JobTitle string `json:"job_title,omitempty"` // defaults to 'Associate'
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
Lang string `json:"lang,omitempty"` // defaults to 'de'
|
||||
}
|
||||
@@ -366,9 +372,8 @@ type AdminCreateInput struct {
|
||||
// the given email's auth.users id. Returns a wrapped ErrInvalidInput when the
|
||||
// email isn't in auth.users at all (so the handler can map to 404).
|
||||
//
|
||||
// 'admin' is rejected here for the same reason it's rejected in UpdateProfile:
|
||||
// promotions to admin must go through SQL, not the admin UI. This keeps the
|
||||
// blast radius of an admin's leaked session contained.
|
||||
// global_role is always 'standard' on this path. Promotion to global_admin
|
||||
// is a separate AdminUpdateUser call so it can't be smuggled into create.
|
||||
func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInput) (*models.User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(input.Email))
|
||||
if email == "" {
|
||||
@@ -381,12 +386,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
if !offices.IsValid(input.Office) {
|
||||
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
||||
}
|
||||
role := strings.TrimSpace(input.Role)
|
||||
if role == "" {
|
||||
role = "associate"
|
||||
}
|
||||
if role == "admin" {
|
||||
return nil, ErrAdminBootstrapOnly
|
||||
jobTitle := strings.TrimSpace(input.JobTitle)
|
||||
if jobTitle == "" {
|
||||
jobTitle = "Associate"
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(input.Lang))
|
||||
if lang == "" {
|
||||
@@ -433,9 +435,9 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat, lang)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
authID, email, displayName, input.Office, role, dezernat, lang,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, job_title, global_role, dezernat, lang)
|
||||
VALUES ($1, $2, $3, $4, $5, 'standard', $6, $7)`,
|
||||
authID, email, displayName, input.Office, jobTitle, dezernat, lang,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
@@ -452,7 +454,8 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
|
||||
type AdminUpdateInput struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Office *string `json:"office,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
GlobalRole *string `json:"global_role,omitempty"`
|
||||
Dezernat *string `json:"dezernat,omitempty"`
|
||||
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
@@ -463,13 +466,18 @@ type AdminUpdateInput struct {
|
||||
}
|
||||
|
||||
// AdminUpdateUser mutates any paliad.users row. Same validation rules as
|
||||
// UpdateProfile (no role='admin' assignment, valid office/lang/timezone).
|
||||
// Returns ErrUserNotOnboarded when the target row is missing.
|
||||
// UpdateProfile, plus: AdminUpdate may write additional_offices and
|
||||
// global_role (the privileged fields that self-service must not touch).
|
||||
// Returns ErrUserNotOnboarded when the target row is missing. Returns
|
||||
// ErrLastGlobalAdmin when the call would demote the last global_admin.
|
||||
//
|
||||
// Why a separate method instead of routing through UpdateProfile: the self
|
||||
// service path is intentionally narrow (the user touches their own row), and
|
||||
// AdminUpdate also writes additional_offices, which we do not want exposed on
|
||||
// PATCH /api/me.
|
||||
// Note: this method assumes the caller already passed
|
||||
// auth.RequireAdmin — the handler enforces "only existing global admins
|
||||
// may call this endpoint". The last-admin guard runs unconditionally here
|
||||
// regardless, as a belt-and-braces safety net.
|
||||
//
|
||||
// JobTitle of "" (empty after trim) clears job_title to NULL — admins
|
||||
// without a real job title legitimately store NULL.
|
||||
func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input AdminUpdateInput) (*models.User, error) {
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -492,16 +500,48 @@ func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input A
|
||||
args = append(args, *input.Office)
|
||||
i++
|
||||
}
|
||||
if input.Role != nil {
|
||||
role := strings.TrimSpace(*input.Role)
|
||||
if role == "" {
|
||||
return nil, fmt.Errorf("%w: role cannot be empty", ErrInvalidInput)
|
||||
if input.JobTitle != nil {
|
||||
jt := strings.TrimSpace(*input.JobTitle)
|
||||
var val any
|
||||
if jt == "" {
|
||||
val = nil
|
||||
} else {
|
||||
val = jt
|
||||
}
|
||||
if role == "admin" {
|
||||
return nil, ErrAdminBootstrapOnly
|
||||
sets = append(sets, fmt.Sprintf("job_title = $%d", i))
|
||||
args = append(args, val)
|
||||
i++
|
||||
}
|
||||
if input.GlobalRole != nil {
|
||||
gr := strings.TrimSpace(*input.GlobalRole)
|
||||
if gr != "standard" && gr != "global_admin" {
|
||||
return nil, fmt.Errorf("%w: invalid global_role %q (expected 'standard' or 'global_admin')", ErrInvalidInput, gr)
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("role = $%d", i))
|
||||
args = append(args, role)
|
||||
// Last-admin guard: refuse to demote the only remaining global_admin
|
||||
// so the firm can't lock itself out of /admin/team.
|
||||
if gr == "standard" {
|
||||
var current string
|
||||
if err := s.db.GetContext(ctx, ¤t,
|
||||
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotOnboarded
|
||||
}
|
||||
return nil, fmt.Errorf("lookup global_role: %w", err)
|
||||
}
|
||||
if current == "global_admin" {
|
||||
var others int
|
||||
if err := s.db.GetContext(ctx, &others,
|
||||
`SELECT count(*) FROM paliad.users
|
||||
WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("count admins: %w", err)
|
||||
}
|
||||
if others == 0 {
|
||||
return nil, ErrLastGlobalAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
sets = append(sets, fmt.Sprintf("global_role = $%d", i))
|
||||
args = append(args, gr)
|
||||
i++
|
||||
}
|
||||
if input.Dezernat != nil {
|
||||
@@ -627,22 +667,22 @@ func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var role string
|
||||
if err := tx.GetContext(ctx, &role,
|
||||
`SELECT role FROM paliad.users WHERE id = $1`, id); err != nil {
|
||||
var globalRole string
|
||||
if err := tx.GetContext(ctx, &globalRole,
|
||||
`SELECT global_role FROM paliad.users WHERE id = $1`, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrUserNotOnboarded
|
||||
}
|
||||
return fmt.Errorf("lookup user: %w", err)
|
||||
}
|
||||
if role == "admin" {
|
||||
if globalRole == "global_admin" {
|
||||
var others int
|
||||
if err := tx.GetContext(ctx, &others,
|
||||
`SELECT count(*) FROM paliad.users WHERE role = 'admin' AND id <> $1`, id); err != nil {
|
||||
`SELECT count(*) FROM paliad.users WHERE global_role = 'global_admin' AND id <> $1`, id); err != nil {
|
||||
return fmt.Errorf("count admins: %w", err)
|
||||
}
|
||||
if others == 0 {
|
||||
return fmt.Errorf("%w: cannot delete the last remaining admin", ErrInvalidInput)
|
||||
return ErrLastGlobalAdmin
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,18 +56,20 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
users, pool, done := setupUserTest(t)
|
||||
defer done()
|
||||
|
||||
// Ensure the table is empty so the bootstrap-admin gate is deterministic.
|
||||
// Ensure the table is empty so the bootstrap path is deterministic. The
|
||||
// first inserter becomes global_admin; this test just exercises the rest
|
||||
// of the Create shape and isn't asserting on global_role.
|
||||
pool.ExecContext(context.Background(), `DELETE FROM paliad.users`)
|
||||
|
||||
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
|
||||
seedAuthUser(t, pool, id, "first@hlc.com")
|
||||
defer cleanupUsers(t, pool, id)
|
||||
|
||||
dezernat := " Team M\u00fcller "
|
||||
dezernat := " Team Müller "
|
||||
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: " First User ",
|
||||
Office: "munich",
|
||||
Role: "Trainee",
|
||||
JobTitle: "Trainee",
|
||||
Dezernat: &dezernat,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -79,10 +81,10 @@ func TestUserService_Create_Valid(t *testing.T) {
|
||||
if u.DisplayName != "First User" {
|
||||
t.Errorf("display_name not trimmed: %q", u.DisplayName)
|
||||
}
|
||||
if u.Office != "munich" || u.Role != "Trainee" || u.Email != "first@hlc.com" {
|
||||
if u.Office != "munich" || u.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" {
|
||||
t.Errorf("field mismatch: %+v", u)
|
||||
}
|
||||
if u.Dezernat == nil || *u.Dezernat != "Team M\u00fcller" {
|
||||
if u.Dezernat == nil || *u.Dezernat != "Team Müller" {
|
||||
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
|
||||
}
|
||||
}
|
||||
@@ -99,9 +101,9 @@ func TestUserService_Create_InvalidInput(t *testing.T) {
|
||||
name string
|
||||
input CreateUserInput
|
||||
}{
|
||||
{"missing display_name", CreateUserInput{Office: "munich", Role: "associate"}},
|
||||
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", Role: "associate"}},
|
||||
{"missing role", CreateUserInput{DisplayName: "X", Office: "munich", Role: " "}},
|
||||
{"missing display_name", CreateUserInput{Office: "munich", JobTitle: "associate"}},
|
||||
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", JobTitle: "associate"}},
|
||||
{"missing job_title", CreateUserInput{DisplayName: "X", Office: "munich", JobTitle: " "}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -121,7 +123,7 @@ func TestUserService_Create_DuplicateReturns409(t *testing.T) {
|
||||
defer cleanupUsers(t, pool, id)
|
||||
|
||||
ctx := context.Background()
|
||||
in := CreateUserInput{DisplayName: "Dup", Office: "munich", Role: "associate"}
|
||||
in := CreateUserInput{DisplayName: "Dup", Office: "munich", JobTitle: "associate"}
|
||||
if _, err := users.Create(ctx, id, "dup@hlc.com", in); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
@@ -131,12 +133,14 @@ func TestUserService_Create_DuplicateReturns409(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
|
||||
// TestUserService_Create_BootstrapPromotesFirstUser asserts the bootstrap
|
||||
// rule: when paliad.users is empty, the first inserter is silently promoted
|
||||
// to global_admin. Subsequent inserters default to standard.
|
||||
func TestUserService_Create_BootstrapPromotesFirstUser(t *testing.T) {
|
||||
users, pool, done := setupUserTest(t)
|
||||
defer done()
|
||||
ctx := context.Background()
|
||||
|
||||
// Start with an empty table so the first admin is the bootstrap admin.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
|
||||
|
||||
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000011")
|
||||
@@ -145,23 +149,75 @@ func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
|
||||
seedAuthUser(t, pool, second, "second@hlc.com")
|
||||
defer cleanupUsers(t, pool, first, second)
|
||||
|
||||
if _, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: "First", Office: "munich", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("bootstrap admin: %v", err)
|
||||
}
|
||||
|
||||
_, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
|
||||
DisplayName: "Second", Office: "munich", Role: "admin",
|
||||
u1, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: "First", Office: "munich", JobTitle: "Partner",
|
||||
})
|
||||
if !errors.Is(err, ErrAdminBootstrapOnly) {
|
||||
t.Fatalf("expected ErrAdminBootstrapOnly, got %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap user: %v", err)
|
||||
}
|
||||
if u1.GlobalRole != "global_admin" {
|
||||
t.Fatalf("first user should be global_admin, got %q", u1.GlobalRole)
|
||||
}
|
||||
|
||||
// Non-admin role still works for the second user.
|
||||
if _, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
|
||||
DisplayName: "Second", Office: "munich", Role: "associate",
|
||||
}); err != nil {
|
||||
t.Fatalf("second user as associate: %v", err)
|
||||
u2, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
|
||||
DisplayName: "Second", Office: "munich", JobTitle: "Associate",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second user: %v", err)
|
||||
}
|
||||
if u2.GlobalRole != "standard" {
|
||||
t.Fatalf("second user should be standard, got %q", u2.GlobalRole)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserService_AdminUpdateUser_LastGlobalAdmin asserts the demotion guard.
|
||||
func TestUserService_AdminUpdateUser_LastGlobalAdmin(t *testing.T) {
|
||||
users, pool, done := setupUserTest(t)
|
||||
defer done()
|
||||
ctx := context.Background()
|
||||
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
|
||||
|
||||
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000021")
|
||||
seedAuthUser(t, pool, first, "lonely@hlc.com")
|
||||
defer cleanupUsers(t, pool, first)
|
||||
|
||||
if _, err := users.Create(ctx, first, "lonely@hlc.com", CreateUserInput{
|
||||
DisplayName: "Lonely", Office: "munich", JobTitle: "Counsel",
|
||||
}); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
standard := "standard"
|
||||
_, err := users.AdminUpdateUser(ctx, first, AdminUpdateInput{GlobalRole: &standard})
|
||||
if !errors.Is(err, ErrLastGlobalAdmin) {
|
||||
t.Fatalf("expected ErrLastGlobalAdmin, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserService_IsAdmin asserts IsAdmin reads the global_role column.
|
||||
func TestUserService_IsAdmin(t *testing.T) {
|
||||
users, pool, done := setupUserTest(t)
|
||||
defer done()
|
||||
ctx := context.Background()
|
||||
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
|
||||
|
||||
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000031")
|
||||
seedAuthUser(t, pool, id, "first@hlc.com")
|
||||
defer cleanupUsers(t, pool, id)
|
||||
|
||||
if _, err := users.Create(ctx, id, "first@hlc.com", CreateUserInput{
|
||||
DisplayName: "First", Office: "munich", JobTitle: "Counsel",
|
||||
}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
got, err := users.IsAdmin(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("IsAdmin: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Fatalf("bootstrap user should pass IsAdmin")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user