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
+4 -3
View File
@@ -58,9 +58,10 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
- **Settings** — account (name / email verification / password), appearance
(theme + dark mode, **language**, **region**, date format, **currency**, font
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
Anker Solix), **Security** (biometric toggle), and the account-deletion state
machine. Auth relays PocketBase's own stateless tokens, so there is no
per-device session list to show or revoke.
Anker Solix), **Security** (biometric toggle), **Organization** (create your
own — which makes you its admin — or rename/delete the one you administer), and
the account-deletion state machine. Auth relays PocketBase's own stateless
tokens, so there is no per-device session list to show or revoke.
- **Users (admin)** — user management tab (list / create / role / reset password
/ delete), shown only for the admin role.
+18
View File
@@ -177,6 +177,24 @@
"confirmPassword": "Bekræft din adgangskode",
"password": "Adgangskode"
},
"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",
"delete": "Slet",
"deleteTitle": "Slet organisation?",
"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",
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.",
+18
View File
@@ -264,6 +264,24 @@
"password": "Password"
},
"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",
"delete": "Delete",
"deleteTitle": "Delete organization?",
"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",
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out ends this device's session only. To lock out every device, change your password above.",
+18
View File
@@ -181,6 +181,24 @@
"confirmPassword": "Potwierdź hasło",
"password": "Hasło"
},
"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ę",
"delete": "Usuń",
"deleteTitle": "Usunąć organizację?",
"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",
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.",
+23
View File
@@ -178,6 +178,29 @@ class ApiClient {
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
// --- organizations ---
// Listing is manager-only (an admin sees just their own org), but creating is
// open to any user who has none — the creator becomes that org's admin in the
// same request. Renames and deletes are scoped to the caller's own org unless
// they are a superadmin. Responses are enveloped ({organizations}/{organization}).
Future<List<Organization>> listOrgs() async {
final data = await _send("GET", "/orgs");
final items = (data["organizations"] ?? []) as List;
return items.map((e) => Organization.fromJson(Map<String, dynamic>.from(e))).toList();
}
Future<Organization> createOrg(String name) async {
final data = await _send("POST", "/orgs", body: {"name": name});
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
}
Future<Organization> renameOrg(String id, String name) async {
final data = await _send("PATCH", "/orgs/$id", body: {"name": name});
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
}
Future<void> deleteOrg(String id) => _send("DELETE", "/orgs/$id");
// --- service records ---
Future<List<ServiceRecord>> listCarServices(String carId) async {
final data = await _send("GET", "/cars/$carId/service-records") as List;
+13
View File
@@ -62,6 +62,19 @@ class AuthService extends ChangeNotifier {
notifyListeners();
}
/// Adopts the role from a freshly fetched profile. A session's role can change
/// under it — creating an organization promotes the creator to that org's admin
/// — and the nav gates the Users tab on the cached copy, so it has to catch up
/// without requiring a re-login. A no-op when the role is unchanged.
Future<void> adoptRole(UserProfile profile) async {
final u = user;
if (u == null || profile.role == u.role) return;
user = AuthUser(id: u.id, email: u.email, name: u.name, role: profile.role);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_userKey, jsonEncode(user!.toJson()));
notifyListeners();
}
Future<void> logout() async {
api.token = null;
user = null;
+21
View File
@@ -663,6 +663,21 @@ class AdminUser {
bool get isSuperadmin => role == "superadmin";
}
/// A tenant users belong to, as returned by the organization endpoints.
class Organization {
final String id;
final String name;
final String created;
Organization({required this.id, required this.name, this.created = ""});
factory Organization.fromJson(Map<String, dynamic> j) => Organization(
id: _asStr(j["id"]),
name: _asStr(j["name"]),
created: _asStr(j["created"]),
);
}
/// The full authenticated profile (Settings panel), mirroring /api/me.
class UserProfile {
final String id;
@@ -677,6 +692,8 @@ class UserProfile {
final String currency; // ISO 4217 code, e.g. "EUR"
final String fontSize; // small | medium | large
final String role; // user | admin
final String organization; // org record id ("" = belongs to no organization)
final String organizationName; // resolved name ("" when unset/unresolvable)
final DateTime? deletionRequestedAt;
UserProfile({
@@ -692,6 +709,8 @@ class UserProfile {
this.currency = "USD",
required this.fontSize,
required this.role,
this.organization = "",
this.organizationName = "",
required this.deletionRequestedAt,
});
@@ -708,6 +727,8 @@ class UserProfile {
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
role: j["role"] == null ? "user" : _asStr(j["role"]),
organization: _asStr(j["organization"]),
organizationName: _asStr(j["organizationName"]),
deletionRequestedAt: j["deletionRequestedAt"] == null
? null
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
+252
View File
@@ -50,6 +50,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
final p = await apiClient.getMe();
appSettings.applyFromProfile(p);
// The profile is the authority on the role; creating or deleting an
// organization changes it, so keep the session's cached copy (which gates
// the Users tab) in step.
await authService.adoptRole(p);
Uint8List? avatar;
if (p.hasAvatar) {
final bytes = await apiClient.getAvatarBytes();
@@ -128,6 +132,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 12),
_SecuritySection(email: _profile!.email, snack: _snack),
const SizedBox(height: 12),
_OrganizationSection(profile: _profile!, onChanged: _load, snack: _snack),
const SizedBox(height: 12),
const _PrivacySection(),
const SizedBox(height: 12),
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
@@ -858,6 +864,252 @@ class _SecuritySectionState extends State<_SecuritySection> {
}
}
// --- Organization -----------------------------------------------------------
/// Organization membership, adapting to who is looking:
/// - a user with no organization gets a "create your own" field, 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 card only mirrors it.
class _OrganizationSection extends StatefulWidget {
final UserProfile profile;
final Future<void> Function() onChanged;
final void Function(String) snack;
const _OrganizationSection({
required this.profile,
required this.onChanged,
required this.snack,
});
@override
State<_OrganizationSection> createState() => _OrganizationSectionState();
}
class _OrganizationSectionState extends State<_OrganizationSection> {
final _createName = TextEditingController();
List<Organization> _orgs = [];
bool _busy = false;
bool get _isSuperadmin => widget.profile.isSuperadmin;
bool get _isManager => widget.profile.isAdmin;
String get _myOrg => widget.profile.organization;
// Anyone who is not a superadmin and has no org yet can stand one up.
bool get _showCreateOwn => !_isSuperadmin && _myOrg.isEmpty;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_createName.dispose();
super.dispose();
}
Future<void> _load() async {
// Listing is manager-only; an org-less user just gets the create field.
if (!_isManager) {
if (mounted) setState(() => _orgs = []);
return;
}
try {
final list = await apiClient.listOrgs();
if (mounted) setState(() => _orgs = list);
} catch (e) {
widget.snack("$e");
}
}
/// Creating an org as a non-superadmin promotes the caller to its admin, so the
/// profile is reloaded to pick up the new role + membership.
Future<void> _create(String name) async {
if (name.trim().isEmpty) return;
setState(() => _busy = true);
try {
await apiClient.createOrg(name.trim());
_createName.clear();
if (!_isSuperadmin) await widget.onChanged();
await _load();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _promptCreate() async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.newOrg")),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
hintText: t("settings.org.namePlaceholder"),
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("settings.org.create"))),
],
),
);
if (ok == true) await _create(controller.text);
}
Future<void> _promptRename(Organization o) async {
final controller = TextEditingController(text: o.name);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.rename")),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("common.save"))),
],
),
);
if (ok != true || controller.text.trim().isEmpty) return;
setState(() => _busy = true);
try {
await apiClient.renameOrg(o.id, controller.text.trim());
await _load();
} catch (e) {
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _promptDelete(Organization o) async {
// Deleting your own org detaches you from it and demotes you to a plain user.
final mine = !_isSuperadmin && o.id == _myOrg;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("settings.org.deleteTitle")),
content: Text(mine
? t("settings.org.confirmDeleteOwn", params: {"name": o.name})
: t("settings.org.confirmDelete", params: {"name": o.name})),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t("settings.org.delete")),
),
],
),
);
if (confirmed != true) return;
setState(() => _busy = true);
try {
await apiClient.deleteOrg(o.id);
if (mine) await widget.onChanged(); // now org-less and demoted to user
await _load();
} catch (e) {
// The server refuses (409) while the org still has other members.
widget.snack("$e");
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
return _Card(
title: _isSuperadmin ? t("settings.org.titleAll") : t("settings.org.title"),
children: [
Text(
_isSuperadmin
? t("settings.org.subtitleAll")
: _showCreateOwn
? t("settings.org.subtitleNone")
: t("settings.org.subtitleOwn"),
style: TextStyle(fontSize: 12, color: muted),
),
const SizedBox(height: 12),
if (_showCreateOwn) ...[
TextField(
controller: _createName,
decoration: InputDecoration(
labelText: t("settings.org.nameLabel"),
hintText: t("settings.org.namePlaceholder"),
border: const OutlineInputBorder(),
),
onSubmitted: _busy ? null : _create,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: _busy ? null : () => _create(_createName.text),
child: Text(_busy ? t("common.saving") : t("settings.org.create")),
),
),
const SizedBox(height: 8),
Text(t("settings.org.createHint"), style: TextStyle(fontSize: 12, color: muted)),
] else ...[
if (_isSuperadmin)
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: _busy ? null : _promptCreate,
child: Text(t("settings.org.newOrg")),
),
),
if (_orgs.isEmpty)
Text(t("settings.org.empty"), style: TextStyle(fontSize: 13, color: muted))
else
for (final o in _orgs)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(o.name, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(o.id, style: TextStyle(fontSize: 11, color: muted)),
],
),
),
IconButton(
tooltip: t("settings.org.rename"),
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: _busy ? null : () => _promptRename(o),
),
IconButton(
tooltip: t("settings.org.delete"),
icon: const Icon(Icons.delete_outline, size: 20, color: DriverVault.danger),
onPressed: _busy ? null : () => _promptDelete(o),
),
],
),
),
],
],
);
}
}
// --- Privacy & security -----------------------------------------------------
/// Sessions are PocketBase's own stateless tokens, so there is no per-device