Skip to content

Commit 396ee58

Browse files
committed
feat(init): list sub-orgs with counts in org picker (ENG-4673)
- Add GET /v1/organization/accessible-with-sub-orgs API call with ErrEndpointNotSupported sentinel for older self-hosted instances - Use flat GET /v1/organization list as source of truth; enrich with sub-org counts best-effort (failures fall back silently with debug log) - Show two-level picker: root org with sub-org count, then sub-org selector - Extract api.Organization named type; apply same sub-org picker to login flow - Update E2E login test to handle optional sub-org prompt step
1 parent ef5c77e commit 396ee58

6 files changed

Lines changed: 162 additions & 61 deletions

File tree

packages/api/api.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323
operationCallLogin2V3 = "CallLogin2V3"
2424
operationCallLoginV3 = "CallLoginV3"
2525
operationCallGetAllOrganizations = "CallGetAllOrganizations"
26+
operationCallGetAllOrganizationsWithSubOrgs = "CallGetAllOrganizationsWithSubOrgs"
2627
operationCallSelectOrganization = "CallSelectOrganization"
2728
operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo"
2829
operationCallGetProjectById = "CallGetProjectById"
@@ -63,6 +64,7 @@ const (
6364
)
6465

6566
var ErrNotFound = errors.New("resource not found")
67+
var ErrEndpointNotSupported = errors.New("endpoint not supported by this Infisical instance")
6668

6769
func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncryptedWorkspaceKeyRequest) (GetEncryptedWorkspaceKeyResponse, error) {
6870
endpoint := fmt.Sprintf("%v/v2/workspace/%v/encrypted-key", config.INFISICAL_URL, request.WorkspaceId)
@@ -217,6 +219,31 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo
217219
return loginTwoV2Response, nil
218220
}
219221

222+
func CallGetAllOrganizationsWithSubOrgs(httpClient *resty.Client) (GetOrganizationsWithSubOrgsResponse, error) {
223+
var resp GetOrganizationsWithSubOrgsResponse
224+
response, err := httpClient.
225+
R().
226+
SetResult(&resp).
227+
SetHeader("User-Agent", USER_AGENT).
228+
Get(fmt.Sprintf("%v/v1/organization/accessible-with-sub-orgs", config.INFISICAL_URL))
229+
230+
if err != nil {
231+
return GetOrganizationsWithSubOrgsResponse{}, NewGenericRequestError(operationCallGetAllOrganizationsWithSubOrgs, err)
232+
}
233+
234+
// 404 means this Infisical instance doesn't support the endpoint yet (older self-hosted).
235+
// Check before the generic IsError() so we can return the sentinel instead of an API error.
236+
if response.StatusCode() == http.StatusNotFound {
237+
return GetOrganizationsWithSubOrgsResponse{}, ErrEndpointNotSupported
238+
}
239+
240+
if response.IsError() {
241+
return GetOrganizationsWithSubOrgsResponse{}, NewAPIErrorWithResponse(operationCallGetAllOrganizationsWithSubOrgs, response, nil)
242+
}
243+
244+
return resp, nil
245+
}
246+
220247
func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) {
221248
var orgResponse GetOrganizationsResponse
222249
response, err := httpClient.

packages/api/model.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,30 @@ type GetCertificateProfileResponse struct {
151151
CertificateProfile CertificateProfile `json:"certificateProfile"`
152152
}
153153

154+
type Organization struct {
155+
ID string `json:"id"`
156+
Name string `json:"name"`
157+
}
158+
154159
type GetOrganizationsResponse struct {
155-
Organizations []struct {
156-
ID string `json:"id"`
157-
Name string `json:"name"`
158-
} `json:"organizations"`
160+
Organizations []Organization `json:"organizations"`
161+
}
162+
163+
type SubOrganization struct {
164+
ID string `json:"id"`
165+
Name string `json:"name"`
166+
Slug string `json:"slug"`
167+
}
168+
169+
type OrganizationWithSubOrgs struct {
170+
ID string `json:"id"`
171+
Name string `json:"name"`
172+
Slug string `json:"slug"`
173+
SubOrganizations []SubOrganization `json:"subOrganizations"`
174+
}
175+
176+
type GetOrganizationsWithSubOrgsResponse struct {
177+
Organizations []OrganizationWithSubOrgs `json:"organizations"`
159178
}
160179

161180
type SelectOrganizationResponse struct {

packages/cmd/init.go

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"fmt"
99

1010
"github.com/Infisical/infisical-merge/packages/api"
11+
"github.com/Infisical/infisical-merge/packages/config"
1112
"github.com/Infisical/infisical-merge/packages/models"
1213
"github.com/Infisical/infisical-merge/packages/util"
14+
"github.com/go-resty/resty/v2"
1315
"github.com/manifoldco/promptui"
1416
"github.com/posthog/posthog-go"
1517
"github.com/rs/zerolog/log"
@@ -55,29 +57,12 @@ var initCmd = &cobra.Command{
5557
}
5658
httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken)
5759

58-
organizationResponse, err := api.CallGetAllOrganizations(httpClient)
60+
selectedOrgID, err := pickOrganization(httpClient, "Which Infisical organization would you like to select a project from?")
5961
if err != nil {
60-
util.HandleError(err, "Unable to pull organizations that belong to you")
61-
}
62-
63-
organizations := organizationResponse.Organizations
64-
65-
organizationNames := util.GetOrganizationsNameList(organizationResponse)
66-
67-
prompt := promptui.Select{
68-
Label: "Which Infisical organization would you like to select a project from?",
69-
Items: organizationNames,
70-
Size: 7,
71-
}
72-
73-
index, _, err := prompt.Run()
74-
if err != nil {
75-
util.HandleError(err)
62+
util.HandleError(err, "Unable to select organization")
7663
}
7764

78-
selectedOrganization := organizations[index]
79-
80-
tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID})
65+
tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
8166
if tokenResponse.MfaEnabled {
8267
i := 1
8368
for i < 6 {
@@ -113,7 +98,7 @@ var initCmd = &cobra.Command{
11398
i++
11499
} else {
115100
httpClient.SetAuthToken(verifyMFAresponse.Token)
116-
tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID})
101+
tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrgID})
117102
break
118103
}
119104
}
@@ -137,15 +122,15 @@ var initCmd = &cobra.Command{
137122
util.HandleError(err, "Unable to pull projects that belong to you")
138123
}
139124

