Files
DriverVault/API Server/panel/src/components/OrgsCard.vue
T
tajniak81andClaude Opus 5 cd16d4383f 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>
2026-08-17 16:54:02 +02:00

154 lines
5.1 KiB
Vue

<script setup>
import { computed, ref, onMounted } from "vue";
import { isManager, isSuperadmin, loadMe, me, request } from "../api";
import { t } from "../i18n";
// 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 || [];
error.value = "";
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
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() {
busy.value = true;
error.value = "";
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",
body: { name: draftName.value },
});
}
editing.value = null;
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
async function remove(o) {
// 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 other members.
error.value = e.message;
} finally {
busy.value = false;
}
}
</script>
<template>
<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">
{{ 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="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>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draftName" class="dh-input" :placeholder="t('orgs.namePlaceholder')" @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("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
{{ canCreate && !isSuperadmin ? t("orgs.createHint") : t("orgs.empty") }}
</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>{{ t("common.name") }}</th>
<th>{{ t("orgs.colId") }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="o in orgs" :key="o.id" class="border-t border-subtle transition-colors hover:bg-sunken">
<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="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") }}
</button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>