- Add owner/admin/member roles with proper permissions - Implement invite links and join requests system - Add organization settings dialog with member management - Create database migrations for invitations and invite links - Update backend API with org management endpoints - Fix compilation errors and audit logging - Update frontend models and API integration
457 lines
14 KiB
Dart
457 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../blocs/organization/organization_state.dart';
|
|
import '../blocs/permission/permission_state.dart';
|
|
import '../models/organization.dart';
|
|
import '../services/org_api.dart';
|
|
import '../theme/app_theme.dart';
|
|
import '../theme/modern_glass_button.dart';
|
|
|
|
class OrganizationSettingsDialog extends StatefulWidget {
|
|
final Organization organization;
|
|
final PermissionState permissionState;
|
|
final OrgApi orgApi;
|
|
|
|
const OrganizationSettingsDialog({
|
|
super.key,
|
|
required this.organization,
|
|
required this.permissionState,
|
|
required this.orgApi,
|
|
});
|
|
|
|
@override
|
|
State<OrganizationSettingsDialog> createState() => _OrganizationSettingsDialogState();
|
|
}
|
|
|
|
class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog> with TickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
List<Member> _members = [];
|
|
List<Invitation> _invitations = [];
|
|
List<JoinRequest> _joinRequests = [];
|
|
String? _inviteLink;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
_loadData();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
if (!mounted) return;
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final results = await Future.wait([
|
|
widget.orgApi.getMembers(widget.organization.id),
|
|
widget.orgApi.getInvitations(widget.organization.id),
|
|
widget.orgApi.getJoinRequests(widget.organization.id),
|
|
widget.orgApi.getInviteLink(widget.organization.id),
|
|
]);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_members = results[0] as List<Member>;
|
|
_invitations = results[1] as List<Invitation>;
|
|
_joinRequests = results[2] as List<JoinRequest>;
|
|
_inviteLink = results[3] as String?;
|
|
_isLoading = false;
|
|
_error = null;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _updateMemberRole(String userId, String newRole) async {
|
|
try {
|
|
await widget.orgApi.updateMemberRole(widget.organization.id, userId, newRole);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to update role: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _removeMember(String userId) async {
|
|
try {
|
|
|
|
await widget.orgApi.removeMember(widget.organization.id, userId);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to remove member: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _inviteUser(String username, String role) async {
|
|
try {
|
|
|
|
await widget.orgApi.createInvitation(widget.organization.id, username, role);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to send invitation: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _cancelInvitation(String invitationId) async {
|
|
try {
|
|
|
|
await widget.orgApi.cancelInvitation(widget.organization.id, invitationId);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to cancel invitation: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _acceptJoinRequest(String requestId, String role) async {
|
|
try {
|
|
|
|
await widget.orgApi.acceptJoinRequest(widget.organization.id, requestId, role);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to accept request: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _rejectJoinRequest(String requestId) async {
|
|
try {
|
|
|
|
await widget.orgApi.rejectJoinRequest(widget.organization.id, requestId);
|
|
await _loadData(); // Refresh
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to reject request: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _regenerateInviteLink() async {
|
|
try {
|
|
|
|
final newLink = await widget.orgApi.regenerateInviteLink(widget.organization.id);
|
|
setState(() => _inviteLink = newLink);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to regenerate link: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _copyInviteLink() {
|
|
if (_inviteLink != null) {
|
|
Clipboard.setData(ClipboardData(text: _inviteLink!));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Invite link copied to clipboard')),
|
|
);
|
|
}
|
|
}
|
|
|
|
bool get _canManage => widget.permissionState is PermissionLoaded &&
|
|
(widget.permissionState as PermissionLoaded).capabilities.canAdmin;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: AppTheme.primaryBackground,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Container(
|
|
width: 600,
|
|
height: 500,
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Organization Settings',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: Icon(Icons.close, color: AppTheme.secondaryText),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Tabs
|
|
TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: 'Members'),
|
|
Tab(text: 'Invite'),
|
|
Tab(text: 'Requests'),
|
|
Tab(text: 'Link'),
|
|
],
|
|
labelColor: AppTheme.accentColor,
|
|
unselectedLabelColor: AppTheme.secondaryText,
|
|
indicatorColor: AppTheme.accentColor,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Tab content
|
|
Expanded(
|
|
child: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error != null
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(_error!, style: TextStyle(color: AppTheme.errorColor)),
|
|
const SizedBox(height: 16),
|
|
ModernGlassButton(
|
|
onPressed: _loadData,
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildMembersTab(),
|
|
_buildInviteTab(),
|
|
_buildRequestsTab(),
|
|
_buildLinkTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMembersTab() {
|
|
return ListView.builder(
|
|
itemCount: _members.length,
|
|
itemBuilder: (context, index) {
|
|
final member = _members[index];
|
|
return ListTile(
|
|
title: Text(
|
|
member.user.displayName ?? member.user.username,
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
subtitle: Text(
|
|
member.role,
|
|
style: TextStyle(color: AppTheme.secondaryText),
|
|
),
|
|
trailing: _canManage ? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (member.role != 'owner')
|
|
DropdownButton<String>(
|
|
value: member.role,
|
|
items: ['admin', 'member'].map((role) {
|
|
return DropdownMenuItem(
|
|
value: role,
|
|
child: Text(role),
|
|
);
|
|
}).toList(),
|
|
onChanged: (newRole) {
|
|
if (newRole != null && newRole != member.role) {
|
|
_updateMemberRole(member.userId, newRole);
|
|
}
|
|
},
|
|
),
|
|
if (member.role != 'owner')
|
|
IconButton(
|
|
icon: Icon(Icons.remove_circle, color: AppTheme.errorColor),
|
|
onPressed: () => _removeMember(member.userId),
|
|
),
|
|
],
|
|
) : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildInviteTab() {
|
|
final usernameController = TextEditingController();
|
|
String selectedRole = 'member';
|
|
|
|
return Column(
|
|
children: [
|
|
// Pending invitations
|
|
if (_invitations.isNotEmpty) ...[
|
|
Text(
|
|
'Pending Invitations',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: _invitations.length,
|
|
itemBuilder: (context, index) {
|
|
final inv = _invitations[index];
|
|
return ListTile(
|
|
title: Text(inv.username, style: TextStyle(color: AppTheme.primaryText)),
|
|
subtitle: Text('Role: ${inv.role}', style: TextStyle(color: AppTheme.secondaryText)),
|
|
trailing: IconButton(
|
|
icon: Icon(Icons.cancel, color: AppTheme.errorColor),
|
|
onPressed: () => _cancelInvitation(inv.id),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
|
|
// Invite form
|
|
if (_canManage) ...[
|
|
const Divider(),
|
|
Text(
|
|
'Invite New User',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: usernameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Username',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
const SizedBox(height: 8),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedRole,
|
|
items: ['admin', 'member'].map((role) {
|
|
return DropdownMenuItem(
|
|
value: role,
|
|
child: Text(role),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) => selectedRole = value ?? 'member',
|
|
decoration: const InputDecoration(
|
|
labelText: 'Role',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ModernGlassButton(
|
|
onPressed: () {
|
|
final username = usernameController.text.trim();
|
|
if (username.isNotEmpty) {
|
|
_inviteUser(username, selectedRole);
|
|
usernameController.clear();
|
|
}
|
|
},
|
|
child: const Text('Send Invitation'),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRequestsTab() {
|
|
return ListView.builder(
|
|
itemCount: _joinRequests.length,
|
|
itemBuilder: (context, index) {
|
|
final req = _joinRequests[index];
|
|
return ListTile(
|
|
title: Text(
|
|
req.user.displayName ?? req.user.username,
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
subtitle: Text(
|
|
'Requested to join',
|
|
style: TextStyle(color: AppTheme.secondaryText),
|
|
),
|
|
trailing: _canManage ? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => _acceptJoinRequest(req.id, 'member'),
|
|
child: const Text('Accept'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => _rejectJoinRequest(req.id),
|
|
child: Text('Reject', style: TextStyle(color: AppTheme.errorColor)),
|
|
),
|
|
],
|
|
) : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildLinkTab() {
|
|
return Column(
|
|
children: [
|
|
if (_inviteLink != null) ...[
|
|
Text(
|
|
'Invite Link',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_inviteLink!,
|
|
style: TextStyle(color: AppTheme.secondaryText),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
ModernGlassButton(
|
|
onPressed: _copyInviteLink,
|
|
child: const Text('Copy Link'),
|
|
),
|
|
const SizedBox(width: 16),
|
|
if (_canManage)
|
|
ModernGlassButton(
|
|
onPressed: _regenerateInviteLink,
|
|
child: const Text('Regenerate'),
|
|
),
|
|
],
|
|
),
|
|
] else ...[
|
|
const Text('No invite link available'),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
} |