Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed an out-of-band superadmin. Creating one is now self-service, and an admin manages the org they belong to. - POST /api/orgs is open to any authenticated user. A creator who isn't a superadmin must have no organization yet (a single-valued membership relation means a second one would abandon the first), and is promoted to the new org's admin and first member in the same request. If that promotion fails the org is rolled back, so it is never left stranded with nobody able to administer it. Superadmins still create tenants without joining them. - PATCH/DELETE are manager-gated and scope an admin to their own org. An admin deletes theirs only as its sole member: they are detached and demoted to a plain user before the record goes, so the org is empty when it is removed. Other members still block deletion with a 409. - /api/me now carries organization + organizationName, which the clients need to tell "no org yet" from "org you administer". The panel, Web App (new OrgManager.vue in Settings) and Phone App (new _OrganizationSection) all mirror the server's gates rather than re-deciding them. The Phone App cached its role at login and gates the Users tab on it, so AuthService.adoptRole refreshes that from the profile instead of making a freshly promoted admin sign in again. Covered by orgs_test.go, which drives the real handler + middleware chain against a stand-in PocketBase: promotion, the already-a-member refusal, superadmin staying unattached, the rollback, own-org scoping, the detach-and-demote, and the blocking-member 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
358ee68f94
commit
cd16d4383f
@@ -26,7 +26,14 @@ onMounted(async () => {
|
||||
const sections = computed(() => {
|
||||
const out = [{ id: "overview", label: t("sections.overview") }];
|
||||
if (isManager.value) {
|
||||
out.push({ id: "users", label: t("sections.users") }, { id: "orgs", label: t("sections.orgs") });
|
||||
out.push({ id: "users", label: t("sections.users") });
|
||||
}
|
||||
// Organizations is open to everyone: a manager sees the org(s) they manage,
|
||||
// and an org-less user gets the form that stands one up (making them its
|
||||
// admin). Only a caller with neither an org nor a manager role sees nothing
|
||||
// to do there, and they still get the create form.
|
||||
if (isManager.value || me.value) {
|
||||
out.push({ id: "orgs", label: t("sections.orgs") });
|
||||
}
|
||||
if (isSuperadmin.value) {
|
||||
out.push(
|
||||
@@ -129,9 +136,9 @@ const managementApi = [
|
||||
{ method: "PATCH", path: "/api/users/{id}", desc: "Update email / name / role / org / password" },
|
||||
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user" },
|
||||
{ method: "GET", path: "/api/orgs", desc: "List organizations" },
|
||||
{ method: "POST", path: "/api/orgs", desc: "Create an organization (superadmin)" },
|
||||
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
|
||||
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an organization (superadmin)" },
|
||||
{ method: "POST", path: "/api/orgs", desc: "Create an organization (any user without one; creator becomes admin)" },
|
||||
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (own org; any as superadmin)" },
|
||||
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an organization (own org; any as superadmin)" },
|
||||
];
|
||||
|
||||
const superadminApi = [
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { isSuperadmin, request } from "../api";
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { isManager, isSuperadmin, loadMe, me, request } from "../api";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// Listing is manager-scoped (an admin sees only their own org); creating,
|
||||
// renaming and deleting are superadmin-only, matching the server's gates.
|
||||
// Mirrors the server's gates: listing is manager-scoped (an admin sees only
|
||||
// their own org); a caller with no organization may create one and becomes its
|
||||
// admin; an admin may rename or delete their own org; a superadmin spans all.
|
||||
const orgs = ref([]);
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const editing = ref(null); // org id, or "new"
|
||||
const draftName = ref("");
|
||||
|
||||
const myOrg = computed(() => me.value?.organization || "");
|
||||
// A superadmin creates tenants freely; anyone else only their first one.
|
||||
const canCreate = computed(() => isSuperadmin.value || !myOrg.value);
|
||||
// An admin manages only their own org; a superadmin manages every org.
|
||||
function canManage(o) {
|
||||
return isSuperadmin.value || o.id === myOrg.value;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// Listing is manager-only; an org-less user just gets the create form.
|
||||
if (!isManager.value) {
|
||||
orgs.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const out = await request("/api/orgs");
|
||||
orgs.value = out.organizations || [];
|
||||
@@ -43,6 +57,9 @@ async function save() {
|
||||
try {
|
||||
if (editing.value === "new") {
|
||||
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
||||
// Creating an org as a non-superadmin makes the caller its admin, so the
|
||||
// local identity (role + organization) is now stale.
|
||||
if (!isSuperadmin.value) await loadMe();
|
||||
} else {
|
||||
await request(`/api/orgs/${editing.value}`, {
|
||||
method: "PATCH",
|
||||
@@ -59,14 +76,18 @@ async function save() {
|
||||
}
|
||||
|
||||
async function remove(o) {
|
||||
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
|
||||
// Deleting your own org detaches you from it and drops you back to a plain user.
|
||||
const mine = !isSuperadmin.value && o.id === myOrg.value;
|
||||
const key = mine ? "orgs.confirmDeleteOwn" : "orgs.confirmDelete";
|
||||
if (!confirm(t(key, { name: o.name }))) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
||||
if (mine) await loadMe(); // now org-less and demoted to user
|
||||
await load();
|
||||
} catch (e) {
|
||||
// The server refuses (409) while the org still has members.
|
||||
// The server refuses (409) while the org still has other members.
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
@@ -78,10 +99,14 @@ async function remove(o) {
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">
|
||||
{{ isSuperadmin ? t("orgs.title") : t("common.organization") }}
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isSuperadmin ? t("orgs.subtitle") : myOrg ? t("orgs.subtitleOwn") : t("orgs.subtitleNone") }}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
|
||||
<button v-if="canCreate" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||
@@ -98,7 +123,7 @@ async function remove(o) {
|
||||
</div>
|
||||
|
||||
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
||||
{{ t("orgs.empty") }}
|
||||
{{ canCreate && !isSuperadmin ? t("orgs.createHint") : t("orgs.empty") }}
|
||||
</p>
|
||||
|
||||
<table v-else class="w-full text-left text-sm">
|
||||
@@ -114,7 +139,7 @@ async function remove(o) {
|
||||
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
||||
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
||||
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
||||
<template v-if="isSuperadmin">
|
||||
<template v-if="canManage(o)">
|
||||
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
|
||||
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
||||
{{ t("common.delete") }}
|
||||
|
||||
@@ -73,11 +73,15 @@
|
||||
"orgs": {
|
||||
"title": "Organisationer",
|
||||
"subtitle": "Enheder, som brugere tilhører",
|
||||
"subtitleOwn": "Din organisation",
|
||||
"subtitleNone": "Opret en for at administrere dit eget team",
|
||||
"newOrg": "Ny organisation",
|
||||
"namePlaceholder": "Acme Fleet",
|
||||
"empty": "Ingen organisationer endnu.",
|
||||
"createHint": "Du har endnu ingen organisation. Opret en for at blive dens administrator.",
|
||||
"colId": "ID",
|
||||
"confirmDelete": "Slet organisationen \"{name}\"?"
|
||||
"confirmDelete": "Slet organisationen \"{name}\"?",
|
||||
"confirmDeleteOwn": "Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger."
|
||||
},
|
||||
|
||||
"pocketbase": {
|
||||
|
||||
@@ -73,11 +73,15 @@
|
||||
"orgs": {
|
||||
"title": "Organizations",
|
||||
"subtitle": "Tenants users belong to",
|
||||
"subtitleOwn": "Your organization",
|
||||
"subtitleNone": "Create one to manage your own team",
|
||||
"newOrg": "New organization",
|
||||
"namePlaceholder": "Acme Fleet",
|
||||
"empty": "No organizations yet.",
|
||||
"createHint": "You have no organization yet. Create one to become its admin.",
|
||||
"colId": "ID",
|
||||
"confirmDelete": "Delete the organization \"{name}\"?"
|
||||
"confirmDelete": "Delete the organization \"{name}\"?",
|
||||
"confirmDeleteOwn": "Delete your organization \"{name}\"? You will be removed from it and become a regular user."
|
||||
},
|
||||
|
||||
"pocketbase": {
|
||||
|
||||
@@ -73,11 +73,15 @@
|
||||
"orgs": {
|
||||
"title": "Organizacje",
|
||||
"subtitle": "Podmioty, do których należą użytkownicy",
|
||||
"subtitleOwn": "Twoja organizacja",
|
||||
"subtitleNone": "Utwórz ją, aby zarządzać własnym zespołem",
|
||||
"newOrg": "Nowa organizacja",
|
||||
"namePlaceholder": "Acme Fleet",
|
||||
"empty": "Brak organizacji.",
|
||||
"createHint": "Nie masz jeszcze organizacji. Utwórz ją, aby zostać jej administratorem.",
|
||||
"colId": "ID",
|
||||
"confirmDelete": "Usunąć organizację „{name}”?"
|
||||
"confirmDelete": "Usunąć organizację „{name}”?",
|
||||
"confirmDeleteOwn": "Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem."
|
||||
},
|
||||
|
||||
"pocketbase": {
|
||||
|
||||
Reference in New Issue
Block a user