feat: implement organization membership management and registration flow

This commit is contained in:
MOHAN 2026-07-06 15:37:24 +05:30
parent 62e44a6297
commit 7aa1841b01
9 changed files with 317 additions and 28 deletions

View File

@ -2,6 +2,8 @@
Flutter mobile frontend for the private Metatron-Drive file workspace. Flutter mobile frontend for the private Metatron-Drive file workspace.
The app uses one global account with multiple organization memberships. New accounts apply with an organization code; organization administrators must approve access before login.
## Run ## Run
```bash ```bash

View File

@ -11,6 +11,14 @@ class SessionController extends ChangeNotifier {
final TokenStore tokens; final TokenStore tokens;
SessionStatus status = SessionStatus.loading; SessionStatus status = SessionStatus.loading;
UserProfile? user; UserProfile? user;
String? organizationId;
OrganizationMembership? get membership {
final matches = user?.approvedMemberships.where(
(item) => item.organization.id == organizationId,
);
return matches == null || matches.isEmpty ? null : matches.first;
}
Future<void> initialize() async { Future<void> initialize() async {
final minimumSplash = Future<void>.delayed( final minimumSplash = Future<void>.delayed(
@ -22,6 +30,10 @@ class SessionController extends ChangeNotifier {
} else { } else {
try { try {
user = await api.me(); user = await api.me();
await _selectInitialOrganization();
if (organizationId == null) {
throw StateError('No approved organization membership');
}
status = SessionStatus.signedIn; status = SessionStatus.signedIn;
} catch (_) { } catch (_) {
await tokens.clear(); await tokens.clear();
@ -36,21 +48,46 @@ class SessionController extends ChangeNotifier {
final result = await api.login(username, password); final result = await api.login(username, password);
await tokens.write(result.token); await tokens.write(result.token);
user = result.user; user = result.user;
await _selectInitialOrganization();
status = SessionStatus.signedIn; status = SessionStatus.signedIn;
notifyListeners(); notifyListeners();
} }
Future<void> register(Map<String, dynamic> input) async { Future<String> register(Map<String, dynamic> input) => api.register(input);
final result = await api.register(input);
await tokens.write(result.token); Future<void> _selectInitialOrganization() async {
user = result.user; final approved = user?.approvedMemberships ?? const [];
status = SessionStatus.signedIn; final saved = await tokens.readOrganization();
final selected = approved.where((item) => item.organization.id == saved);
organizationId = selected.isNotEmpty
? selected.first.organization.id
: (approved.isEmpty ? null : approved.first.organization.id);
if (organizationId != null) {
await tokens.writeOrganization(organizationId!);
}
}
Future<void> selectOrganization(String id) async {
if (!(user?.approvedMemberships.any((item) => item.organization.id == id) ??
false)) {
return;
}
organizationId = id;
await tokens.writeOrganization(id);
notifyListeners();
}
Future<void> refreshProfile() async {
user = await api.me();
await _selectInitialOrganization();
notifyListeners(); notifyListeners();
} }
Future<void> logout() async { Future<void> logout() async {
await tokens.clear(); await tokens.clear();
await tokens.clearOrganization();
user = null; user = null;
organizationId = null;
status = SessionStatus.signedOut; status = SessionStatus.signedOut;
notifyListeners(); notifyListeners();
} }

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
@ -17,6 +18,7 @@ class UploadRecord {
this.status = TaskStatus.enqueued, this.status = TaskStatus.enqueued,
this.sizeBytes = -1, this.sizeBytes = -1,
this.errorMessage, this.errorMessage,
this.organizationId,
}); });
final String taskId; final String taskId;
@ -27,6 +29,7 @@ class UploadRecord {
final TaskStatus status; final TaskStatus status;
final int sizeBytes; final int sizeBytes;
final String? errorMessage; final String? errorMessage;
final String? organizationId;
bool get isFinished => status.isFinalState; bool get isFinished => status.isFinalState;
bool get canRetry => status == TaskStatus.failed; bool get canRetry => status == TaskStatus.failed;
@ -45,6 +48,7 @@ class UploadRecord {
status: status ?? this.status, status: status ?? this.status,
sizeBytes: sizeBytes ?? this.sizeBytes, sizeBytes: sizeBytes ?? this.sizeBytes,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
organizationId: organizationId,
); );
} }
@ -64,15 +68,28 @@ class UploadController extends ChangeNotifier {
final Map<String, Task> _tasks = {}; final Map<String, Task> _tasks = {};
bool _notificationPermissionChecked = false; bool _notificationPermissionChecked = false;
bool _disposed = false; bool _disposed = false;
String? _organizationId;
List<UploadRecord> get uploads { List<UploadRecord> get uploads {
final result = _uploads.values.toList() final result =
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); _uploads.values
.where(
(item) =>
item.organizationId == null ||
item.organizationId == _organizationId,
)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return result; return result;
} }
int completedVersion = 0; int completedVersion = 0;
void selectOrganization(String? organizationId) {
if (_organizationId == organizationId) return;
_organizationId = organizationId;
}
Future<void> _restoreHistory() async { Future<void> _restoreHistory() async {
// start() initializes tracking asynchronously during application startup. // start() initializes tracking asynchronously during application startup.
// A short retry also covers slower devices on their first launch. // A short retry also covers slower devices on their first launch.
@ -102,6 +119,8 @@ class UploadController extends ChangeNotifier {
await _ensureNotificationPermission(); await _ensureNotificationPermission();
final token = await _tokens.read(); final token = await _tokens.read();
if (token == null) throw StateError('Your session has expired'); if (token == null) throw StateError('Your session has expired');
final organizationId = await _tokens.readOrganization();
if (organizationId == null) throw StateError('Select an organization');
final task = UploadTask( final task = UploadTask(
url: '${AppConfig.apiBaseUrl}/files/upload', url: '${AppConfig.apiBaseUrl}/files/upload',
filename: filename, filename: filename,
@ -109,12 +128,18 @@ class UploadController extends ChangeNotifier {
baseDirectory: BaseDirectory.applicationSupport, baseDirectory: BaseDirectory.applicationSupport,
fileField: 'file', fileField: 'file',
fields: folderId == null ? const {} : {'folderId': folderId}, fields: folderId == null ? const {} : {'folderId': folderId},
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
'X-Organization-Id': organizationId,
},
group: 'uploads', group: 'uploads',
updates: Updates.statusAndProgress, updates: Updates.statusAndProgress,
retries: 2, retries: 2,
displayName: displayName, displayName: displayName,
metaData: displayName, metaData: jsonEncode({
'name': displayName,
'organizationId': organizationId,
}),
); );
_tasks[task.taskId] = task; _tasks[task.taskId] = task;
_uploads[task.taskId] = UploadRecord( _uploads[task.taskId] = UploadRecord(
@ -122,6 +147,7 @@ class UploadController extends ChangeNotifier {
name: displayName, name: displayName,
filename: filename, filename: filename,
createdAt: task.creationTime, createdAt: task.creationTime,
organizationId: organizationId,
); );
notifyListeners(); notifyListeners();
final accepted = await FileDownloader().enqueue(task); final accepted = await FileDownloader().enqueue(task);
@ -180,23 +206,40 @@ class UploadController extends ChangeNotifier {
_tasks[record.taskId] = record.task; _tasks[record.taskId] = record.task;
_uploads[record.taskId] = UploadRecord( _uploads[record.taskId] = UploadRecord(
taskId: record.taskId, taskId: record.taskId,
name: record.task.displayName, name: _taskName(record.task),
filename: record.task.filename, filename: record.task.filename,
createdAt: record.task.creationTime, createdAt: record.task.creationTime,
progress: record.progress.clamp(0, 1), progress: record.progress.clamp(0, 1),
status: record.status, status: record.status,
sizeBytes: record.expectedFileSize, sizeBytes: record.expectedFileSize,
errorMessage: record.exception?.description, errorMessage: record.exception?.description,
organizationId: _taskOrganization(record.task),
); );
} }
UploadRecord _fromTask(Task task) => UploadRecord( UploadRecord _fromTask(Task task) => UploadRecord(
taskId: task.taskId, taskId: task.taskId,
name: task.displayName, name: _taskName(task),
filename: task.filename, filename: task.filename,
createdAt: task.creationTime, createdAt: task.creationTime,
organizationId: _taskOrganization(task),
); );
Map<String, dynamic>? _taskMetadata(Task task) {
try {
final value = jsonDecode(task.metaData);
return value is Map<String, dynamic> ? value : null;
} catch (_) {
return null;
}
}
String _taskName(Task task) =>
_taskMetadata(task)?['name'] as String? ?? task.displayName;
String? _taskOrganization(Task task) =>
_taskMetadata(task)?['organizationId'] as String?;
Future<void> _deleteStagedFile(Task task) async { Future<void> _deleteStagedFile(Task task) async {
final file = File(await task.filePath()); final file = File(await task.filePath());
if (await file.exists()) await file.delete(); if (await file.exists()) await file.delete();
@ -218,9 +261,14 @@ class UploadController extends ChangeNotifier {
} }
final token = await _tokens.read(); final token = await _tokens.read();
if (token == null) throw StateError('Your session has expired'); if (token == null) throw StateError('Your session has expired');
final organizationId = await _tokens.readOrganization();
if (organizationId == null) throw StateError('Select an organization');
final retried = original.copyWith( final retried = original.copyWith(
taskId: DateTime.now().microsecondsSinceEpoch.toString(), taskId: DateTime.now().microsecondsSinceEpoch.toString(),
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
'X-Organization-Id': organizationId,
},
creationTime: DateTime.now(), creationTime: DateTime.now(),
retriesRemaining: original.retries, retriesRemaining: original.retries,
); );

View File

@ -73,7 +73,9 @@ class WhatsDriveApp extends StatelessWidget {
builder: (context, session, _) => switch (session.status) { builder: (context, session, _) => switch (session.status) {
SessionStatus.loading => const SplashScreen(), SessionStatus.loading => const SplashScreen(),
SessionStatus.signedOut => const LoginScreen(), SessionStatus.signedOut => const LoginScreen(),
SessionStatus.signedIn => const DriveScreen(), SessionStatus.signedIn => DriveScreen(
key: ValueKey(session.organizationId),
),
}, },
), ),
), ),

View File

@ -7,6 +7,7 @@ class UserProfile {
required this.email, required this.email,
required this.gender, required this.gender,
required this.age, required this.age,
this.memberships = const [],
}); });
final String id; final String id;
@ -16,6 +17,14 @@ class UserProfile {
final String email; final String email;
final String gender; final String gender;
final int age; final int age;
final List<OrganizationMembership> memberships;
List<OrganizationMembership> get approvedMemberships => memberships
.where(
(item) =>
item.status == 'APPROVED' && item.organization.status == 'ACTIVE',
)
.toList();
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile( factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
id: json['id'] as String, id: json['id'] as String,
@ -25,5 +34,53 @@ class UserProfile {
email: json['email'] as String, email: json['email'] as String,
gender: json['gender'] as String, gender: json['gender'] as String,
age: json['age'] as int, age: json['age'] as int,
memberships: (json['memberships'] as List? ?? const [])
.map((item) => OrganizationMembership.fromJson(item))
.toList(),
); );
} }
class OrganizationSummary {
const OrganizationSummary({
required this.id,
required this.code,
required this.name,
required this.status,
});
final String id;
final String code;
final String name;
final String status;
factory OrganizationSummary.fromJson(Map<String, dynamic> json) =>
OrganizationSummary(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
status: json['status'] as String,
);
}
class OrganizationMembership {
const OrganizationMembership({
required this.id,
required this.role,
required this.status,
required this.organization,
this.rejectionReason,
});
final String id;
final String role;
final String status;
final String? rejectionReason;
final OrganizationSummary organization;
factory OrganizationMembership.fromJson(Map<String, dynamic> json) =>
OrganizationMembership(
id: json['id'] as String,
role: json['role'] as String,
status: json['status'] as String,
rejectionReason: json['rejectionReason'] as String?,
organization: OrganizationSummary.fromJson(json['organization']),
);
}

