diff --git a/b0esche_cloud/lib/blocs/permission/permission_bloc.dart b/b0esche_cloud/lib/blocs/permission/permission_bloc.dart index 596680f..fae3936 100644 --- a/b0esche_cloud/lib/blocs/permission/permission_bloc.dart +++ b/b0esche_cloud/lib/blocs/permission/permission_bloc.dart @@ -1,9 +1,13 @@ import 'package:bloc/bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'permission_event.dart'; import 'permission_state.dart'; +import '../../services/api_client.dart'; class PermissionBloc extends Bloc { - PermissionBloc() : super(PermissionInitial()) { + final ApiClient apiClient; + + PermissionBloc(this.apiClient) : super(PermissionInitial()) { on(_onLoadPermissions); on(_onPermissionsReset); } @@ -13,19 +17,20 @@ class PermissionBloc extends Bloc { Emitter emit, ) async { emit(PermissionLoading()); - // Simulate loading permissions from backend for orgId - await Future.delayed(const Duration(seconds: 1)); - // Mock capabilities based on orgId - // Allow all permissions for authenticated users (proper permissions should come from backend) - final capabilities = Capabilities( - canRead: true, - canWrite: true, - canShare: true, - canAdmin: true, - canAnnotate: true, - canEdit: true, - ); - emit(PermissionLoaded(capabilities)); + try { + final response = await apiClient.getRaw('/orgs/${event.orgId}/permissions'); + final capabilities = Capabilities( + canRead: response['canRead'] ?? false, + canWrite: response['canWrite'] ?? false, + canShare: response['canShare'] ?? false, + canAdmin: response['canAdmin'] ?? false, + canAnnotate: response['canAnnotate'] ?? false, + canEdit: response['canEdit'] ?? false, + ); + emit(PermissionLoaded(capabilities)); + } catch (e) { + emit(PermissionDenied(e.toString())); + } } void _onPermissionsReset( diff --git a/b0esche_cloud/lib/models/organization.dart b/b0esche_cloud/lib/models/organization.dart new file mode 100644 index 0000000..96ae350 --- /dev/null +++ b/b0esche_cloud/lib/models/organization.dart @@ -0,0 +1,94 @@ +import 'user.dart'; + +class Member { + final String userId; + final String orgId; + final String role; + final DateTime createdAt; + final User user; + + const Member({ + required this.userId, + required this.orgId, + required this.role, + required this.createdAt, + required this.user, + }); + + factory Member.fromJson(Map json) { + return Member( + userId: json['UserID'] ?? json['userId'], + orgId: json['OrgID'] ?? json['orgId'], + role: json['Role'] ?? json['role'], + createdAt: DateTime.parse(json['CreatedAt'] ?? json['createdAt']), + user: User.fromJson(json['User'] ?? json), + ); + } +} + +class Invitation { + final String id; + final String orgId; + final String invitedBy; + final String username; + final String role; + final DateTime createdAt; + final DateTime expiresAt; + final DateTime? acceptedAt; + + const Invitation({ + required this.id, + required this.orgId, + required this.invitedBy, + required this.username, + required this.role, + required this.createdAt, + required this.expiresAt, + this.acceptedAt, + }); + + factory Invitation.fromJson(Map json) { + return Invitation( + id: json['id'], + orgId: json['orgId'], + invitedBy: json['invitedBy'], + username: json['username'], + role: json['role'], + createdAt: DateTime.parse(json['createdAt']), + expiresAt: DateTime.parse(json['expiresAt']), + acceptedAt: json['acceptedAt'] != null ? DateTime.parse(json['acceptedAt']) : null, + ); + } +} + +class JoinRequest { + final String id; + final String orgId; + final String userId; + final String? inviteToken; + final DateTime requestedAt; + final String status; + final User user; + + const JoinRequest({ + required this.id, + required this.orgId, + required this.userId, + this.inviteToken, + required this.requestedAt, + required this.status, + required this.user, + }); + + factory JoinRequest.fromJson(Map json) { + return JoinRequest( + id: json['ID'] ?? json['id'], + orgId: json['OrgID'] ?? json['orgId'], + userId: json['UserID'] ?? json['userId'], + inviteToken: json['InviteToken'] ?? json['inviteToken'], + requestedAt: DateTime.parse(json['RequestedAt'] ?? json['requestedAt']), + status: json['Status'] ?? json['status'], + user: User.fromJson(json['User'] ?? json), + ); + } +} \ No newline at end of file diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index b5e3474..31716cb 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -13,10 +13,12 @@ import '../blocs/upload/upload_bloc.dart'; import '../repositories/file_repository.dart'; import '../services/file_service.dart'; import '../services/org_api.dart'; +import '../services/api_client.dart'; import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; import 'login_form.dart' show LoginForm; import 'file_explorer.dart'; +import '../widgets/organization_settings_dialog.dart'; import '../widgets/audio_player_bar.dart'; import '../injection.dart'; @@ -65,7 +67,7 @@ class _HomePageState extends State with TickerProviderStateMixin { ), ); - _permissionBloc = PermissionBloc(); + _permissionBloc = PermissionBloc(getIt()); _fileBrowserBloc = FileBrowserBloc(getIt()); _uploadBloc = UploadBloc(getIt()); _organizationBloc = OrganizationBloc( @@ -204,6 +206,22 @@ class _HomePageState extends State with TickerProviderStateMixin { ); } + void _showOrganizationSettings(BuildContext context) { + final orgState = _organizationBloc.state; + final permState = _permissionBloc.state; + + if (orgState is OrganizationLoaded && orgState.selectedOrg != null) { + showDialog( + context: context, + builder: (dialogContext) => OrganizationSettingsDialog( + organization: orgState.selectedOrg!, + permissionState: permState, + orgApi: getIt(), + ), + ); + } + } + Widget _buildOrgRow(BuildContext context) { return BlocBuilder( builder: (context, state) { @@ -352,13 +370,13 @@ class _HomePageState extends State with TickerProviderStateMixin { ); } - Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) { + Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false, VoidCallback? onTap}) { final isSelected = _selectedTab == label; final highlightColor = const Color.fromARGB(255, 100, 200, 255); final defaultColor = AppTheme.secondaryText; return GestureDetector( - onTap: () { + onTap: onTap ?? () { setState(() { _selectedTab = label; }); @@ -477,6 +495,8 @@ class _HomePageState extends State with TickerProviderStateMixin { const SizedBox(width: 16), _buildNavButton('Add', Icons.add), const SizedBox(width: 16), + _buildNavButton('Settings', Icons.settings, onTap: () => _showOrganizationSettings(context)), + const SizedBox(width: 16), _buildNavButton( 'Profile', Icons.person, diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart index 5f33626..53a8fe3 100644 --- a/b0esche_cloud/lib/services/api_client.dart +++ b/b0esche_cloud/lib/services/api_client.dart @@ -71,6 +71,15 @@ class ApiClient { } } + Future> getRaw(String path, {Map? queryParameters}) async { + try { + final response = await _dio.get(path, queryParameters: queryParameters); + return response.data; + } on DioException catch (e) { + throw _handleError(e); + } + } + Future post( String path, { dynamic data, @@ -85,6 +94,27 @@ class ApiClient { } } + Future patch( + String path, { + dynamic data, + required T Function(dynamic data) fromJson, + }) async { + try { + final response = await _dio.patch(path, data: data); + return fromJson(response.data); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future delete(String path) async { + try { + await _dio.delete(path); + } on DioException catch (e) { + throw _handleError(e); + } + } + Future> getList( String path, { Map? queryParameters, diff --git a/b0esche_cloud/lib/services/org_api.dart b/b0esche_cloud/lib/services/org_api.dart index e1526df..81a4caa 100644 --- a/b0esche_cloud/lib/services/org_api.dart +++ b/b0esche_cloud/lib/services/org_api.dart @@ -1,4 +1,6 @@ import '../blocs/organization/organization_state.dart'; +import '../models/organization.dart'; +import '../models/user.dart'; import 'api_client.dart'; import 'dart:developer' as developer; @@ -29,4 +31,98 @@ class OrgApi { rethrow; } } + + Future> getMembers(String orgId) async { + return await _apiClient.getList( + '/orgs/$orgId/members', + fromJson: (data) => Member.fromJson(data), + ); + } + + Future updateMemberRole(String orgId, String userId, String role) async { + await _apiClient.patch( + '/orgs/$orgId/members/$userId', + data: {'role': role}, + fromJson: (data) => data, + ); + } + + Future removeMember(String orgId, String userId) async { + await _apiClient.delete('/orgs/$orgId/members/$userId'); + } + + Future> searchUsers(String orgId, String query) async { + return await _apiClient.getList( + '/orgs/$orgId/users/search?q=$query', + fromJson: (data) => User.fromJson(data), + ); + } + + Future createInvitation(String orgId, String username, String role) async { + final result = await _apiClient.post( + '/orgs/$orgId/invitations', + data: {'username': username, 'role': role}, + fromJson: (data) => Invitation.fromJson(data), + ); + return result; + } + + Future> getInvitations(String orgId) async { + return await _apiClient.getList( + '/orgs/$orgId/invitations', + fromJson: (data) => Invitation.fromJson(data), + ); + } + + Future cancelInvitation(String orgId, String invitationId) async { + await _apiClient.delete('/orgs/$orgId/invitations/$invitationId'); + } + + Future createJoinRequest(String orgId, {String? inviteToken}) async { + final data = {'orgId': orgId}; + if (inviteToken != null) { + data['inviteToken'] = inviteToken; + } + final result = await _apiClient.post( + '/join-requests', + data: data, + fromJson: (data) => JoinRequest.fromJson(data), + ); + return result; + } + + Future> getJoinRequests(String orgId) async { + return await _apiClient.getList( + '/orgs/$orgId/join-requests', + fromJson: (data) => JoinRequest.fromJson(data), + ); + } + + Future acceptJoinRequest(String orgId, String requestId, String role) async { + await _apiClient.post( + '/orgs/$orgId/join-requests/$requestId/accept', + data: {'role': role}, + fromJson: (data) => null, + ); + } + + Future rejectJoinRequest(String orgId, String requestId) async { + await _apiClient.post( + '/orgs/$orgId/join-requests/$requestId/reject', + fromJson: (data) => null, + ); + } + + Future getInviteLink(String orgId) async { + final result = await _apiClient.getRaw('/orgs/$orgId/invite-link'); + return result['inviteLink'] as String?; + } + + Future regenerateInviteLink(String orgId) async { + final result = await _apiClient.post( + '/orgs/$orgId/invite-link/regenerate', + fromJson: (data) => data, + ); + return result['inviteLink'] as String; + } } diff --git a/b0esche_cloud/lib/theme/app_theme.dart b/b0esche_cloud/lib/theme/app_theme.dart index 212adbd..f85d491 100644 --- a/b0esche_cloud/lib/theme/app_theme.dart +++ b/b0esche_cloud/lib/theme/app_theme.dart @@ -5,6 +5,7 @@ class AppTheme { static const Color accentColor = Color.fromARGB(255, 100, 200, 255); static const Color secondaryText = Colors.white70; static const Color primaryText = Colors.white; + static const Color errorColor = Colors.redAccent; static const Color glassBackground = Colors.white; static const double glassOpacity = 0.1; static const double glassBlur = 10; diff --git a/b0esche_cloud/lib/widgets/organization_settings_dialog.dart b/b0esche_cloud/lib/widgets/organization_settings_dialog.dart new file mode 100644 index 0000000..b6ee71b --- /dev/null +++ b/b0esche_cloud/lib/widgets/organization_settings_dialog.dart @@ -0,0 +1,457 @@ +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 createState() => _OrganizationSettingsDialogState(); +} + +class _OrganizationSettingsDialogState extends State with TickerProviderStateMixin { + late TabController _tabController; + List _members = []; + List _invitations = []; + List _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 _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; + _invitations = results[1] as List; + _joinRequests = results[2] as List; + _inviteLink = results[3] as String?; + _isLoading = false; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _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 _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 _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 _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 _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 _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 _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( + 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( + 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'), + ], + ], + ); + } +} \ No newline at end of file diff --git a/go_cloud/api b/go_cloud/api index d5ee341..1e3eabe 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index c412a3b..dc51575 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -101,11 +101,12 @@ type Session struct { } type Organization struct { - ID uuid.UUID `json:"id"` - OwnerID uuid.UUID `json:"ownerId"` - Name string `json:"name"` - Slug string `json:"slug"` - CreatedAt time.Time `json:"createdAt"` + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"ownerId"` + Name string `json:"name"` + Slug string `json:"slug"` + InviteLinkToken *string `json:"inviteLinkToken,omitempty"` + CreatedAt time.Time `json:"createdAt"` } type Membership struct { @@ -115,6 +116,26 @@ type Membership struct { CreatedAt time.Time } +type Invitation struct { + ID uuid.UUID + OrgID uuid.UUID + InvitedBy uuid.UUID + Username string + Role string + CreatedAt time.Time + ExpiresAt time.Time + AcceptedAt *time.Time +} + +type JoinRequest struct { + ID uuid.UUID + OrgID uuid.UUID + UserID uuid.UUID + InviteToken *string + RequestedAt time.Time + Status string +} + type Activity struct { ID uuid.UUID UserID uuid.UUID @@ -232,12 +253,14 @@ func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membe } func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) { + // Generate a unique invite link token + inviteToken := uuid.New().String() var org Organization err := db.QueryRowContext(ctx, ` - INSERT INTO organizations (owner_id, name, slug) - VALUES ($1, $2, $3) - RETURNING id, owner_id, name, slug, created_at - `, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt) + INSERT INTO organizations (owner_id, name, slug, invite_link_token) + VALUES ($1, $2, $3, $4) + RETURNING id, owner_id, name, slug, invite_link_token, created_at + `, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt) if err != nil { return nil, err } @@ -308,6 +331,272 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, return memberships, rows.Err() } +// GetOrgMembersWithUsers returns members with user details +func (db *DB) GetOrgMembersWithUsers(ctx context.Context, orgID uuid.UUID) ([]struct { + Membership + User +}, error) { + rows, err := db.QueryContext(ctx, ` + SELECT m.user_id, m.org_id, m.role, m.created_at, + u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at + FROM memberships m + JOIN users u ON m.user_id = u.id + WHERE m.org_id = $1 + ORDER BY m.created_at + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []struct { + Membership + User + } + for rows.Next() { + var m struct { + Membership + User + } + err := rows.Scan( + &m.Membership.UserID, &m.Membership.OrgID, &m.Membership.Role, &m.Membership.CreatedAt, + &m.User.ID, &m.User.Email, &m.User.Username, &m.User.DisplayName, &m.User.CreatedAt, &m.User.LastLoginAt, + ) + if err != nil { + return nil, err + } + members = append(members, m) + } + return members, rows.Err() +} + +// UpdateMemberRole updates a member's role +func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, newRole string) error { + _, err := db.ExecContext(ctx, ` + UPDATE memberships + SET role = $1 + WHERE org_id = $2 AND user_id = $3 + `, newRole, orgID, userID) + return err +} + +// RemoveMember removes a user from an organization +func (db *DB) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + DELETE FROM memberships + WHERE org_id = $1 AND user_id = $2 + `, orgID, userID) + return err +} + +// SearchUsersByUsername searches users by partial username match +func (db *DB) SearchUsersByUsername(ctx context.Context, query string, limit int) ([]User, error) { + if limit <= 0 { + limit = 10 + } + rows, err := db.QueryContext(ctx, ` + SELECT id, email, username, display_name, created_at, last_login_at + FROM users + WHERE username ILIKE $1 + ORDER BY username + LIMIT $2 + `, "%"+query+"%", limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.DisplayName, &u.CreatedAt, &u.LastLoginAt) + if err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +// CreateInvitation creates a new invitation +func (db *DB) CreateInvitation(ctx context.Context, orgID, invitedBy uuid.UUID, username, role string) (*Invitation, error) { + var inv Invitation + err := db.QueryRowContext(ctx, ` + INSERT INTO invitations (org_id, invited_by, username, role) + VALUES ($1, $2, $3, $4) + RETURNING id, org_id, invited_by, username, role, created_at, expires_at, accepted_at + `, orgID, invitedBy, username, role).Scan( + &inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role, + &inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt, + ) + if err != nil { + return nil, err + } + return &inv, nil +} + +// GetOrgInvitations returns pending invitations for an org +func (db *DB) GetOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Invitation, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, org_id, invited_by, username, role, created_at, expires_at, accepted_at + FROM invitations + WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var invitations []Invitation + for rows.Next() { + var inv Invitation + err := rows.Scan( + &inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role, + &inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt, + ) + if err != nil { + return nil, err + } + invitations = append(invitations, inv) + } + return invitations, rows.Err() +} + +// CancelInvitation cancels an invitation +func (db *DB) CancelInvitation(ctx context.Context, invitationID uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + DELETE FROM invitations + WHERE id = $1 + `, invitationID) + return err +} + +// CreateJoinRequest creates a join request +func (db *DB) CreateJoinRequest(ctx context.Context, orgID, userID uuid.UUID, inviteToken *string) (*JoinRequest, error) { + var req JoinRequest + err := db.QueryRowContext(ctx, ` + INSERT INTO join_requests (org_id, user_id, invite_token) + VALUES ($1, $2, $3) + ON CONFLICT (org_id, user_id) DO UPDATE SET + invite_token = EXCLUDED.invite_token, + requested_at = NOW(), + status = 'pending' + RETURNING id, org_id, user_id, invite_token, requested_at, status + `, orgID, userID, inviteToken).Scan( + &req.ID, &req.OrgID, &req.UserID, &req.InviteToken, &req.RequestedAt, &req.Status, + ) + if err != nil { + return nil, err + } + return &req, nil +} + +// GetOrgJoinRequests returns pending join requests for an org +func (db *DB) GetOrgJoinRequests(ctx context.Context, orgID uuid.UUID) ([]struct { + JoinRequest + User +}, error) { + rows, err := db.QueryContext(ctx, ` + SELECT jr.id, jr.org_id, jr.user_id, jr.invite_token, jr.requested_at, jr.status, + u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at + FROM join_requests jr + JOIN users u ON jr.user_id = u.id + WHERE jr.org_id = $1 AND jr.status = 'pending' + ORDER BY jr.requested_at DESC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var requests []struct { + JoinRequest + User + } + for rows.Next() { + var r struct { + JoinRequest + User + } + err := rows.Scan( + &r.JoinRequest.ID, &r.JoinRequest.OrgID, &r.JoinRequest.UserID, &r.JoinRequest.InviteToken, &r.JoinRequest.RequestedAt, &r.JoinRequest.Status, + &r.User.ID, &r.User.Email, &r.User.Username, &r.User.DisplayName, &r.User.CreatedAt, &r.User.LastLoginAt, + ) + if err != nil { + return nil, err + } + requests = append(requests, r) + } + return requests, rows.Err() +} + +// AcceptJoinRequest accepts a join request and adds the user as member +func (db *DB) AcceptJoinRequest(ctx context.Context, requestID uuid.UUID, role string) error { + // Get the request details + var orgID, userID uuid.UUID + err := db.QueryRowContext(ctx, ` + SELECT org_id, user_id + FROM join_requests + WHERE id = $1 AND status = 'pending' + `, requestID).Scan(&orgID, &userID) + if err != nil { + return err + } + + // Add membership + err = db.AddMembership(ctx, userID, orgID, role) + if err != nil { + return err + } + + // Mark request as accepted + _, err = db.ExecContext(ctx, ` + UPDATE join_requests + SET status = 'accepted' + WHERE id = $1 + `, requestID) + return err +} + +// RejectJoinRequest rejects a join request +func (db *DB) RejectJoinRequest(ctx context.Context, requestID uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + UPDATE join_requests + SET status = 'rejected' + WHERE id = $1 + `, requestID) + return err +} + +// GetInviteLink returns the invite link token for an org +func (db *DB) GetInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) { + var token *string + err := db.QueryRowContext(ctx, ` + SELECT invite_link_token + FROM organizations + WHERE id = $1 + `, orgID).Scan(&token) + if err != nil { + return nil, err + } + return token, nil +} + +// RegenerateInviteLink generates a new invite link token +func (db *DB) RegenerateInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) { + newToken := uuid.New().String() + _, err := db.ExecContext(ctx, ` + UPDATE organizations + SET invite_link_token = $1 + WHERE id = $2 + `, newToken, orgID) + if err != nil { + return nil, err + } + return &newToken, nil +} + // GetOrgFiles returns files for a given organization (top-level folder listing) func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { if page <= 0 { @@ -710,15 +999,6 @@ func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uu return nil } -func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error { - _, err := db.ExecContext(ctx, ` - UPDATE memberships - SET role = $1 - WHERE org_id = $2 AND user_id = $3 - `, role, orgID, userID) - return err -} - // Passkey-related methods func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) { diff --git a/go_cloud/internal/errors/errors.go b/go_cloud/internal/errors/errors.go index 6531473..fa42527 100644 --- a/go_cloud/internal/errors/errors.go +++ b/go_cloud/internal/errors/errors.go @@ -18,6 +18,7 @@ const ( CodePermissionDenied ErrorCode = "PERMISSION_DENIED" CodeNotFound ErrorCode = "NOT_FOUND" CodeConflict ErrorCode = "CONFLICT" + CodeAlreadyExists ErrorCode = "ALREADY_EXISTS" CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT" CodeInternal ErrorCode = "INTERNAL" ) diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 1608140..fc181a1 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -275,6 +275,42 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) { updateMemberRoleHandler(w, req, db, auditLogger) }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/members/{userId}", func(w http.ResponseWriter, req *http.Request) { + removeMemberHandler(w, req, db, auditLogger) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/users/search", func(w http.ResponseWriter, req *http.Request) { + searchUsersHandler(w, req, db) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invitations", func(w http.ResponseWriter, req *http.Request) { + createInvitationHandler(w, req, db, auditLogger) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invitations", func(w http.ResponseWriter, req *http.Request) { + listInvitationsHandler(w, req, db) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/invitations/{invitationId}", func(w http.ResponseWriter, req *http.Request) { + cancelInvitationHandler(w, req, db, auditLogger) + }) + r.Post("/join-requests", func(w http.ResponseWriter, req *http.Request) { + createJoinRequestHandler(w, req, db, auditLogger) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/join-requests", func(w http.ResponseWriter, req *http.Request) { + listJoinRequestsHandler(w, req, db) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/accept", func(w http.ResponseWriter, req *http.Request) { + acceptJoinRequestHandler(w, req, db, auditLogger) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/reject", func(w http.ResponseWriter, req *http.Request) { + rejectJoinRequestHandler(w, req, db, auditLogger) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invite-link", func(w http.ResponseWriter, req *http.Request) { + getInviteLinkHandler(w, req, db) + }) + r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invite-link/regenerate", func(w http.ResponseWriter, req *http.Request) { + regenerateInviteLinkHandler(w, req, db, auditLogger) + }) + r.Get("/permissions", func(w http.ResponseWriter, req *http.Request) { + getPermissionsHandler(w, req, db) + }) }) }) // Close protected routes @@ -827,7 +863,7 @@ func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) - members, err := db.GetOrgMembers(r.Context(), orgID) + members, err := db.GetOrgMembersWithUsers(r.Context(), orgID) if err != nil { errors.LogError(r, err, "Failed to get org members") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -865,6 +901,365 @@ func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *databas w.Write([]byte(`{"status": "ok"}`)) } +func removeMemberHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + userIDStr := chi.URLParam(r, "userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest) + return + } + + // Check if trying to remove the owner + membership, err := db.GetUserMembership(r.Context(), userID, orgID) + if err != nil { + errors.LogError(r, err, "Failed to get membership") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + if membership.Role == "owner" { + errors.WriteError(w, errors.CodePermissionDenied, "Cannot remove organization owner", http.StatusForbidden) + return + } + + if err := db.RemoveMember(r.Context(), orgID, userID); err != nil { + errors.LogError(r, err, "Failed to remove member") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + resource := userID.String() + auditLogger.Log(r.Context(), audit.Entry{ + OrgID: &orgID, + Action: "remove_member", + Resource: &resource, + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) +} + +func searchUsersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + query := r.URL.Query().Get("q") + if query == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Query parameter 'q' is required", http.StatusBadRequest) + return + } + + users, err := db.SearchUsersByUsername(r.Context(), query, 10) + if err != nil { + errors.LogError(r, err, "Failed to search users") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +func createInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + userIDStr, ok := middleware.GetUserID(r.Context()) + if !ok { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + invitedBy, _ := uuid.Parse(userIDStr) + + var req struct { + Username string `json:"username"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + if req.Role != "admin" && req.Role != "member" { + errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest) + return + } + + invitation, err := db.CreateInvitation(r.Context(), orgID, invitedBy, req.Username, req.Role) + if err != nil { + if strings.Contains(err.Error(), "duplicate key value") { + errors.WriteError(w, errors.CodeAlreadyExists, "User is already invited or a member", http.StatusConflict) + return + } + errors.LogError(r, err, "Failed to create invitation") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &invitedBy, + OrgID: &orgID, + Action: "create_invitation", + Resource: &req.Username, + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(invitation) +} + +func listInvitationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + + invitations, err := db.GetOrgInvitations(r.Context(), orgID) + if err != nil { + errors.LogError(r, err, "Failed to get invitations") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(invitations) +} + +func cancelInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + invitationIDStr := chi.URLParam(r, "invitationId") + invitationID, err := uuid.Parse(invitationIDStr) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invitation ID", http.StatusBadRequest) + return + } + + if err := db.CancelInvitation(r.Context(), invitationID); err != nil { + errors.LogError(r, err, "Failed to cancel invitation") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + resource := invitationID.String() + auditLogger.Log(r.Context(), audit.Entry{ + OrgID: &orgID, + Action: "cancel_invitation", + Resource: &resource, + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) +} + +func createJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + var req struct { + OrgID string `json:"orgId"` + InviteToken *string `json:"inviteToken,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + orgID, err := uuid.Parse(req.OrgID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid org ID", http.StatusBadRequest) + return + } + + userIDStr, ok := middleware.GetUserID(r.Context()) + if !ok { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + userID, _ := uuid.Parse(userIDStr) + + // If invite token provided, validate it + if req.InviteToken != nil { + var token *string + err := db.QueryRowContext(r.Context(), ` + SELECT invite_link_token FROM organizations WHERE id = $1 + `, orgID).Scan(&token) + if err != nil || token == nil || *token != *req.InviteToken { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invite token", http.StatusBadRequest) + return + } + } + + joinRequest, err := db.CreateJoinRequest(r.Context(), orgID, userID, req.InviteToken) + if err != nil { + errors.LogError(r, err, "Failed to create join request") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "create_join_request", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(joinRequest) +} + +func listJoinRequestsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + + requests, err := db.GetOrgJoinRequests(r.Context(), orgID) + if err != nil { + errors.LogError(r, err, "Failed to get join requests") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(requests) +} + +func acceptJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + requestIDStr := chi.URLParam(r, "requestId") + requestID, err := uuid.Parse(requestIDStr) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request ID", http.StatusBadRequest) + return + } + + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + if req.Role != "admin" && req.Role != "member" { + errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest) + return + } + + if err := db.AcceptJoinRequest(r.Context(), requestID, req.Role); err != nil { + errors.LogError(r, err, "Failed to accept join request") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + resource := requestID.String() + auditLogger.Log(r.Context(), audit.Entry{ + OrgID: &orgID, + Action: "accept_join_request", + Resource: &resource, + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) +} + +func rejectJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + requestIDStr := chi.URLParam(r, "requestId") + requestID, err := uuid.Parse(requestIDStr) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request ID", http.StatusBadRequest) + return + } + + if err := db.RejectJoinRequest(r.Context(), requestID); err != nil { + errors.LogError(r, err, "Failed to reject join request") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + resource := requestID.String() + auditLogger.Log(r.Context(), audit.Entry{ + OrgID: &orgID, + Action: "reject_join_request", + Resource: &resource, + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) +} + +func getInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + + token, err := db.GetInviteLink(r.Context(), orgID) + if err != nil { + errors.LogError(r, err, "Failed to get invite link") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + response := struct { + InviteLink *string `json:"inviteLink"` + }{ + InviteLink: token, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func regenerateInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + + newToken, err := db.RegenerateInviteLink(r.Context(), orgID) + if err != nil { + errors.LogError(r, err, "Failed to regenerate invite link") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + OrgID: &orgID, + Action: "regenerate_invite_link", + Success: true, + }) + + response := struct { + InviteLink string `json:"inviteLink"` + }{ + InviteLink: *newToken, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func getPermissionsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID) + userIDStr, ok := middleware.GetUserID(r.Context()) + if !ok { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + userID, _ := uuid.Parse(userIDStr) + + // Check each permission + canRead, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileRead) + canWrite, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileWrite) + canEdit, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.DocumentEdit) + canAdmin, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.OrgManage) + + response := struct { + CanRead bool `json:"canRead"` + CanWrite bool `json:"canWrite"` + CanShare bool `json:"canShare"` + CanAdmin bool `json:"canAdmin"` + CanAnnotate bool `json:"canAnnotate"` + CanEdit bool `json:"canEdit"` + }{ + CanRead: canRead, + CanWrite: canWrite, + CanShare: canRead, // Share is tied to read for now + CanAdmin: canAdmin, + CanAnnotate: canEdit, // Annotate is tied to edit + CanEdit: canEdit, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + func fileMetaHandler(w http.ResponseWriter, r *http.Request) { meta := struct { LastModified string `json:"lastModified"` diff --git a/go_cloud/internal/permission/permission.go b/go_cloud/internal/permission/permission.go index 3140dd0..9c6bb95 100644 --- a/go_cloud/internal/permission/permission.go +++ b/go_cloud/internal/permission/permission.go @@ -23,8 +23,7 @@ const ( var rolePermissions = map[string][]Permission{ "owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage}, "admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit}, - "editor": {FileRead, FileWrite, DocumentView, DocumentEdit}, - "viewer": {FileRead, DocumentView}, + "member": {FileRead, DocumentView}, } // HasPermission checks if user has permission in org diff --git a/go_cloud/migrations/0005_org_invitations.sql b/go_cloud/migrations/0005_org_invitations.sql new file mode 100644 index 0000000..ac665e5 --- /dev/null +++ b/go_cloud/migrations/0005_org_invitations.sql @@ -0,0 +1,29 @@ +-- Add invitations and join_requests tables for organization management + +CREATE TABLE invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + invited_by UUID NOT NULL REFERENCES users(id), + username TEXT NOT NULL, -- username of the invited user + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '7 days'), + accepted_at TIMESTAMP WITH TIME ZONE, + UNIQUE(org_id, username) -- prevent duplicate invites for same user in org +); + +CREATE TABLE join_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + invite_token TEXT, -- optional, if from invite link + requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')), + UNIQUE(org_id, user_id) -- prevent duplicate requests +); + +-- Index for faster lookups +CREATE INDEX idx_invitations_org_id ON invitations(org_id); +CREATE INDEX idx_invitations_username ON invitations(username); +CREATE INDEX idx_join_requests_org_id ON join_requests(org_id); +CREATE INDEX idx_join_requests_user_id ON join_requests(user_id); \ No newline at end of file diff --git a/go_cloud/migrations/0006_org_invite_link.sql b/go_cloud/migrations/0006_org_invite_link.sql new file mode 100644 index 0000000..ed40870 --- /dev/null +++ b/go_cloud/migrations/0006_org_invite_link.sql @@ -0,0 +1,4 @@ +-- Add invite_link_token to organizations for shareable invite links + +ALTER TABLE organizations ADD COLUMN invite_link_token TEXT UNIQUE; +CREATE INDEX idx_organizations_invite_link_token ON organizations(invite_link_token); \ No newline at end of file diff --git a/go_cloud/migrations/run-migrations.sh b/go_cloud/migrations/run-migrations.sh index 68e3e33..cd18fe8 100644 --- a/go_cloud/migrations/run-migrations.sh +++ b/go_cloud/migrations/run-migrations.sh @@ -31,20 +31,28 @@ run_migration() { } # Run migrations in order -echo "Step 1/4: Initial schema..." +echo "Step 1/6: Initial schema..." run_migration "$SCRIPT_DIR/0001_initial.sql" echo -echo "Step 2/4: Passkeys and authentication..." +echo "Step 2/6: Passkeys and authentication..." run_migration "$SCRIPT_DIR/0002_passkeys.sql" echo -echo "Step 3/4: Files and storage..." +echo "Step 3/6: Files and storage..." run_migration "$SCRIPT_DIR/0003_files.sql" echo -echo "Step 4/4: Organization ownership and slug scope..." +echo "Step 4/6: Organization ownership and slug scope..." run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql" +echo +echo "Step 5/6: Organization invitations and join requests..." +run_migration "$SCRIPT_DIR/0005_org_invitations.sql" + +echo +echo "Step 6/6: Organization invite links..." +run_migration "$SCRIPT_DIR/0006_org_invite_link.sql" + echo echo "=== All migrations completed successfully! ==="