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:
Leon Bösche
2026-01-23 23:21:23 +01:00
parent a03b0dfe33
commit 20bc0ac757
15 changed files with 1461 additions and 42 deletions

View File

@@ -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(

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;

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

Binary file not shown.

View File

@@ -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) {

View File

@@ -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"
)

View File

@@ -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"`

View File

@@ -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

View 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);

View 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);

View File

@@ -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! ==="