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>
88 lines
2.6 KiB
Dart
88 lines
2.6 KiB
Dart
import "dart:convert";
|
|
import "package:flutter/foundation.dart";
|
|
import "package:shared_preferences/shared_preferences.dart";
|
|
|
|
import "api.dart";
|
|
import "models.dart";
|
|
|
|
const _tokenKey = "cc_token";
|
|
const _userKey = "cc_user";
|
|
|
|
/// Holds session state and persists the token across app restarts.
|
|
class AuthService extends ChangeNotifier {
|
|
final ApiClient api;
|
|
AuthUser? user;
|
|
bool ready = false;
|
|
|
|
/// In-memory (never persisted) app-lock flag. When biometric login is enabled
|
|
/// the app starts/returns locked: the token is still valid but the UI hides
|
|
/// behind a biometric unlock instead of jumping straight to the dashboard.
|
|
bool locked = false;
|
|
|
|
AuthService(this.api) {
|
|
api.onUnauthorized = () => logout();
|
|
}
|
|
|
|
bool get isAuthenticated => api.token != null;
|
|
|
|
void lock() {
|
|
if (!locked && isAuthenticated) {
|
|
locked = true;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void unlock() {
|
|
if (locked) {
|
|
locked = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> loadFromStorage() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final t = prefs.getString(_tokenKey);
|
|
final u = prefs.getString(_userKey);
|
|
if (t != null) {
|
|
api.token = t;
|
|
if (u != null) user = AuthUser.fromJson(jsonDecode(u));
|
|
}
|
|
ready = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> login(String email, String password) async {
|
|
final (token, u) = await api.login(email, password);
|
|
api.token = token;
|
|
user = u;
|
|
locked = false;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_tokenKey, token);
|
|
await prefs.setString(_userKey, jsonEncode(u.toJson()));
|
|
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;
|
|
locked = false;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_tokenKey);
|
|
await prefs.remove(_userKey);
|
|
notifyListeners();
|
|
}
|
|
}
|