Files
b0esche_cloud/b0esche_cloud/lib/widgets/organization_settings_dialog.dart
Leon Bösche 20bc0ac757 Implement complete Organizations feature with RBAC
- 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
2026-01-23 23:21:23 +01:00

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'),
],
],
);
}
}