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
This commit is contained in:
@@ -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<PermissionEvent, PermissionState> {
|
||||
PermissionBloc() : super(PermissionInitial()) {
|
||||
final ApiClient apiClient;
|
||||
|
||||
PermissionBloc(this.apiClient) : super(PermissionInitial()) {
|
||||
on<LoadPermissions>(_onLoadPermissions);
|
||||
on<PermissionsReset>(_onPermissionsReset);
|
||||
}
|
||||
@@ -13,19 +17,20 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||
Emitter<PermissionState> 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(
|
||||
|
||||
94
b0esche_cloud/lib/models/organization.dart
Normal file
94
b0esche_cloud/lib/models/organization.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HomePage> with TickerProviderStateMixin {
|
||||
),
|
||||
);
|
||||
|
||||
_permissionBloc = PermissionBloc();
|
||||
_permissionBloc = PermissionBloc(getIt<ApiClient>());
|
||||
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||
_organizationBloc = OrganizationBloc(
|
||||
@@ -204,6 +206,22 @@ class _HomePageState extends State<HomePage> 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<OrgApi>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOrgRow(BuildContext context) {
|
||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||
builder: (context, state) {
|
||||
@@ -352,13 +370,13 @@ class _HomePageState extends State<HomePage> 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<HomePage> 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,
|
||||
|
||||
@@ -71,6 +71,15 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getRaw(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
try {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
@@ -85,6 +94,27 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> patch<T>(
|
||||
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<void> delete(String path) async {
|
||||
try {
|
||||
await _dio.delete(path);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<T>> getList<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
|
||||
@@ -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<List<Member>> getMembers(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/members',
|
||||
fromJson: (data) => Member.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMemberRole(String orgId, String userId, String role) async {
|
||||
await _apiClient.patch(
|
||||
'/orgs/$orgId/members/$userId',
|
||||
data: {'role': role},
|
||||
fromJson: (data) => data,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeMember(String orgId, String userId) async {
|
||||
await _apiClient.delete('/orgs/$orgId/members/$userId');
|
||||
}
|
||||
|
||||
Future<List<User>> searchUsers(String orgId, String query) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/users/search?q=$query',
|
||||
fromJson: (data) => User.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Invitation> 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<List<Invitation>> getInvitations(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/invitations',
|
||||
fromJson: (data) => Invitation.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelInvitation(String orgId, String invitationId) async {
|
||||
await _apiClient.delete('/orgs/$orgId/invitations/$invitationId');
|
||||
}
|
||||
|
||||
Future<JoinRequest> 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<List<JoinRequest>> getJoinRequests(String orgId) async {
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/join-requests',
|
||||
fromJson: (data) => JoinRequest.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> acceptJoinRequest(String orgId, String requestId, String role) async {
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/join-requests/$requestId/accept',
|
||||
data: {'role': role},
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> rejectJoinRequest(String orgId, String requestId) async {
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/join-requests/$requestId/reject',
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getInviteLink(String orgId) async {
|
||||
final result = await _apiClient.getRaw('/orgs/$orgId/invite-link');
|
||||
return result['inviteLink'] as String?;
|
||||
}
|
||||
|
||||
Future<String> regenerateInviteLink(String orgId) async {
|
||||
final result = await _apiClient.post(
|
||||
'/orgs/$orgId/invite-link/regenerate',
|
||||
fromJson: (data) => data,
|
||||
);
|
||||
return result['inviteLink'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
457
b0esche_cloud/lib/widgets/organization_settings_dialog.dart
Normal file
457
b0esche_cloud/lib/widgets/organization_settings_dialog.dart
Normal file
@@ -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<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'),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
go_cloud/api
BIN
go_cloud/api
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
29
go_cloud/migrations/0005_org_invitations.sql
Normal file
29
go_cloud/migrations/0005_org_invitations.sql
Normal file
@@ -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);
|
||||
4
go_cloud/migrations/0006_org_invite_link.sql
Normal file
4
go_cloud/migrations/0006_org_invite_link.sql
Normal file
@@ -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);
|
||||
@@ -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! ==="
|
||||
|
||||
Reference in New Issue
Block a user