Skip to content

Commit 15757f4

Browse files
Merge pull request #1962 from DonOmalVindula/feat/invite-user-ou-be
Add OU selection backend support for invited users
2 parents 2dba7cc + de88097 commit 15757f4

8 files changed

Lines changed: 468 additions & 32 deletions

File tree

backend/cmd/server/bootstrap/flows/user_onboarding/user_onboarding_flow.json

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"executor": {
2828
"name": "UserTypeResolver"
2929
},
30-
"onSuccess": "prompt_email",
30+
"onSuccess": "ou_selection",
3131
"onIncomplete": "prompt_usertype"
3232
},
3333
{
@@ -39,9 +39,14 @@
3939
"align": "center",
4040
"type": "TEXT",
4141
"id": "heading_usertype",
42-
"label": "{{ t(signup:forms.user_type.title) }}",
42+
"label": "{{ t(onboarding:forms.user_type.title) }}",
4343
"variant": "HEADING_1"
4444
},
45+
{
46+
"type": "TEXT",
47+
"id": "subtitle_usertype",
48+
"label": "{{ t(onboarding:forms.user_type.subtitle) }}"
49+
},
4550
{
4651
"type": "BLOCK",
4752
"id": "block_usertype",
@@ -50,15 +55,15 @@
5055
"type": "SELECT",
5156
"id": "usertype_input",
5257
"ref": "userType",
53-
"label": "{{ t(signup:forms.user_type.fields.user_type.label) }}",
54-
"placeholder": "{{ t(signup:forms.user_type.fields.user_type.placeholder) }}",
58+
"label": "{{ t(onboarding:forms.user_type.fields.user_type.label) }}",
59+
"placeholder": "{{ t(onboarding:forms.user_type.fields.user_type.placeholder) }}",
5560
"required": true,
5661
"options": []
5762
},
5863
{
5964
"type": "ACTION",
6065
"id": "action_usertype",
61-
"label": "{{ t(signup:forms.user_type.actions.continue.label) }}",
66+
"label": "{{ t(onboarding:forms.user_type.actions.continue.label) }}",
6267
"variant": "PRIMARY",
6368
"eventType": "SUBMIT"
6469
}
@@ -83,6 +88,74 @@
8388
}
8489
]
8590
},
91+
{
92+
"id": "ou_selection",
93+
"type": "TASK_EXECUTION",
94+
"properties": {
95+
"resolveFrom": "prompt"
96+
},
97+
"executor": {
98+
"name": "OUResolverExecutor"
99+
},
100+
"onSuccess": "prompt_email",
101+
"onIncomplete": "prompt_ou_selection"
102+
},
103+
{
104+
"id": "prompt_ou_selection",
105+
"type": "PROMPT",
106+
"meta": {
107+
"components": [
108+
{
109+
"align": "center",
110+
"type": "TEXT",
111+
"id": "heading_ou_selection",
112+
"label": "{{ t(onboarding:forms.ou_selection.title) }}",
113+
"variant": "HEADING_1"
114+
},
115+
{
116+
"type": "TEXT",
117+
"id": "subtitle_ou_selection",
118+
"label": "{{ t(onboarding:forms.ou_selection.subtitle) }}"
119+
},
120+
{
121+
"type": "BLOCK",
122+
"id": "block_ou_selection",
123+
"components": [
124+
{
125+
"type": "OU_SELECT",
126+
"id": "ou_selection_input",
127+
"ref": "ouId",
128+
"label": "{{ t(onboarding:forms.ou_selection.fields.ou.label) }}",
129+
"required": true
130+
},
131+
{
132+
"type": "ACTION",
133+
"id": "action_ou_selection",
134+
"label": "{{ t(onboarding:forms.ou_selection.actions.continue.label) }}",
135+
"variant": "PRIMARY",
136+
"eventType": "SUBMIT"
137+
}
138+
]
139+
}
140+
]
141+
},
142+
"prompts": [
143+
{
144+
"inputs": [
145+
{
146+
"ref": "ou_selection_input",
147+
"identifier": "ouId",
148+
"type": "OU_SELECT",
149+
"required": true
150+
}
151+
],
152+
"action": {
153+
"ref": "action_ou_selection",
154+
"nextNode": "ou_selection"
155+
}
156+
}
157+
]
158+
},
86159
{
87160
"id": "prompt_email",
88161
"type": "PROMPT",

backend/cmd/server/bootstrap/i18n/en-US.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@
1818
"forms.credential.title": "Set Password",
1919
"forms.credential.fields.password.label": "Password",
2020
"forms.credential.fields.password.placeholder": "Enter password",
21-
"forms.credential.actions.submit.label": "Set Password"
21+
"forms.credential.actions.submit.label": "Set Password",
22+
"forms.user_type.title": "Select a user type",
23+
"forms.user_type.subtitle": "Choose a user type (schema) for the new user.",
24+
"forms.user_type.fields.user_type.label": "User Type",
25+
"forms.user_type.fields.user_type.placeholder": "Select a user type",
26+
"forms.user_type.actions.continue.label": "Continue",
27+
"forms.ou_selection.title": "Select an organization unit",
28+
"forms.ou_selection.subtitle": "Choose which organization unit this user should belong to.",
29+
"forms.ou_selection.fields.ou.label": "Organization Unit",
30+
"forms.ou_selection.actions.continue.label": "Continue"
2231
},
2332
"signin": {
2433
"images.app_logo.alt": "Application logo",

backend/internal/flow/common/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ const (
135135
DataInviteLink = "inviteLink"
136136
// DataEmailSent is the key used to indicate that an email was sent successfully in the flow response.
137137
DataEmailSent = "emailSent"
138+
// DataRootOUID is the key used to pass the root OU ID to the frontend for the OU tree picker.
139+
DataRootOUID = "rootOuId"
138140
)
139141

140142
// DefaultHTTPTimeout defines the default timeout duration for HTTP requests.

backend/internal/flow/executor/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func Initialize(
9494
"", []common.Input{{Identifier: userAttributeUsername, Type: "string", Required: true}}, []common.Input{},
9595
flowFactory, userProvider))
9696
reg.RegisterExecutor(ExecutorNameConsent, newConsentExecutor(flowFactory, authRegistry.ConsentEnforcerService))
97-
reg.RegisterExecutor(ExecutorNameOUResolver, newOUResolverExecutor(flowFactory))
97+
reg.RegisterExecutor(ExecutorNameOUResolver, newOUResolverExecutor(flowFactory, ouService))
9898
reg.RegisterExecutor(ExecutorNameAttributeUniquenessValidator, newAttributeUniquenessValidator(
9999
flowFactory, userSchemaService, userProvider))
100100

