feat: implement organization membership management and registration flow
This commit is contained in:
parent
62e44a6297
commit
7aa1841b01
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user