Skip to content

Commit 3dfa389

Browse files
fiftincursoragent
andauthored
Implement project invitation system (#3187)
* Add project invite system with create, accept, and manage endpoints Co-authored-by: denguk <[email protected]> * refactor(invite): separete interface * Add project invite management API and related tests Co-authored-by: denguk <[email protected]> * feat(invite): tests * feat(invite): return get list * feat(invite): add post endpoint test * feat: rename field * chore: spacles * test(dredd): add missed invite id * feat(invites): add config options * feat(invites): add ui * refactor(invites): rename fields * feat(invites): send invite from UI * feat(invites): invitees page * refactor(invite): add dialog component * feat(invites): send email * chore(ui): spaces * feat(invites): accept invite page * feat(invite): accept invite screen --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent d87f0be commit 3dfa389

26 files changed

+2289
-41
lines changed

.dredd/hooks/capabilities.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ var view *db.View
2222
var integration *db.Integration
2323
var integrationextractvalue *db.IntegrationExtractValue
2424
var integrationmatch *db.IntegrationMatcher
25+
var invite *db.ProjectInvite
2526

2627
// Runtime created simple ID values for some items we need to reference in other objects
2728
var repoID int
@@ -45,6 +46,7 @@ var capabilities = map[string][]string{
4546
"integration": {"project", "template"},
4647
"integrationextractvalue": {"integration"},
4748
"integrationmatcher": {"integration"},
49+
"invite": {"user", "project"},
4850
}
4951

5052
func capabilityWrapper(cap string) func(t *trans.Transaction) {
@@ -76,6 +78,8 @@ func resolveCapability(caps []string, resolved []string, uid string) {
7678

7779
//Add dep specific stuff
7880
switch v {
81+
case "invite":
82+
invite = addInvite()
7983
case "schedule":
8084
schedule = addSchedule()
8185
case "view":
@@ -199,6 +203,7 @@ var pathSubPatterns = []func() string{
199203
func() string { return strconv.Itoa(integration.ID) },
200204
func() string { return strconv.Itoa(integrationextractvalue.ID) },
201205
func() string { return strconv.Itoa(integrationmatch.ID) },
206+
func() string { return strconv.Itoa(invite.ID) }, // invite_id, x-example: 14
202207
}
203208

204209
// alterRequestPath with the above slice of functions
@@ -230,6 +235,9 @@ func alterRequestBody(t *trans.Transaction) {
230235
bodyFieldProcessor("ssh_key_id", userKey.ID, &request)
231236
bodyFieldProcessor("become_key_id", userKey.ID, &request)
232237
}
238+
if invite != nil {
239+
bodyFieldProcessor("invite_id", 4, &request)
240+
}
233241
bodyFieldProcessor("environment_id", environmentID, &request)
234242
bodyFieldProcessor("inventory_id", inventoryID, &request)
235243
bodyFieldProcessor("repository_id", repoID, &request)

.dredd/hooks/helpers.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ package main
33
import (
44
"encoding/json"
55
"fmt"
6-
"github.com/semaphoreui/semaphore/pkg/tz"
76
"os"
87

8+
"github.com/semaphoreui/semaphore/pkg/tz"
9+
910
"github.com/go-gorp/gorp/v3"
1011
"github.com/semaphoreui/semaphore/db"
1112
"github.com/semaphoreui/semaphore/db/bolt"
@@ -195,6 +196,35 @@ func addView() *db.View {
195196
return &view
196197
}
197198

199+
func addInvite() *db.ProjectInvite {
200+
invite, err := store.CreateProjectInvite(db.ProjectInvite{
201+
ProjectID: userProject.ID,
202+
UserID: &userPathTestUser.ID,
203+
Email: &userPathTestUser.Email,
204+
Role: "owner",
205+
Status: db.ProjectInvitePending,
206+
Token: getUUID(),
207+
InviterUserID: testRunnerUser.ID,
208+
Created: tz.Now(),
209+
ExpiresAt: nil, // No expiration for this test
210+
AcceptedAt: nil,
211+
})
212+
213+
fmt.Println("***************************************")
214+
fmt.Println("***************************************")
215+
fmt.Println("***************************************")
216+
fmt.Println(invite.ID)
217+
fmt.Println("***************************************")
218+
fmt.Println("***************************************")
219+
fmt.Println("***************************************")
220+
221+
if err != nil {
222+
panic(err)
223+
}
224+
225+
return &invite
226+
}
227+
198228
func addSchedule() *db.Schedule {
199229
schedule, err := store.CreateSchedule(db.Schedule{
200230
TemplateID: int(templateID),

.dredd/hooks/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ func main() {
8080
transaction.Request.Body = "{ \"user_id\": " + strconv.Itoa(userPathTestUser.ID) + ",\"role\": \"owner\"}"
8181
})
8282

83+
h.Before("project > /api/project/{project_id}/invites > Get invitations for project > 200 > application/json", capabilityWrapper("invite"))
84+
h.Before("project > /api/project/{project_id}/invites > Create project invitation > 201 > application/json", capabilityWrapper("invite"))
85+
h.Before("project > /api/project/{project_id}/invites/{invite_id} > Get specific project invitation > 200 > application/json", capabilityWrapper("invite"))
86+
h.Before("project > /api/project/{project_id}/invites/{invite_id} > Update project invitation status > 204 > application/json", capabilityWrapper("invite"))
87+
h.Before("project > /api/project/{project_id}/invites/{invite_id} > Delete project invitation > 204 > application/json", capabilityWrapper("invite"))
88+
8389
h.Before("integration > /api/project/{project_id}/integrations > get all integrations > 200 > application/json", capabilityWrapper("integration"))
8490
h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Get Integration > 200 > application/json", capabilityWrapper("integration"))
8591
h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Update Integration > 204 > application/json", capabilityWrapper("integration"))

api-docs.yml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,87 @@ definitions:
147147
type: string
148148
enum: [owner, manager, task_runner, guest]
149149

150+
ProjectInvite:
151+
type: object
152+
properties:
153+
id:
154+
type: integer
155+
minimum: 1
156+
project_id:
157+
type: integer
158+
minimum: 1
159+
user_id:
160+
type: integer
161+
minimum: 1
162+
description: User ID for user-based invites (optional)
163+
email:
164+
type: string
165+
format: email
166+
description: Email address for email-based invites (optional)
167+
role:
168+
type: string
169+
enum: [owner, manager, task_runner, guest]
170+
example: manager
171+
status:
172+
type: string
173+
enum: [pending, accepted, declined, expired]
174+
example: pending
175+
inviter_user_id:
176+
type: integer
177+
minimum: 1
178+
description: ID of the user who created the invite
179+
created:
180+
type: string
181+
format: date-time
182+
description: When the invite was created
183+
expires_at:
184+
type: string
185+
format: date-time
186+
description: When the invite expires (optional)
187+
accepted_at:
188+
type: string
189+
format: date-time
190+
description: When the invite was accepted (optional)
191+
inviter_user:
192+
$ref: "#/definitions/User"
193+
description: Details of the user who created the invite
194+
user:
195+
$ref: "#/definitions/User"
196+
description: Details of the invited user (for user-based invites)
197+
198+
ProjectInviteRequest:
199+
type: object
200+
properties:
201+
# user_id:
202+
# type: integer
203+
# minimum: 1
204+
# description: User ID to invite (use either user_id or email, not both)
205+
email:
206+
type: string
207+
format: email
208+
description: Email address to invite (use either user_id or email, not both)
209+
x-example: [email protected]
210+
role:
211+
type: string
212+
enum: [owner, manager, task_runner, guest]
213+
example: manager
214+
expires_at:
215+
type: string
216+
format: date-time
217+
description: When the invite should expire (optional, defaults to 7 days)
218+
required:
219+
- role
220+
221+
AcceptInviteRequest:
222+
type: object
223+
properties:
224+
token:
225+
type: string
226+
description: The invitation token
227+
x-example: "a1b2c3d4e5f6..."
228+
required:
229+
- token
230+
150231
ProjectBackup:
151232
type: object
152233
example: {"meta":{"name":"homelab","alert":true,"alert_chat":"Test","max_parallel_tasks":0,"type":null},"templates":[{"inventory":"Build","repository":"Demo","environment":"Empty","name":"Build","playbook":"build.yml","arguments":"[]","allow_override_args_in_task":false,"description":"Build Job","vault_key":null,"type":"build","start_version":"1.0.0","build_template":null,"view":"Build","autorun":false,"survey_vars":[],"suppress_success_alerts":false,"cron":"* * * * *"}],"repositories":[{"name":"Demo","git_url":"https://github.com/semaphoreui/semaphore-demo.git","git_branch":"main","ssh_key":"None"}],"keys":[{"name":"None","type":"none"},{"name":"Vault Password","type":"login_password"}],"views":[{"title":"Build","position":0}],"inventories":[{"name":"Build","inventory":"","ssh_key":"None","become_key":"None","type":"static"},{"name":"Dev","inventory":"","ssh_key":"None","become_key":"None","type":"file"},{"name":"Prod","inventory":"","ssh_key":"None","become_key":"None","type":"file"}],"environments":[{"name":"Empty","password":null,"json":"{}","env":null}]}
@@ -1150,6 +1231,13 @@ parameters:
11501231
type: integer
11511232
required: true
11521233
x-example: 13
1234+
invite_id:
1235+
name: invite_id
1236+
description: Invite ID
1237+
in: path
1238+
type: integer
1239+
required: true
1240+
x-example: 14
11531241

11541242
paths:
11551243
/debug/gc:
@@ -1654,6 +1742,120 @@ paths:
16541742
204:
16551743
description: User updated
16561744

1745+
# Invite management
1746+
/project/{project_id}/invites:
1747+
parameters:
1748+
- $ref: "#/parameters/project_id"
1749+
get:
1750+
tags:
1751+
- project
1752+
summary: Get invitations for project
1753+
parameters:
1754+
- name: sort
1755+
in: query
1756+
required: false
1757+
type: string
1758+
enum: [created, status, role]
1759+
description: sorting field
1760+
x-example: created
1761+
- name: order
1762+
in: query
1763+
required: false
1764+
type: string
1765+
enum: [asc, desc]
1766+
description: ordering manner
1767+
x-example: desc
1768+
responses:
1769+
200:
1770+
description: Project invitations
1771+
schema:
1772+
type: array
1773+
items:
1774+
$ref: "#/definitions/ProjectInvite"
1775+
post:
1776+
tags:
1777+
- project
1778+
summary: Create project invitation
1779+
parameters:
1780+
- name: Invite
1781+
in: body
1782+
required: true
1783+
schema:
1784+
$ref: "#/definitions/ProjectInviteRequest"
1785+
responses:
1786+
201:
1787+
description: Invitation created
1788+
schema:
1789+
$ref: "#/definitions/ProjectInvite"
1790+
400:
1791+
description: Bad request (invalid role, missing user_id/email, or both provided)
1792+
409:
1793+
description: User already a member or invitation already exists
1794+
1795+
/project/{project_id}/invites/{invite_id}:
1796+
parameters:
1797+
- $ref: "#/parameters/project_id"
1798+
- $ref: "#/parameters/invite_id"
1799+
get:
1800+
tags:
1801+
- project
1802+
summary: Get specific project invitation
1803+
responses:
1804+
200:
1805+
description: Project invitation
1806+
schema:
1807+
$ref: "#/definitions/ProjectInvite"
1808+
404:
1809+
description: Invitation not found
1810+
# put:
1811+
# tags:
1812+
# - project
1813+
# summary: Update project invitation status
1814+
# parameters:
1815+
# - name: Invite Update
1816+
# in: body
1817+
# required: true
1818+
# schema:
1819+
# type: object
1820+
# properties:
1821+
# status:
1822+
# type: string
1823+
# enum: [pending, declined, expired]
1824+
# example: declined
1825+
# responses:
1826+
# 204:
1827+
# description: Invitation updated
1828+
# 400:
1829+
# description: Invalid status or status transition
1830+
# delete:
1831+
# tags:
1832+
# - project
1833+
# summary: Delete project invitation
1834+
# responses:
1835+
# 204:
1836+
# description: Invitation deleted
1837+
#
1838+
# /invites/accept:
1839+
# post:
1840+
# tags:
1841+
# - project
1842+
# summary: Accept project invitation
1843+
# parameters:
1844+
# - name: Accept Invite
1845+
# in: body
1846+
# required: true
1847+
# schema:
1848+
# $ref: "#/definitions/AcceptInviteRequest"
1849+
# responses:
1850+
# 204:
1851+
# description: Invitation accepted successfully
1852+
# 400:
1853+
# description: Invalid token, invitation expired, or user already a member
1854+
# 403:
1855+
# description: Invitation not for this user
1856+
# 404:
1857+
# description: Invitation not found
1858+
16571859
/project/{project_id}/integrations:
16581860
parameters:
16591861
- $ref: "#/parameters/project_id"

0 commit comments

Comments
 (0)