backend/internal/flow/executor/ou_resolver_executor.go

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@
1919
package executor
2020

2121
import (
22+
"errors"
23+
2224
"github.com/asgardeo/thunder/internal/flow/common"
2325
"github.com/asgardeo/thunder/internal/flow/core"
26+
"github.com/asgardeo/thunder/internal/ou"
27+
"github.com/asgardeo/thunder/internal/system/error/serviceerror"
2428
"github.com/asgardeo/thunder/internal/system/log"
2529
"github.com/asgardeo/thunder/internal/system/security"
2630
)
@@ -29,32 +33,51 @@ import (
2933
const (
3034
// ouResolveFromCaller indicates that the caller's OU should be used when creating the user.
3135
ouResolveFromCaller = "caller"
36+
// ouResolveFromPrompt indicates that the user should be prompted to select an OU.
37+
ouResolveFromPrompt = "prompt"
3238
)
3339

3440
// ouResolverExecutor resolves the organization unit for a user being onboarded.
3541
type ouResolverExecutor struct {
3642
core.ExecutorInterface
37-
logger *log.Logger
43+
ouService ou.OrganizationUnitServiceInterface
44+
logger *log.Logger
3845
}
3946

4047
// newOUResolverExecutor creates a new OU resolver executor.
41-
func newOUResolverExecutor(flowFactory core.FlowFactoryInterface) *ouResolverExecutor {
48+
func newOUResolverExecutor(
49+
flowFactory core.FlowFactoryInterface,
50+
ouService ou.OrganizationUnitServiceInterface,
51+
) *ouResolverExecutor {
4252
logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "OUResolverExecutor"))
53+
54+
defaultInputs := []common.Input{
55+
{
56+
Ref: "ou_selection_input",
57+
Identifier: ouIDKey,
58+
Type: "OU_SELECT",
59+
Required: true,
60+
},
61+
}
62+
4363
base := flowFactory.CreateExecutor(
4464
ExecutorNameOUResolver,
4565
common.ExecutorTypeUtility,
46-
[]common.Input{},
66+
defaultInputs,
4767
[]common.Input{},
4868
)
4969
return &ouResolverExecutor{
5070
ExecutorInterface: base,
71+
ouService: ouService,
5172
logger: logger,
5273
}
5374
}
5475

