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

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