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.
|
||||
|
||||
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
|
||||
|
||||
@ -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<void> initialize() async {
|
||||
final minimumSplash = Future<void>.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<void> register(Map<String, dynamic> input) async {
|
||||
final result = await api.register(input);
|
||||
await tokens.write(result.token);
|
||||
user = result.user;
|
||||
status = SessionStatus.signedIn;
|
||||
Future<String> register(Map<String, dynamic> input) => api.register(input);
|
||||
|
||||
Future<void> _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<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();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await tokens.clear();
|
||||
await tokens.clearOrganization();
|
||||
user = null;
|
||||
organizationId = null;
|
||||
status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -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<String, Task> _tasks = {};
|
||||
bool _notificationPermissionChecked = false;
|
||||
bool _disposed = false;
|
||||
String? _organizationId;
|
||||
|
||||
List<UploadRecord> 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<void> _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<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 {
|
||||
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,
|
||||
);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<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(
|
||||
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<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
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
context.read<UploadController>().selectOrganization(
|
||||
context.read<SessionController>().organizationId,
|
||||
);
|
||||
final controller = context.read<UploadController>();
|
||||
if (_uploadController == controller) return;
|
||||
_uploadController?.removeListener(_onUploadChanged);
|
||||
@ -250,7 +253,9 @@ class _DriveScreenState extends State<DriveScreen> {
|
||||
|
||||
@override
|
||||
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(
|
||||
canPop: _path.isEmpty,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
@ -265,11 +270,13 @@ class _DriveScreenState extends State<DriveScreen> {
|
||||
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<DriveScreen> {
|
||||
],
|
||||
),
|
||||
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(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UploadHistoryScreen()),
|
||||
@ -412,6 +441,7 @@ class _DriveScreenState extends State<DriveScreen> {
|
||||
file: _files[index],
|
||||
token: _token,
|
||||
api: context.read<ApiClient>(),
|
||||
organizationId: session.organizationId,
|
||||
onDelete: () => _deleteFile(_files[index]),
|
||||
),
|
||||
),
|
||||
@ -457,6 +487,15 @@ class _DriveScreenState extends State<DriveScreen> {
|
||||
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<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 {
|
||||
final password = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
@ -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<String, String> _organizationHeader(String? organizationId) =>
|
||||
organizationId == null ? const {} : {'X-Organization-Id': organizationId};
|
||||
|
||||
class _UploadStrip extends StatelessWidget {
|
||||
const _UploadStrip();
|
||||
@override
|
||||
|
||||
@ -12,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _form = GlobalKey<FormState>();
|
||||
final _name = TextEditingController();
|
||||
final _organizationCode = TextEditingController();
|
||||
final _username = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
@ -20,7 +21,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
|
||||
@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<RegisterScreen> {
|
||||
if (!_form.currentState!.validate()) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<SessionController>().register({
|
||||
final message = await context.read<SessionController>().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<RegisterScreen> {
|
||||
'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<RegisterScreen> {
|
||||
'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,
|
||||
|
||||
@ -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<String, dynamic> input,
|
||||
) async {
|
||||
Future<String> register(Map<String, dynamic> 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<UserProfile> me() async {
|
||||
@ -71,6 +72,21 @@ class ApiClient {
|
||||
Future<void> deleteAccount(String 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 {
|
||||
final response = await dio.get(
|
||||
'/folders',
|
||||
|
||||
@ -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<String?> read() => _storage.read(key: _tokenKey);
|
||||
Future<void> write(String token) =>
|
||||
_storage.write(key: _tokenKey, value: token);
|
||||
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