5576
// Execute resolves the organization unit for the user being onboarded.
5677
// It reads the "resolveFrom" node property to determine the OU resolution strategy.
57-
// When set to "caller", it overrides the default OU with the caller's OU from the security context.
78+
// Supported strategies:
79+
// - "caller": overrides the default OU with the caller's OU from the security context.
80+
// - "prompt": checks for child OUs and prompts the user to select one if applicable.
5881
func (e *ouResolverExecutor) Execute(ctx *core.NodeContext) (*common.ExecutorResponse, error) {
5982
logger := e.logger.With(log.String(log.LoggerKeyFlowID, ctx.FlowID))
6083

@@ -72,6 +95,8 @@ func (e *ouResolverExecutor) Execute(ctx *core.NodeContext) (*common.ExecutorRes
7295
switch resolveFrom {
7396
case ouResolveFromCaller:
7497
return e.resolveFromCaller(ctx, execResp, logger)
98+
case ouResolveFromPrompt:
99+
return e.resolveFromPrompt(ctx, logger)
75100
default:
76101
logger.Error("Unsupported resolveFrom value", log.String("resolveFrom", resolveFrom))
77102
execResp.Status = common.ExecFailure
@@ -97,6 +122,84 @@ func (e *ouResolverExecutor) resolveFromCaller(ctx *core.NodeContext,
97122
return execResp, nil
98123
}
99124

125+
// resolveFromPrompt checks whether the user type's OU has child OUs and,
126+
// if so, prompts the admin to select one during the onboarding flow.
127+
func (e *ouResolverExecutor) resolveFromPrompt(ctx *core.NodeContext,
128+
logger *log.Logger) (*common.ExecutorResponse, error) {
129+
execResp := &common.ExecutorResponse{
130+
RuntimeData: make(map[string]string),
131+
AdditionalData: make(map[string]string),
132+
ForwardedData: make(map[string]interface{}),
133+
}
134+
135+
// Read the default OU set by UserTypeResolver.
136+
// The "prompt" strategy requires UserTypeResolver to have run first and set the defaultOUID.
137+
parentOUID := ctx.RuntimeData[defaultOUIDKey]
138+
if parentOUID == "" {
139+
return nil, errors.New(
140+
"no defaultOUID in runtime data; UserTypeResolver must run before OUResolver with prompt strategy",
141+
)
142+
}
143+
144+
// If the user already provided an OU selection, validate and accept it.
145+
if selectedOUID, ok := ctx.UserInputs[ouIDKey]; ok && selectedOUID != "" {
146+
// Validate that the selected OU belongs to the parent OU's subtree.
147+
isDescendant, svcErr := e.ouService.IsParent(ctx.Context, parentOUID, selectedOUID)
148+
if svcErr != nil {
149+
if svcErr.Type == serviceerror.ClientErrorType {
150+
execResp.Status = common.ExecFailure
151+
execResp.FailureReason = "The selected organization unit is not valid."
152+
return execResp, nil
153+
}
154+
155+
return nil, errors.New("failed to validate selected organization unit: " + svcErr.Error)
156+
}
157+
if !isDescendant {
158+
logger.Debug("Selected OU is not a descendant of the parent OU",
159+
log.String(ouIDKey, selectedOUID),
160+
log.String("parentOUID", parentOUID))
161+
execResp.Status = common.ExecFailure
162+
execResp.FailureReason = "The selected organization unit is not valid for the chosen user type."
163+
return execResp, nil
164+
}
165+
166+
logger.Debug("OU selected by user", log.String(ouIDKey, selectedOUID))
167+
execResp.RuntimeData[ouIDKey] = selectedOUID
168+
execResp.Status = common.ExecComplete
169+
return execResp, nil
170+
}
171+
172+
// Check if the parent OU has child OUs.
173+
children, svcErr := e.ouService.GetOrganizationUnitChildren(ctx.Context, parentOUID, 1, 0)
174+
if svcErr != nil {
175+
return nil, errors.New("failed to check child organization units: " + svcErr.Error)
176+
}
177+
178+
if children.TotalResults == 0 {
179+
logger.Debug("No child OUs found, skipping OU selection")
180+
execResp.Status = common.ExecComplete
181+
return execResp, nil
182+
}
183+
184+
// Child OUs exist — prompt the user to select one.
185+
logger.Debug("Child OUs found, requesting OU selection",
186+
log.String("parentOUID", parentOUID),
187+
log.Int("totalChildren", children.TotalResults))
188+
189+
execResp.Status = common.ExecUserInputRequired
190+
191+
inputs := e.GetDefaultInputs()
192+
if len(inputs) > 0 {
193+
input := inputs[0]
194+
execResp.Inputs = []common.Input{input}
195+
// Forward the root OU ID so the frontend knows where to start the tree picker.
196+
execResp.AdditionalData[common.DataRootOUID] = parentOUID
197+
execResp.ForwardedData[common.ForwardedDataKeyInputs] = execResp.Inputs
198+
}
199+
200+
return execResp, nil
201+
}
202+
100203
// getResolveFrom retrieves the resolveFrom strategy from the node properties.
101204
func (e *ouResolverExecutor) getResolveFrom(ctx *core.NodeContext) string {
102205
if ctx.NodeProperties == nil {

0 commit comments

Comments
 (0)