diff --git a/README.md b/README.md index e907cfc..aad9f24 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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 ```bash diff --git a/lib/controllers/session_controller.dart b/lib/controllers/session_controller.dart index 416efef..c100dc8 100644 --- a/lib/controllers/session_controller.dart +++ b/lib/controllers/session_controller.dart @@ -11,6 +11,14 @@ class SessionController extends ChangeNotifier { final TokenStore tokens; SessionStatus status = SessionStatus.loading; 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 initialize() async { final minimumSplash = Future.delayed( @@ -22,6 +30,10 @@ class SessionController extends ChangeNotifier { } else { try { user = await api.me(); + await _selectInitialOrganization(); + if (organizationId == null) { + throw StateError('No approved organization membership'); + } status = SessionStatus.signedIn; } catch (_) { await tokens.clear(); @@ -36,21 +48,46 @@ class SessionController extends ChangeNotifier { final result = await api.login(username, password); await tokens.write(result.token); user = result.user; + await _selectInitialOrganization(); status = SessionStatus.signedIn; notifyListeners(); } - Future register(Map input) async { - final result = await api.register(input); - await tokens.write(result.token); - user = result.user; - status = SessionStatus.signedIn; + Future register(Map input) => api.register(input); + + Future _selectInitialOrganization() async { + final approved = user?.approvedMemberships ?? const []; + 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 selectOrganization(String id) async { + if (!(user?.approvedMemberships.any((item) => item.organization.id == id) ?? + false)) { + return; + } + organizationId = id; + await tokens.writeOrganization(id); + notifyListeners(); + } + + Future refreshProfile() async { + user = await api.me(); + await _selectInitialOrganization(); notifyListeners(); } Future logout() async { await tokens.clear(); + await tokens.clearOrganization(); user = null; + organizationId = null; status = SessionStatus.signedOut; notifyListeners(); } diff --git a/lib/controllers/upload_controller.dart b/lib/controllers/upload_controller.dart index 87d46e1..b043c28 100644 --- a/lib/controllers/upload_controller.dart +++ b/lib/controllers/upload_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -17,6 +18,7 @@ class UploadRecord { this.status = TaskStatus.enqueued, this.sizeBytes = -1, this.errorMessage, + this.organizationId, }); final String taskId; @@ -27,6 +29,7 @@ class UploadRecord { final TaskStatus status; final int sizeBytes; final String? errorMessage; + final String? organizationId; bool get isFinished => status.isFinalState; bool get canRetry => status == TaskStatus.failed; @@ -45,6 +48,7 @@ class UploadRecord { status: status ?? this.status, sizeBytes: sizeBytes ?? this.sizeBytes, errorMessage: errorMessage ?? this.errorMessage, + organizationId: organizationId, ); } @@ -64,15 +68,28 @@ class UploadController extends ChangeNotifier { final Map _tasks = {}; bool _notificationPermissionChecked = false; bool _disposed = false; + String? _organizationId; List get uploads { - final result = _uploads.values.toList() - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final result = + _uploads.values + .where( + (item) => + item.organizationId == null || + item.organizationId == _organizationId, + ) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return result; } int completedVersion = 0; + void selectOrganization(String? organizationId) { + if (_organizationId == organizationId) return; + _organizationId = organizationId; + } + Future _restoreHistory() async { // start() initializes tracking asynchronously during application startup. // A short retry also covers slower devices on their first launch. @@ -102,6 +119,8 @@ class UploadController extends ChangeNotifier { await _ensureNotificationPermission(); final token = await _tokens.read(); 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( url: '${AppConfig.apiBaseUrl}/files/upload', filename: filename, @@ -109,12 +128,18 @@ class UploadController extends ChangeNotifier { baseDirectory: BaseDirectory.applicationSupport, fileField: 'file', fields: folderId == null ? const {} : {'folderId': folderId}, - headers: {'Authorization': 'Bearer $token'}, + headers: { + 'Authorization': 'Bearer $token', + 'X-Organization-Id': organizationId, + }, group: 'uploads', updates: Updates.statusAndProgress, retries: 2, displayName: displayName, - metaData: displayName, + metaData: jsonEncode({ + 'name': displayName, + 'organizationId': organizationId, + }), ); _tasks[task.taskId] = task; _uploads[task.taskId] = UploadRecord( @@ -122,6 +147,7 @@ class UploadController extends ChangeNotifier { name: displayName, filename: filename, createdAt: task.creationTime, + organizationId: organizationId, ); notifyListeners(); final accepted = await FileDownloader().enqueue(task); @@ -180,23 +206,40 @@ class UploadController extends ChangeNotifier { _tasks[record.taskId] = record.task; _uploads[record.taskId] = UploadRecord( taskId: record.taskId, - name: record.task.displayName, + name: _taskName(record.task), filename: record.task.filename, createdAt: record.task.creationTime, progress: record.progress.clamp(0, 1), status: record.status, sizeBytes: record.expectedFileSize, errorMessage: record.exception?.description, + organizationId: _taskOrganization(record.task), ); } UploadRecord _fromTask(Task task) => UploadRecord( taskId: task.taskId, - name: task.displayName, + name: _taskName(task), filename: task.filename, createdAt: task.creationTime, + organizationId: _taskOrganization(task), ); + Map? _taskMetadata(Task task) { + try { + final value = jsonDecode(task.metaData); + return value is Map ? 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 _deleteStagedFile(Task task) async { final file = File(await task.filePath()); if (await file.exists()) await file.delete(); @@ -218,9 +261,14 @@ class UploadController extends ChangeNotifier { } final token = await _tokens.read(); 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( taskId: DateTime.now().microsecondsSinceEpoch.toString(), - headers: {'Authorization': 'Bearer $token'}, + headers: { + 'Authorization': 'Bearer $token', + 'X-Organization-Id': organizationId, + }, creationTime: DateTime.now(), retriesRemaining: original.retries, ); diff --git a/lib/main.dart b/lib/main.dart index 5161b1d..72814b1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,7 +73,9 @@ class WhatsDriveApp extends StatelessWidget { builder: (context, session, _) => switch (session.status) { SessionStatus.loading => const SplashScreen(), SessionStatus.signedOut => const LoginScreen(), - SessionStatus.signedIn => const DriveScreen(), + SessionStatus.signedIn => DriveScreen( + key: ValueKey(session.organizationId), + ), }, ), ), diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index fc10eb1..9e357b7 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -7,6 +7,7 @@ class UserProfile { required this.email, required this.gender, required this.age, + this.memberships = const [], }); final String id; @@ -16,6 +17,14 @@ class UserProfile { final String email; final String gender; final int age; + final List memberships; + + List get approvedMemberships => memberships + .where( + (item) => + item.status == 'APPROVED' && item.organization.status == 'ACTIVE', + ) + .toList(); factory UserProfile.fromJson(Map json) => UserProfile( id: json['id'] as String, @@ -25,5 +34,53 @@ class UserProfile { email: json['email'] as String, gender: json['gender'] as String, 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 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 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']), + ); +} diff --git a/lib/screens/drive_screen.dart b/lib/screens/drive_screen.dart index 7cde612..8ebe68a 100644 --- a/lib/screens/drive_screen.dart +++ b/lib/screens/drive_screen.dart @@ -44,6 +44,9 @@ class _DriveScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + context.read().selectOrganization( + context.read().organizationId, + ); final controller = context.read(); if (_uploadController == controller) return; _uploadController?.removeListener(_onUploadChanged); @@ -250,7 +253,9 @@ class _DriveScreenState extends State { @override Widget build(BuildContext context) { - final user = context.watch().user!; + final session = context.watch(); + final user = session.user!; + final membership = session.membership; return PopScope( canPop: _path.isEmpty, onPopInvokedWithResult: (didPop, result) { @@ -265,11 +270,13 @@ class _DriveScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _path.isEmpty ? 'Metatron-Drive' : _path.last.name, + _path.isEmpty + ? membership?.organization.name ?? 'Metatron-Drive' + : _path.last.name, style: const TextStyle(fontWeight: FontWeight.w800), ), Text( - 'Hello, ${user.name.split(' ').first}', + 'Hello, ${user.name.split(' ').first} • ${membership?.organization.code ?? ''}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, @@ -279,6 +286,28 @@ class _DriveScreenState extends State { ], ), actions: [ + PopupMenuButton( + 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( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const UploadHistoryScreen()), @@ -412,6 +441,7 @@ class _DriveScreenState extends State { file: _files[index], token: _token, api: context.read(), + organizationId: session.organizationId, onDelete: () => _deleteFile(_files[index]), ), ), @@ -457,6 +487,15 @@ class _DriveScreenState extends State { style: const TextStyle(color: Colors.black54), ), 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( onPressed: () { Navigator.pop(sheetContext); @@ -481,6 +520,56 @@ class _DriveScreenState extends State { ); } + Future _joinOrganization() async { + final code = TextEditingController(); + final submitted = await showDialog( + 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().joinOrganization( + code.text.trim().toUpperCase(), + ); + if (mounted) { + await context.read().refreshProfile(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.read().messageFrom(error))), + ); + } + } finally { + code.dispose(); + } + } + Future _confirmAccountDeletion() async { final password = TextEditingController(); final confirmed = await showDialog( @@ -668,11 +757,13 @@ class _FileCard extends StatelessWidget { required this.file, required this.token, required this.api, + required this.organizationId, required this.onDelete, }); final DriveFile file; final String? token; final ApiClient api; + final String? organizationId; final VoidCallback onDelete; @override Widget build(BuildContext context) => Card( @@ -686,7 +777,10 @@ class _FileCard extends StatelessWidget { child: file.isImage && token != null ? Image.network( api.contentUrl(file.id), - headers: {'Authorization': 'Bearer $token'}, + headers: { + 'Authorization': 'Bearer $token', + ..._organizationHeader(organizationId), + }, fit: BoxFit.cover, errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, size: 42), @@ -741,6 +835,9 @@ class _FileCard extends StatelessWidget { : '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; } +Map _organizationHeader(String? organizationId) => + organizationId == null ? const {} : {'X-Organization-Id': organizationId}; + class _UploadStrip extends StatelessWidget { const _UploadStrip(); @override diff --git a/lib/screens/register_screen.dart b/lib/screens/register_screen.dart index 39f5af6..56bd91c 100644 --- a/lib/screens/register_screen.dart +++ b/lib/screens/register_screen.dart @@ -12,6 +12,7 @@ class RegisterScreen extends StatefulWidget { class _RegisterScreenState extends State { final _form = GlobalKey(); final _name = TextEditingController(); + final _organizationCode = TextEditingController(); final _username = TextEditingController(); final _email = TextEditingController(); final _phone = TextEditingController(); @@ -20,7 +21,14 @@ class _RegisterScreenState extends State { @override void dispose() { - for (final value in [_name, _username, _email, _phone, _password]) { + for (final value in [ + _organizationCode, + _name, + _username, + _email, + _phone, + _password, + ]) { value.dispose(); } super.dispose(); @@ -30,7 +38,8 @@ class _RegisterScreenState extends State { if (!_form.currentState!.validate()) return; setState(() => _busy = true); try { - await context.read().register({ + final message = await context.read().register({ + 'organizationCode': _organizationCode.text.trim().toUpperCase(), 'name': _name.text.trim(), 'username': _username.text.trim().toLowerCase(), 'email': _email.text.trim().toLowerCase(), @@ -41,7 +50,11 @@ class _RegisterScreenState extends State { 'gender': 'Prefer not to say', '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) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -83,6 +96,18 @@ class _RegisterScreenState extends State { 'We create your personal Google Drive folder automatically.', ), 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( controller: _name, textCapitalization: TextCapitalization.words, diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 9daa9a7..f20624a 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -17,7 +17,13 @@ class ApiClient { InterceptorsWrapper( onRequest: (options, handler) async { 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); }, ), @@ -53,14 +59,9 @@ class ApiClient { ); } - Future<({String token, UserProfile user})> register( - Map input, - ) async { + Future register(Map input) async { final response = await dio.post('/auth/register', data: input); - return ( - token: response.data['token'] as String, - user: UserProfile.fromJson(response.data['user']), - ); + return response.data['message'] as String; } Future me() async { @@ -71,6 +72,21 @@ class ApiClient { Future deleteAccount(String password) => dio.delete('/retention/account', data: {'password': password}); + Future lookupOrganization(String code) async { + final response = await dio.get( + '/organizations/lookup/${Uri.encodeComponent(code)}', + ); + return OrganizationSummary.fromJson(response.data['organization']); + } + + Future joinOrganization(String code) async { + final response = await dio.post( + '/auth/join', + data: {'organizationCode': code}, + ); + return response.data['message'] as String; + } + Future> folders(String? parentId) async { final response = await dio.get( '/folders', diff --git a/lib/services/token_store.dart b/lib/services/token_store.dart index 577b0bc..9f9b5c1 100644 --- a/lib/services/token_store.dart +++ b/lib/services/token_store.dart @@ -2,10 +2,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class TokenStore { static const _tokenKey = 'auth_token'; + static const _organizationKey = 'organization_id'; const TokenStore(); FlutterSecureStorage get _storage => const FlutterSecureStorage(); Future read() => _storage.read(key: _tokenKey); Future write(String token) => _storage.write(key: _tokenKey, value: token); Future clear() => _storage.delete(key: _tokenKey); + Future readOrganization() => _storage.read(key: _organizationKey); + Future writeOrganization(String id) => + _storage.write(key: _organizationKey, value: id); + Future clearOrganization() => _storage.delete(key: _organizationKey); }