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:
tajniak81
2026-08-17 16:54:02 +02:00
co-authored by Claude Opus 5
parent 358ee68f94
commit cd16d4383f
30 changed files with 1193 additions and 63 deletions
+11
View File
@@ -208,6 +208,17 @@ export const api = {
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
// Organizations. Listing is manager-only (an admin sees just their own org),
// but creating is open to any user without one — the creator becomes its
// admin. Renaming and deleting are scoped to the caller's own org unless they
// are a superadmin.
listOrgs: () => request("/orgs").then((r) => r.organizations),
createOrg: (name) =>
request("/orgs", { method: "POST", body: JSON.stringify({ name }) }).then((r) => r.organization),
updateOrg: (id, name) =>
request(`/orgs/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }).then((r) => r.organization),
deleteOrg: (id) => request(`/orgs/${id}`, { method: "DELETE" }),
// Settings — account/profile/appearance
getMe: () => request("/me"),
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
+203
View File
@@ -0,0 +1,203 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { api } from "../api";
import { state, refreshProfile } from "../auth";
import { t } from "../i18n";
// Organization management, adapting to who is looking:
// - a user with no organization gets a "create your own" form, and becomes the
// admin of what they create;
// - an admin sees their own org with Rename + Delete (deleting it detaches them
// and drops them back to a plain user);
// - a superadmin sees every org and can create, rename and delete any of them.
// The API Server enforces all of this; this component only mirrors it.
const role = computed(() => state.profile?.role || state.user?.role || "user");
const isSuperadmin = computed(() => role.value === "superadmin");
const isManager = computed(() => ["admin", "superadmin"].includes(role.value));
const myOrg = computed(() => state.profile?.organization || "");
// Anyone who is not a superadmin and has no org yet can stand one up.
const showCreateOwn = computed(() => !isSuperadmin.value && !myOrg.value);
const orgs = ref([]);
const error = ref("");
const busy = ref(false);
const editing = ref(null); // org id, or "new"
const draftName = ref("");
const createName = ref(""); // for the org-less "create your own" form
async function load() {
// Listing is manager-only; an org-less user just gets the create form.
if (!isManager.value) {
orgs.value = [];
return;
}
try {
orgs.value = (await api.listOrgs()) || [];
error.value = "";
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
// An org-less user creates their first org and is promoted to its admin.
async function createOwnOrg() {
const name = createName.value.trim();
if (!name) return;
busy.value = true;
error.value = "";
try {
await api.createOrg(name);
createName.value = "";
await refreshProfile(); // role -> admin, organization now set
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
function startNew() {
editing.value = "new";
draftName.value = "";
}
function startEdit(o) {
editing.value = o.id;
draftName.value = o.name;
}
function cancel() {
editing.value = null;
error.value = "";
}
async function save() {
const name = draftName.value.trim();
if (!name) return;
busy.value = true;
error.value = "";
try {
if (editing.value === "new") {
await api.createOrg(name);
if (!isSuperadmin.value) await refreshProfile();
} else {
await api.updateOrg(editing.value, name);
}
editing.value = null;
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
async function remove(o) {
const mine = !isSuperadmin.value && o.id === myOrg.value;
const msg = mine
? t("settings.org.confirmDeleteOwn", { name: o.name })
: t("settings.org.confirmDelete", { name: o.name });
if (!confirm(msg)) return;
busy.value = true;
error.value = "";
try {
await api.deleteOrg(o.id);
if (mine) await refreshProfile(); // now org-less and demoted to user
await load();
} catch (e) {
// The server refuses (409) while the org still has other members.
error.value = e.message;
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">
{{ isSuperadmin ? t("settings.org.titleAll") : t("settings.org.title") }}
</h2>
<p class="mt-0.5 text-xs text-muted">
{{
isSuperadmin
? t("settings.org.subtitleAll")
: showCreateOwn
? t("settings.org.subtitleNone")
: t("settings.org.subtitleOwn")
}}
</p>
</div>
<button v-if="isSuperadmin" class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" @click="startNew">
{{ t("settings.org.newOrg") }}
</button>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">
{{ error }}
</p>
<!-- Org-less user: create your own organization and become its admin -->
<div v-if="showCreateOwn">
<label class="dh-label">{{ t("settings.org.nameLabel") }}</label>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="createName"
class="dh-input max-w-sm"
:placeholder="t('settings.org.namePlaceholder')"
autocomplete="off"
@keyup.enter="createOwnOrg"
/>
<button class="dh-btn shrink-0" :disabled="busy || !createName.trim()" @click="createOwnOrg">
{{ t("settings.org.create") }}
</button>
</div>
<p class="mt-2 text-xs text-muted">{{ t("settings.org.createHint") }}</p>
</div>
<!-- Managers: manage the organization(s) they own -->
<template v-else>
<div v-if="editing" class="mb-4 rounded-control bg-sunken p-4">
<label class="dh-label">{{ t("settings.org.nameLabel") }}</label>
<input
v-model="draftName"
class="dh-input max-w-sm"
:placeholder="t('settings.org.namePlaceholder')"
autocomplete="off"
@keyup.enter="save"
/>
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
{{ editing === "new" ? t("settings.org.create") : t("common.save") }}
</button>
<button class="dh-btn dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!orgs.length" class="text-sm text-muted">{{ t("settings.org.empty") }}</p>
<ul v-else class="divide-y divide-subtle">
<li v-for="o in orgs" :key="o.id" class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-strong">{{ o.name }}</p>
<p class="data truncate text-xs text-muted">{{ o.id }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="startEdit(o)">
{{ t("settings.org.rename") }}
</button>
<button
class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-danger"
:disabled="busy"
@click="remove(o)"
>
{{ t("common.delete") }}
</button>
</div>
</li>
</ul>
</template>
</section>
</template>
+18 -1
View File
@@ -108,7 +108,7 @@
"title": "Brugere",
"subtitleAll": "Konti på tværs af alle organisationer.",
"subtitleOrg": "Konti i din organisation.",
"subtitleOrgsNote": "Organisationer tildeles i API-panelet.",
"subtitleOrgsNote": "Organisationer administreres under Indstillinger.",
"addUser": "Tilføj bruger",
"colEmail": "E-mail",
"colName": "Navn",
@@ -199,6 +199,23 @@
"saveBio": "Gem beskrivelse"
},
"org": {
"title": "Organisation",
"titleAll": "Organisationer",
"subtitleOwn": "Den organisation du administrerer",
"subtitleNone": "Opret en for at administrere dit eget team",
"subtitleAll": "Enheder, som brugere tilhører",
"nameLabel": "Organisationsnavn",
"namePlaceholder": "Acme Fleet",
"newOrg": "Ny organisation",
"create": "Opret organisation",
"createHint": "Du bliver dens administrator og kan derefter tilføje og administrere brugere.",
"rename": "Omdøb",
"empty": "Ingen organisationer endnu.",
"confirmDelete": "Slet organisationen „{name}“? Dette kan ikke fortrydes.",
"confirmDeleteOwn": "Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger. Dette kan ikke fortrydes."
},
"privacy": {
"title": "Privatliv og sikkerhed",
"signOut": "Log ud",
+18 -1
View File
@@ -126,7 +126,7 @@
"title": "Users",
"subtitleAll": "Accounts across every organization.",
"subtitleOrg": "Accounts in your organization.",
"subtitleOrgsNote": "Organizations are assigned in the API panel.",
"subtitleOrgsNote": "Organizations are managed in Settings.",
"addUser": "Add user",
"colEmail": "Email",
"colName": "Name",
@@ -274,6 +274,23 @@
"controlDisconnected": "Not connected"
},
"org": {
"title": "Organization",
"titleAll": "Organizations",
"subtitleOwn": "The organization you administer",
"subtitleNone": "Create one to manage your own team",
"subtitleAll": "Tenants users belong to",
"nameLabel": "Organization name",
"namePlaceholder": "Acme Fleet",
"newOrg": "New organization",
"create": "Create organization",
"createHint": "You'll become its admin and can then add and manage users.",
"rename": "Rename",
"empty": "No organizations yet.",
"confirmDelete": "Delete the organization “{name}”? This cannot be undone.",
"confirmDeleteOwn": "Delete your organization “{name}”? You'll be removed from it and become a regular user. This cannot be undone."
},
"privacy": {
"title": "Privacy & security",
"signOut": "Sign out",
+18 -1
View File
@@ -112,7 +112,7 @@
"title": "Użytkownicy",
"subtitleAll": "Konta ze wszystkich organizacji.",
"subtitleOrg": "Konta w Twojej organizacji.",
"subtitleOrgsNote": "Organizacje przypisuje się w panelu API.",
"subtitleOrgsNote": "Organizacjami zarządza się w Ustawieniach.",
"addUser": "Dodaj użytkownika",
"colEmail": "E-mail",
"colName": "Imię i nazwisko",
@@ -203,6 +203,23 @@
"saveBio": "Zapisz opis"
},
"org": {
"title": "Organizacja",
"titleAll": "Organizacje",
"subtitleOwn": "Organizacja, którą administrujesz",
"subtitleNone": "Utwórz ją, aby zarządzać własnym zespołem",
"subtitleAll": "Podmioty, do których należą użytkownicy",
"nameLabel": "Nazwa organizacji",
"namePlaceholder": "Acme Fleet",
"newOrg": "Nowa organizacja",
"create": "Utwórz organizację",
"createHint": "Zostaniesz jej administratorem i będziesz móc dodawać użytkowników oraz nimi zarządzać.",
"rename": "Zmień nazwę",
"empty": "Brak organizacji.",
"confirmDelete": "Usunąć organizację „{name}”? Tego nie można cofnąć.",
"confirmDeleteOwn": "Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem. Tego nie można cofnąć."
},
"privacy": {
"title": "Prywatność i bezpieczeństwo",
"signOut": "Wyloguj się",
+4
View File
@@ -6,6 +6,7 @@ import { state, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import OrgManager from "../components/OrgManager.vue";
const router = useRouter();
@@ -1258,6 +1259,9 @@ onBeforeUnmount(() => {
<!-- Personal settings (continued) -->
<div v-show="activeTab === 'personal'" class="space-y-6">
<!-- Organization: create your own (becoming its admin), or manage it -->
<OrgManager />
<!-- Privacy & Security -->
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">