Rebuild API Server on the PilotVault structure
Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d55f0a4cd
commit
ae6ed4ac1e
56 files changed
+4474
-1475
No files matched your search
+39
-21
@@ -75,16 +75,39 @@ class ApiClient {
|
||||
|
||||
final data = jsonDecode(res.body);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
|
||||
throw ApiException(res.statusCode, msg ?? "Request failed");
|
||||
throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Digs a human-readable message out of the error shapes in play: this
|
||||
/// server's {error}, and PocketBase's {message, data:{field:{message}}} —
|
||||
/// which the user endpoints relay verbatim, so a duplicate email arrives as a
|
||||
/// per-field error rather than a flat string.
|
||||
String _errorMessage(dynamic data, String? fallback) {
|
||||
if (data is! Map) return fallback ?? "Request failed";
|
||||
if (data["error"] != null) return data["error"].toString();
|
||||
|
||||
final fields = data["data"];
|
||||
if (fields is Map && fields.isNotEmpty) {
|
||||
final parts = fields.entries.map((e) {
|
||||
final v = e.value;
|
||||
final msg = v is Map && v["message"] != null ? v["message"] : v;
|
||||
return "${e.key}: $msg";
|
||||
});
|
||||
return parts.join("; ");
|
||||
}
|
||||
if (data["message"] != null) return data["message"].toString();
|
||||
return fallback ?? "Request failed";
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
/// The API Server proxies login to PocketBase and relays its response
|
||||
/// verbatim, so the user arrives under `record` (PocketBase's name) and the
|
||||
/// token is PocketBase's own — the server no longer mints its own JWT.
|
||||
Future<(String, AuthUser)> login(String email, String password) async {
|
||||
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
|
||||
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["user"])));
|
||||
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
|
||||
}
|
||||
|
||||
// --- cars ---
|
||||
@@ -125,31 +148,35 @@ class ApiClient {
|
||||
Future<void> removeCarShare(String carId, String userId) =>
|
||||
_send("DELETE", "/cars/$carId/shares/$userId");
|
||||
|
||||
// --- admin: user management (admin role only) ---
|
||||
// --- user management (admin or superadmin) ---
|
||||
// Admins are scoped by the server to their own organization; superadmins see
|
||||
// everyone. Responses are enveloped ({users}/{user}).
|
||||
Future<List<AdminUser>> listUsers() async {
|
||||
final data = await _send("GET", "/admin/users") as List;
|
||||
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
final data = await _send("GET", "/users");
|
||||
final items = (data["users"] ?? []) as List;
|
||||
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<AdminUser> createUser(
|
||||
{required String email, required String password, String? name, String role = "user"}) async {
|
||||
final data = await _send("POST", "/admin/users",
|
||||
final data = await _send("POST", "/users",
|
||||
body: {"email": email, "password": password, "name": name ?? "", "role": role});
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data));
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
||||
}
|
||||
|
||||
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (name != null) body["name"] = name;
|
||||
if (role != null) body["role"] = role;
|
||||
final data = await _send("PATCH", "/admin/users/$id", body: body);
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data));
|
||||
final data = await _send("PATCH", "/users/$id", body: body);
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
||||
}
|
||||
|
||||
/// Password resets are a field on the user PATCH now, not a separate endpoint.
|
||||
Future<void> setUserPassword(String id, String newPassword) =>
|
||||
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
|
||||
_send("PATCH", "/users/$id", body: {"password": newPassword});
|
||||
|
||||
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
|
||||
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
|
||||
|
||||
// --- service records ---
|
||||
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
||||
@@ -233,15 +260,6 @@ class ApiClient {
|
||||
|
||||
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
|
||||
|
||||
// --- settings: sessions ---
|
||||
Future<List<Session>> listSessions() async {
|
||||
final data = await _send("GET", "/sessions") as List;
|
||||
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
|
||||
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
|
||||
|
||||
// --- settings: account deletion ---
|
||||
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
|
||||
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
|
||||
|
||||
+19
-32
@@ -176,21 +176,27 @@ class CarShare {
|
||||
String get label => name.isNotEmpty ? name : email;
|
||||
}
|
||||
|
||||
/// Roles allowed to manage users. A superadmin is an admin that also spans
|
||||
/// every organization; the API Server enforces that difference.
|
||||
const _managerRoles = {"admin", "superadmin"};
|
||||
|
||||
class AuthUser {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final String role; // "user" | "admin"
|
||||
final String role; // "user" | "admin" | "superadmin"
|
||||
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
|
||||
id: _asStr(j["id"]),
|
||||
email: _asStr(j["email"]),
|
||||
name: _asStr(j["name"]),
|
||||
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
||||
// An empty role is treated as "user", matching the server.
|
||||
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
|
||||
);
|
||||
|
||||
bool get isAdmin => role == "admin";
|
||||
bool get isAdmin => _managerRoles.contains(role);
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
|
||||
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
|
||||
}
|
||||
@@ -202,6 +208,7 @@ class AdminUser {
|
||||
final String name;
|
||||
final String role;
|
||||
final String created;
|
||||
final String organizationName; // "" when the user belongs to no organization
|
||||
|
||||
AdminUser({
|
||||
required this.id,
|
||||
@@ -209,15 +216,19 @@ class AdminUser {
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.created,
|
||||
this.organizationName = "",
|
||||
});
|
||||
|
||||
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
|
||||
id: _asStr(j["id"]),
|
||||
email: _asStr(j["email"]),
|
||||
name: _asStr(j["name"]),
|
||||
role: _asStr(j["role"]),
|
||||
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
|
||||
created: _asStr(j["created"]),
|
||||
organizationName: _asStr(j["organizationName"]),
|
||||
);
|
||||
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
}
|
||||
|
||||
/// The full authenticated profile (Settings panel), mirroring /api/me.
|
||||
@@ -267,34 +278,10 @@ class UserProfile {
|
||||
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
|
||||
);
|
||||
|
||||
bool get isAdmin => role == "admin";
|
||||
bool get isAdmin => _managerRoles.contains(role);
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
bool get deletionPending => deletionRequestedAt != null;
|
||||
}
|
||||
|
||||
/// One active login session (device), mirroring /api/sessions.
|
||||
class Session {
|
||||
final String id;
|
||||
final String deviceLabel;
|
||||
final String ip;
|
||||
final bool current;
|
||||
final DateTime? created;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
Session({
|
||||
required this.id,
|
||||
required this.deviceLabel,
|
||||
required this.ip,
|
||||
required this.current,
|
||||
required this.created,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> j) => Session(
|
||||
id: _asStr(j["id"]),
|
||||
deviceLabel: _asStr(j["deviceLabel"]),
|
||||
ip: _asStr(j["ip"]),
|
||||
current: _asBool(j["current"]),
|
||||
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
|
||||
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
|
||||
);
|
||||
}
|
||||
// The Session model is gone: auth now uses PocketBase's own stateless tokens,
|
||||
// so there is no per-device session list to show or revoke.
|
||||
@@ -6,8 +6,10 @@ import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Admin-only screen to manage user accounts: list, create, change role,
|
||||
/// reset password, delete. The API enforces the real access control and the
|
||||
/// last-admin / self-delete guards; the UI mirrors them to avoid dead actions.
|
||||
/// reset password, delete. The API enforces the real access control — an admin
|
||||
/// is scoped to their own organization and cannot touch a superadmin, and
|
||||
/// nobody may change their own role or delete their own account. The UI mirrors
|
||||
/// those guards to avoid offering dead actions.
|
||||
class AdminUsersScreen extends StatefulWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
@override
|
||||
@@ -26,6 +28,11 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
void _reload() => setState(() => _future = apiClient.listUsers());
|
||||
|
||||
String? get _myId => authService.user?.id;
|
||||
bool get _iamSuperadmin => authService.user?.isSuperadmin == true;
|
||||
|
||||
/// Roles this viewer may hand out. Only a superadmin can mint another one.
|
||||
List<String> get _assignableRoles =>
|
||||
_iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"];
|
||||
|
||||
void _snack(String msg) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
@@ -121,7 +128,6 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
return Center(child: Text("${snap.error}"));
|
||||
}
|
||||
final users = snap.data ?? [];
|
||||
final adminCount = users.where((u) => u.role == "admin").length;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: users.length,
|
||||
@@ -129,7 +135,8 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
itemBuilder: (context, i) {
|
||||
final u = users[i];
|
||||
final isSelf = u.id == _myId;
|
||||
final lastAdmin = u.role == "admin" && adminCount <= 1;
|
||||
// An admin may not edit or delete a superadmin; only a superadmin may.
|
||||
final locked = u.isSuperadmin && !_iamSuperadmin;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Row(
|
||||
@@ -143,20 +150,28 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
"${u.name.isEmpty ? '—' : u.name} · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
"${u.name.isEmpty ? '—' : u.name}"
|
||||
"${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}"
|
||||
" · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButton<String>(
|
||||
value: u.role == "admin" ? "admin" : "user",
|
||||
value: u.role,
|
||||
underline: const SizedBox.shrink(),
|
||||
// Disable demoting the last admin.
|
||||
onChanged: lastAdmin ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
// Nobody may change their own role; only a superadmin may
|
||||
// touch a superadmin.
|
||||
onChanged:
|
||||
(isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: [
|
||||
for (final r in _assignableRoles)
|
||||
DropdownMenuItem(value: r, child: Text(r)),
|
||||
// Keep the current role selectable even when this viewer
|
||||
// can't assign it, so the dropdown has a valid value.
|
||||
if (!_assignableRoles.contains(u.role))
|
||||
DropdownMenuItem(value: u.role, child: Text(u.role)),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
@@ -165,11 +180,15 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
if (choice == "delete") _delete(u);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: "password", child: Text("Reset password")),
|
||||
PopupMenuItem(
|
||||
value: "password",
|
||||
enabled: !locked,
|
||||
child: const Text("Reset password"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
// Can't delete yourself or the last admin.
|
||||
enabled: !isSelf && !lastAdmin,
|
||||
// Can't delete yourself, or a superadmin you don't outrank.
|
||||
enabled: !isSelf && !locked,
|
||||
child: const Text("Delete", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
@@ -199,6 +218,12 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
/// Only a superadmin can create another one. An admin's new users are placed
|
||||
/// in the admin's own organization by the server.
|
||||
List<String> get _assignableRoles => authService.user?.isSuperadmin == true
|
||||
? const ["user", "admin", "superadmin"]
|
||||
: const ["user", "admin"];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
@@ -274,9 +299,8 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
DropdownButton<String>(
|
||||
value: _role,
|
||||
onChanged: (v) => setState(() => _role = v ?? "user"),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
items: [
|
||||
for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -20,7 +20,6 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
UserProfile? _profile;
|
||||
List<Session> _sessions = [];
|
||||
Uint8List? _avatar;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
@@ -49,7 +48,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
try {
|
||||
final p = await apiClient.getMe();
|
||||
appSettings.applyFromProfile(p);
|
||||
final sessions = await apiClient.listSessions();
|
||||
Uint8List? avatar;
|
||||
if (p.hasAvatar) {
|
||||
final bytes = await apiClient.getAvatarBytes();
|
||||
@@ -59,7 +57,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_bio.text = p.bio;
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_sessions = sessions;
|
||||
_avatar = avatar;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -102,7 +99,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 12),
|
||||
_SecuritySection(email: _profile!.email, snack: _snack),
|
||||
const SizedBox(height: 12),
|
||||
_SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack),
|
||||
const _PrivacySection(),
|
||||
const SizedBox(height: 12),
|
||||
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
|
||||
const SizedBox(height: 24),
|
||||
@@ -669,39 +666,12 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Privacy & security (sessions) -----------------------------------------
|
||||
// --- Privacy & security -----------------------------------------------------
|
||||
|
||||
class _SessionsSection extends StatefulWidget {
|
||||
final List<Session> sessions;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _SessionsSection({required this.sessions, required this.onChanged, required this.snack});
|
||||
@override
|
||||
State<_SessionsSection> createState() => _SessionsSectionState();
|
||||
}
|
||||
|
||||
class _SessionsSectionState extends State<_SessionsSection> {
|
||||
Future<void> _revoke(Session s) async {
|
||||
try {
|
||||
await apiClient.revokeSession(s.id);
|
||||
if (s.current) {
|
||||
await authService.logout();
|
||||
return;
|
||||
}
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revokeOthers() async {
|
||||
try {
|
||||
await apiClient.revokeOtherSessions();
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
|
||||
/// list to show or revoke — this card explains what "sign out" actually does.
|
||||
class _PrivacySection extends StatelessWidget {
|
||||
const _PrivacySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -709,53 +679,19 @@ class _SessionsSectionState extends State<_SessionsSection> {
|
||||
title: "Privacy & security",
|
||||
children: [
|
||||
const Text(
|
||||
"Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.",
|
||||
"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.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
if (widget.sessions.length > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _revokeOthers,
|
||||
child: const Text("Log out all other devices"),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => authService.logout(),
|
||||
child: const Text("Sign out", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
...widget.sessions.map((s) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(s.deviceLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
if (s.current)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brandTint(context),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text("This device",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))),
|
||||
),
|
||||
]),
|
||||
Text("${s.ip} · signed in ${formatDate(s.created)}",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _revoke(s),
|
||||
child: const Text("Log out", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user