View File

@ -44,6 +44,9 @@ class _DriveScreenState extends State<DriveScreen> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
context.read<UploadController>().selectOrganization(
context.read<SessionController>().organizationId,
);
final controller = context.read<UploadController>(); final controller = context.read<UploadController>();
if (_uploadController == controller) return; if (_uploadController == controller) return;
_uploadController?.removeListener(_onUploadChanged); _uploadController?.removeListener(_onUploadChanged);
@ -250,7 +253,9 @@ class _DriveScreenState extends State<DriveScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = context.watch<SessionController>().user!; final session = context.watch<SessionController>();
final user = session.user!;
final membership = session.membership;
return PopScope( return PopScope(
canPop: _path.isEmpty, canPop: _path.isEmpty,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@ -265,11 +270,13 @@ class _DriveScreenState extends State<DriveScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_path.isEmpty ? 'Metatron-Drive' : _path.last.name, _path.isEmpty
? membership?.organization.name ?? 'Metatron-Drive'
: _path.last.name,
style: const TextStyle(fontWeight: FontWeight.w800), style: const TextStyle(fontWeight: FontWeight.w800),
), ),
Text( Text(
'Hello, ${user.name.split(' ').first}', 'Hello, ${user.name.split(' ').first}${membership?.organization.code ?? ''}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@ -279,6 +286,28 @@ class _DriveScreenState extends State<DriveScreen> {
], ],
), ),
actions: [ actions: [
PopupMenuButton<String>(
tooltip: 'Switch organization',
icon: const Icon(Icons.business_outlined),
onSelected: session.selectOrganization,
itemBuilder: (_) => user.approvedMemberships
.map(
(item) => PopupMenuItem(
value: item.organization.id,
child: Row(
children: [
if (item.organization.id == session.organizationId)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.check, size: 18),
),
Text(item.organization.name),
],
),
),
)
.toList(),
),
IconButton( IconButton(
onPressed: () => Navigator.of(context).push( onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()), MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
@ -412,6 +441,7 @@ class _DriveScreenState extends State<DriveScreen> {
file: _files[index], file: _files[index],
token: _token, token: _token,
api: context.read<ApiClient>(), api: context.read<ApiClient>(),
organizationId: session.organizationId,
onDelete: () => _deleteFile(_files[index]), onDelete: () => _deleteFile(_files[index]),
), ),
), ),
@ -457,6 +487,15 @@ class _DriveScreenState extends State<DriveScreen> {
style: const TextStyle(color: Colors.black54), style: const TextStyle(color: Colors.black54),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
_joinOrganization();
},
icon: const Icon(Icons.group_add_outlined),
label: const Text('Join another organization'),
),
const SizedBox(height: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@ -481,6 +520,56 @@ class _DriveScreenState extends State<DriveScreen> {
); );
} }
Future<void> _joinOrganization() async {
final code = TextEditingController();
final submitted = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Join an organization'),
content: TextField(
controller: code,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Organization code'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Submit request'),
),
],
),
);
if (submitted != true || !mounted) {
code.dispose();
return;
}
try {
final message = await context.read<ApiClient>().joinOrganization(
code.text.trim().toUpperCase(),
);
if (mounted) {
await context.read<SessionController>().refreshProfile();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.read<ApiClient>().messageFrom(error))),
);
}
} finally {
code.dispose();
}
}
Future<void> _confirmAccountDeletion() async { Future<void> _confirmAccountDeletion() async {
final password = TextEditingController(); final password = TextEditingController();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -668,11 +757,13 @@ class _FileCard extends StatelessWidget {
required this.file, required this.file,
required this.token, required this.token,
required this.api, required this.api,
required this.organizationId,
required this.onDelete, required this.onDelete,
}); });
final DriveFile file; final DriveFile file;
final String? token; final String? token;
final ApiClient api; final ApiClient api;
final String? organizationId;
final VoidCallback onDelete; final VoidCallback onDelete;
@override @override
Widget build(BuildContext context) => Card( Widget build(BuildContext context) => Card(
@ -686,7 +777,10 @@ class _FileCard extends StatelessWidget {
child: file.isImage && token != null child: file.isImage && token != null
? Image.network( ? Image.network(
api.contentUrl(file.id), api.contentUrl(file.id),
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
..._organizationHeader(organizationId),
},
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => errorBuilder: (_, _, _) =>
const Icon(Icons.broken_image_outlined, size: 42), const Icon(Icons.broken_image_outlined, size: 42),
@ -741,6 +835,9 @@ class _FileCard extends StatelessWidget {
: '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; : '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
} }
Map<String, String> _organizationHeader(String? organizationId) =>
organizationId == null ? const {} : {'X-Organization-Id': organizationId};
class _UploadStrip extends StatelessWidget { class _UploadStrip extends StatelessWidget {
const _UploadStrip(); const _UploadStrip();
@override @override

View File

@ -12,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
final _form = GlobalKey<FormState>(); final _form = GlobalKey<FormState>();
final _name = TextEditingController(); final _name = TextEditingController();
final _organizationCode = TextEditingController();
final _username = TextEditingController(); final _username = TextEditingController();
final _email = TextEditingController(); final _email = TextEditingController();
final _phone = TextEditingController(); final _phone = TextEditingController();
@ -20,7 +21,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override @override
void dispose() { void dispose() {
for (final value in [_name, _username, _email, _phone, _password]) { for (final value in [
_organizationCode,
_name,
_username,
_email,
_phone,
_password,
]) {
value.dispose(); value.dispose();
} }
super.dispose(); super.dispose();
@ -30,7 +38,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
if (!_form.currentState!.validate()) return; if (!_form.currentState!.validate()) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await context.read<SessionController>().register({ final message = await context.read<SessionController>().register({
'organizationCode': _organizationCode.text.trim().toUpperCase(),
'name': _name.text.trim(), 'name': _name.text.trim(),
'username': _username.text.trim().toLowerCase(), 'username': _username.text.trim().toLowerCase(),
'email': _email.text.trim().toLowerCase(), 'email': _email.text.trim().toLowerCase(),
@ -41,7 +50,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
'gender': 'Prefer not to say', 'gender': 'Prefer not to say',
'password': _password.text, 'password': _password.text,
}); });
if (mounted) Navigator.of(context).popUntil((route) => route.isFirst); if (mounted) {
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).popUntil((route) => route.isFirst);
messenger.showSnackBar(SnackBar(content: Text(message)));
}
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -83,6 +96,18 @@ class _RegisterScreenState extends State<RegisterScreen> {
'We create your personal Google Drive folder automatically.', 'We create your personal Google Drive folder automatically.',
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField(
controller: _organizationCode,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
labelText: 'Organization code',
prefixIcon: Icon(Icons.business_outlined),
),
validator: (value) => (value?.trim().length ?? 0) < 4
? 'Enter your organization code'
: null,
),
const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _name, controller: _name,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,

View File

@ -17,7 +17,13 @@ class ApiClient {
InterceptorsWrapper( InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final token = await _tokens.read(); final token = await _tokens.read();
if (token != null) options.headers['Authorization'] = 'Bearer $token'; if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
final organizationId = await _tokens.readOrganization();
if (organizationId != null) {
options.headers['X-Organization-Id'] = organizationId;
}
handler.next(options); handler.next(options);
}, },
), ),
@ -53,14 +59,9 @@ class ApiClient {
); );
} }
Future<({String token, UserProfile user})> register( Future<String> register(Map<String, dynamic> input) async {
Map<String, dynamic> input,
) async {
final response = await dio.post('/auth/register', data: input); final response = await dio.post('/auth/register', data: input);
return ( return response.data['message'] as String;
token: response.data['token'] as String,
user: UserProfile.fromJson(response.data['user']),
);
} }
Future<UserProfile> me() async { Future<UserProfile> me() async {
@ -71,6 +72,21 @@ class ApiClient {
Future<void> deleteAccount(String password) => Future<void> deleteAccount(String password) =>
dio.delete('/retention/account', data: {'password': password}); dio.delete('/retention/account', data: {'password': password});
Future<OrganizationSummary> lookupOrganization(String code) async {
final response = await dio.get(
'/organizations/lookup/${Uri.encodeComponent(code)}',
);
return OrganizationSummary.fromJson(response.data['organization']);
}
Future<String> joinOrganization(String code) async {
final response = await dio.post(
'/auth/join',
data: {'organizationCode': code},
);
return response.data['message'] as String;
}
Future<List<DriveFolder>> folders(String? parentId) async { Future<List<DriveFolder>> folders(String? parentId) async {
final response = await dio.get( final response = await dio.get(
'/folders', '/folders',

View File

@ -2,10 +2,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore { class TokenStore {
static const _tokenKey = 'auth_token'; static const _tokenKey = 'auth_token';
static const _organizationKey = 'organization_id';
const TokenStore(); const TokenStore();
FlutterSecureStorage get _storage => const FlutterSecureStorage(); FlutterSecureStorage get _storage => const FlutterSecureStorage();
Future<String?> read() => _storage.read(key: _tokenKey); Future<String?> read() => _storage.read(key: _tokenKey);
Future<void> write(String token) => Future<void> write(String token) =>
_storage.write(key: _tokenKey, value: token); _storage.write(key: _tokenKey, value: token);
Future<void> clear() => _storage.delete(key: _tokenKey); Future<void> clear() => _storage.delete(key: _tokenKey);
Future<String?> readOrganization() => _storage.read(key: _organizationKey);
Future<void> writeOrganization(String id) =>
_storage.write(key: _organizationKey, value: id);
Future<void> clearOrganization() => _storage.delete(key: _organizationKey);
} }