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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user