140-
filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrganization.ID)
125+
filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrgID)
141126

142-
prompt = promptui.Select{
127+
prompt := promptui.Select{
143128
Label: "Which of your Infisical projects would you like to connect this project to?",
144129
Items: workspaceNames,
145130
Size: 7,
146131
}
147132

148-
index, _, err = prompt.Run()
133+
index, _, err := prompt.Run()
149134
if err != nil {
150135
util.HandleError(err)
151136
}
@@ -164,6 +149,63 @@ func init() {
164149
RootCmd.AddCommand(initCmd)
165150
}
166151

152+
// pickOrganization prompts the user to select an organization (and optionally a sub-org).
153+
// GET /v1/organization is always used as the source of truth for the org list.
154+
// GET /v1/organization/accessible-with-sub-orgs is used only to enrich entries with sub-org
155+
// counts and the second-level picker — if it fails or omits an org, that org still appears.
156+
func pickOrganization(httpClient *resty.Client, label string) (string, error) {
157+
orgResp, err := api.CallGetAllOrganizations(httpClient)
158+
if err != nil {
159+
return "", err
160+
}
161+
orgs := orgResp.Organizations
162+
if len(orgs) == 0 {
163+
util.PrintErrorMessageAndExit(fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", config.INFISICAL_URL))
164+
}
165+
166+
// Best-effort: enrich with sub-org data. Ignore any error — the flat list is enough.
167+
subOrgMap := map[string][]api.SubOrganization{}
168+
if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err != nil {
169+
log.Debug().Err(err).Msg("Failed to fetch sub-org data; falling back to flat org list")
170+
} else {
171+
for _, o := range subOrgsResp.Organizations {
172+
subOrgMap[o.ID] = o.SubOrganizations
173+
}
174+
}
175+
176+
labels := util.BuildOrgRootLabels(orgs, subOrgMap)
177+
178+
prompt1 := promptui.Select{
179+
Label: label,
180+
Items: labels,
181+
Size: 7,
182+
}
183+
index, _, err := prompt1.Run()
184+
if err != nil {
185+
return "", err
186+
}
187+
188+
selectedOrg := orgs[index]
189+
subs := subOrgMap[selectedOrg.ID]
190+
191+
if len(subs) == 0 {
192+
return selectedOrg.ID, nil
193+
}
194+
195+
// Second prompt: root org itself or one of its sub-orgs
196+
subItems, subLabels := util.BuildSubOrgPickerItems(selectedOrg.ID, selectedOrg.Name, subs)
197+
prompt2 := promptui.Select{
198+
Label: fmt.Sprintf("Which scope within %s?", selectedOrg.Name),
199+
Items: subLabels,
200+
Size: 7,
201+
}
202+
subIndex, _, err := prompt2.Run()
203+
if err != nil {
204+
return "", err
205+
}
206+
return subItems[subIndex].ID, nil
207+
}
208+
167209
func writeWorkspaceFile(selectedWorkspace models.Workspace) error {
168210
workspaceFileToSave := models.WorkspaceConfigFile{
169211
WorkspaceId: selectedWorkspace.ID,

packages/cmd/login.go

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -713,27 +713,10 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string, email string, organizatio
713713
selectedOrganizationId := organizationId
714714

715715
if selectedOrganizationId == "" {
716-
organizationResponse, err := api.CallGetAllOrganizations(httpClient)
717-
716+
selectedOrganizationId, err = pickOrganization(httpClient, "Which Infisical organization would you like to log into?")
718717
if err != nil {
719-
util.HandleError(err, "Unable to pull organizations that belong to you")
720-
}
721-
722-
organizations := organizationResponse.Organizations
723-
724-
organizationNames := util.GetOrganizationsNameList(organizationResponse)
725-
726-
prompt := promptui.Select{
727-
Label: "Which Infisical organization would you like to log into?",
728-
Items: organizationNames,
718+
util.HandleError(err, "Unable to select organization")
729719
}
730-
731-
index, _, err := prompt.Run()
732-
if err != nil {
733-
util.HandleError(err)
734-
}
735-
736-
selectedOrganizationId = organizations[index].ID
737720
}
738721

739722
selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganizationId})

packages/util/init.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,45 @@ import (
88
"github.com/Infisical/infisical-merge/packages/models"
99
)
1010

11-
func GetOrganizationsNameList(organizationResponse api.GetOrganizationsResponse) []string {
12-
organizations := organizationResponse.Organizations
11+
// OrgPickerItem pairs a display label with the org ID to pass to CallSelectOrganization.
12+
type OrgPickerItem struct {
13+
ID string
14+
Label string
15+
}
1316

14-
if len(organizations) == 0 {
15-
message := fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", config.INFISICAL_URL)
16-
PrintErrorMessageAndExit(message)
17+
// BuildOrgRootLabels returns first-prompt labels: org name with sub-org count when present.
18+
// orgs is the flat list from GET /v1/organization; subOrgMap is keyed by org ID.
19+
func BuildOrgRootLabels(orgs []api.Organization, subOrgMap map[string][]api.SubOrganization) []string {
20+
labels := make([]string, len(orgs))
21+
for i, org := range orgs {
22+
n := len(subOrgMap[org.ID])
23+
switch n {
24+
case 0:
25+
labels[i] = org.Name
26+
case 1:
27+
labels[i] = fmt.Sprintf("%s (1 sub-org)", org.Name)
28+
default:
29+
labels[i] = fmt.Sprintf("%s (%d sub-orgs)", org.Name, n)
30+
}
1731
}
32+
return labels
33+
}
1834

19-
var organizationNames []string
20-
for _, workspace := range organizations {
21-
organizationNames = append(organizationNames, workspace.Name)
22-
}
35+
// BuildSubOrgPickerItems returns items + labels for the second prompt.
36+
// The first item is always the root org itself, followed by each sub-org.
37+
func BuildSubOrgPickerItems(rootID, rootName string, subs []api.SubOrganization) ([]OrgPickerItem, []string) {
38+
items := make([]OrgPickerItem, 0, 1+len(subs))
39+
labels := make([]string, 0, 1+len(subs))
40+
41+
rootLabel := fmt.Sprintf("%s (organization)", rootName)
42+
items = append(items, OrgPickerItem{ID: rootID, Label: rootLabel})
43+
labels = append(labels, rootLabel)
2344

24-
return organizationNames
45+
for _, sub := range subs {
46+
items = append(items, OrgPickerItem{ID: sub.ID, Label: sub.Name})
47+
labels = append(labels, sub.Name)
48+
}
49+
return items, labels
2550
}
2651

2752
func GetWorkspacesInOrganization(workspaceResponse api.GetWorkSpacesResponse, orgId string) ([]models.Workspace, []string) {

test/login_test.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ func UserInitCmd() {
2727
n, err := ptmx.Read(buf)
2828
if n > 0 {
2929
terminalOut := string(buf)
30-
if strings.Contains(terminalOut, "Which Infisical organization would you like to select a project from?") && step < 0 {
30+
if strings.Contains(terminalOut, "Which Infisical organization would you like to select a project from?") && step < 0 {
3131
step += 1
3232
stepChan <- step
33-
} else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 1 {
34-
step += 1;
33+
} else if strings.Contains(terminalOut, "Which scope within") && step < 1 {
34+
step += 1
35+
stepChan <- step
36+
} else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 2 {
37+
step += 1
3538
stepChan <- step
3639
}
3740
}
@@ -44,10 +47,12 @@ func UserInitCmd() {
4447

4548
for i := range stepChan {
4649
switch i {
47-
case 0:
50+
case 0:
4851
ptmx.Write([]byte("\n"))
4952
case 1:
5053
ptmx.Write([]byte("\n"))
54+
case 2:
55+
ptmx.Write([]byte("\n"))
5156
}
5257
}
5358
}

0 commit comments

Comments
 (0)