Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,19 @@ contexts:
sso_account_id: "123456789012"
sso_role_name: DeveloperRole

# SSO portal in one region, resources in another
- name: seoul-sso-123456789012-admin
region: ap-northeast-2 # where resources are queried
sso_region: us-east-1 # where the SSO/IAM Identity Center portal lives
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin
# Preferred structured format: one identity, several resource regions
- name: production
auth:
type: sso
sso_region: us-east-1
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin
resources:
default_region: ap-northeast-2
regions:
- us-east-1
- eu-west-1
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: prod-admin
order: 20
Expand Down Expand Up @@ -205,9 +210,11 @@ contexts:
| `credential` | Use shared AWS profile credentials | `profile` |
| `console_login` | Run `aws login` during `unic context setup`, then use the resulting profile-backed console credentials | `profile` |
| `assume_role` | Assume a role from a base profile | `profile`, `role_arn` |
| `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `profile`, `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name` |
| `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name`; `profile` is optional |

The preferred context format separates `auth` from `resources`. `auth.sso_region` controls IAM Identity Center login and role-credential retrieval. `resources.default_region` is selected at startup, and `resources.regions` lists additional regions available from the global `R` region picker. Switching regions reuses the current credentials and recreates only the regional AWS clients.

For `sso` contexts, `region` is the region resources are queried in. When the IAM Identity Center portal lives in a different region than your resources, set `sso_region` to the portal region — SSO login and role-credential retrieval use `sso_region`, while all resource browsing uses `region`. If `sso_region` is omitted, it defaults to `region` (backward compatible).
Legacy flat fields (`auth_type`, `profile`, `region`, `regions`, `sso_region`, and related auth fields) remain supported. A context without a region list behaves as a single-region context, and an omitted SSO region still falls back to its default resource region.

TUI startup is passive for SSO contexts: it loads the context picker without launching `aws sso login`. SSO login is prompted when you explicitly select or set up an SSO context, or when an AWS-backed workflow needs credentials.

Expand All @@ -217,6 +224,7 @@ Optional context fields:
|---|---|
| `order` | Lower values appear first in the context setup picker. Contexts without `order` fall back after ordered entries in their existing file order. |
| `sso_region` | (SSO only) Region of the IAM Identity Center portal, used for SSO login and role-credential retrieval. Defaults to `region` when unset. Use it when the SSO portal and your resources live in different regions. |
| `resources.regions` / `regions` | Additional resource regions available through the global `R` picker. The default resource region is always included automatically. |

Resolution priority:

Expand Down Expand Up @@ -343,6 +351,7 @@ checks:
| `H` | Jump to service list |
| `i` | Enter Inspector mode from the service list |
| `C` | Open context picker |
| `R` | Switch between the active context's configured resource regions |
| `S` | Open settings |
| `/` | Toggle filter mode on supported screens |
| `f` | Favorite/unfavorite the selected service or context on supported lists |
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ Two shapes exist:
- includes `sso_account_id` and `sso_role_name`
- can produce direct environment exports and SDK credentials

`region` is the resource region; `sso_region` (optional) is the IAM Identity Center portal region used for SSO login and `GetRoleCredentials`. When `sso_region` is unset it falls back to `region`, so single-region setups need no change.
Contexts can use the structured `auth` and `resources` sections to keep identity independent from resource location. `auth.sso_region` is used for SSO login and `GetRoleCredentials`; `resources.default_region` is the initial resource region, and `resources.regions` defines the regions available to the runtime picker. Switching regions reuses the credentials provider and only recreates regional SDK clients.

Legacy flat fields remain supported. `region` maps to `resources.default_region`, `regions` maps to the selectable resource regions, and `sso_region` maps to `auth.sso_region`. A missing region list produces the previous single-region behavior.

## TUI Screen Families

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ UNIC은 현재 세 가지 인증 모드를 지원한다.
- `sso_account_id`, `sso_role_name` 포함
- 직접 env export와 SDK credential 생성 가능

`region`은 리소스 조회 리전이고, `sso_region`(선택)은 SSO 로그인과 `GetRoleCredentials`에 쓰이는 IAM Identity Center 포털 리전이다. `sso_region`이 없으면 `region`으로 폴백하므로 단일 리전 설정은 변경할 필요가 없다.
컨텍스트는 구조화된 `auth`와 `resources` 섹션을 사용해 인증 정보와 리소스 위치를 분리할 수 있다. `auth.sso_region`은 SSO 로그인과 `GetRoleCredentials`에 사용하고, `resources.default_region`은 최초 리소스 리전, `resources.regions`는 런타임 리전 선택기에 노출할 리전 목록이다. 리전 전환 시 기존 credential provider를 재사용하고 리전별 SDK client만 다시 생성한다.

기존 flat 필드도 계속 지원한다. `region`은 `resources.default_region`, `regions`는 선택 가능한 리전 목록, `sso_region`은 `auth.sso_region`에 대응한다. 리전 목록이 없으면 이전과 동일한 단일 리전 동작을 유지한다.

## TUI 화면 계열

Expand Down
16 changes: 16 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const (
screenContextAdd
screenContextSSOAccountList
screenContextSSORoleList
screenRegionPicker
screenSettings
screenLoading
screenError
Expand Down Expand Up @@ -184,6 +185,8 @@ type Model struct {
contextSSOAccount awsservice.SSOAccount
contextSSORoles []awsservice.SSORole
contextSSORoleIdx int
regionIdx int
regionPrevScreen screen

// Context add wizard
addStep int // 0=auth_type select, 1+=field input, -1=confirm
Expand Down Expand Up @@ -525,6 +528,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ctxPrevScreen = m.screen
return m, m.loadContexts()
}
// Global resource-region switch. Authentication identity remains unchanged;
// only region-scoped AWS clients are recreated.
if msg.String() == "R" && m.canSwitchResourceRegion() {
m.deactivateFilter()
m.regionPrevScreen = m.screen
m.regionIdx = m.activeRegionIndex()
m.screen = screenRegionPicker
return m, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Global settings — S opens the settings screen (skip text-entry screens
// and the filter input so it never steals a typed character).
if msg.String() == "S" && !m.filterTI.Focused() && m.screen != screenSettings &&
Expand Down Expand Up @@ -556,6 +568,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateContextSSOAccountList(msg)
case screenContextSSORoleList:
return m.updateContextSSORoleList(msg)
case screenRegionPicker:
return m.updateRegionPicker(msg)
case screenSettings:
return m.updateSettings(msg)
case screenError:
Expand Down Expand Up @@ -714,6 +728,8 @@ func (m Model) View() string {
v = m.viewContextSSOAccountList()
case screenContextSSORoleList:
v = m.viewContextSSORoleList()
case screenRegionPicker:
v = m.viewRegionPicker()
case screenSettings:
v = m.viewSettings()
case screenLoading:
Expand Down
59 changes: 46 additions & 13 deletions internal/app/context_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ var fieldsByAuthType = map[string][]fieldDef{
"sso": {
{key: "name", label: "Name", required: true},
{key: "order", label: "Display Order (optional, lower first)", required: false},
{key: "region", label: "Region (resources)", required: true},
{key: "region", label: "Default Resource Region", required: true},
{key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false},
{key: "sso_region", label: "SSO Login Region (optional, defaults to Region)", required: false},
{key: "sso_start_url", label: "SSO Start URL", required: true},
{key: "sso_account_id", label: "SSO Account ID", required: true},
Expand All @@ -31,18 +32,21 @@ var fieldsByAuthType = map[string][]fieldDef{
{key: "name", label: "Name", required: true},
{key: "order", label: "Display Order (optional, lower first)", required: false},
{key: "region", label: "Region", required: true},
{key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false},
{key: "profile", label: "Profile", required: true},
},
"console_login": {
{key: "name", label: "Name", required: true},
{key: "order", label: "Display Order (optional, lower first)", required: false},
{key: "region", label: "Region", required: true},
{key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false},
{key: "profile", label: "Profile", required: true},
},
"assume_role": {
{key: "name", label: "Name", required: true},
{key: "order", label: "Display Order (optional, lower first)", required: false},
{key: "region", label: "Region", required: true},
{key: "regions", label: "Other Resource Regions (optional, comma-separated)", required: false},
{key: "profile", label: "Profile", required: true},
{key: "role_arn", label: "Role ARN", required: true},
{key: "external_id", label: "External ID (optional)", required: false},
Expand Down Expand Up @@ -128,17 +132,22 @@ func (m Model) saveContext() tea.Cmd {
return errMsg{err: err}
}
entry := config.ContextEntry{
Name: m.addValues["name"],
Order: order,
AuthType: m.addValues["auth_type"],
Region: m.addValues["region"],
Profile: m.addValues["profile"],
RoleArn: m.addValues["role_arn"],
ExternalID: m.addValues["external_id"],
SSOStartURL: m.addValues["sso_start_url"],
SSORegion: m.addValues["sso_region"],
SSOAccountID: m.addValues["sso_account_id"],
SSORoleName: m.addValues["sso_role_name"],
Name: m.addValues["name"],
Order: order,
Auth: &config.ContextAuth{
Type: m.addValues["auth_type"],
Profile: m.addValues["profile"],
RoleArn: m.addValues["role_arn"],
ExternalID: m.addValues["external_id"],
SSOStartURL: m.addValues["sso_start_url"],
SSORegion: m.addValues["sso_region"],
SSOAccountID: m.addValues["sso_account_id"],
SSORoleName: m.addValues["sso_role_name"],
},
Resources: &config.ContextResources{
DefaultRegion: m.addValues["region"],
Regions: parseRegionList(m.addValues["regions"]),
},
}
if err := config.AddContext(m.configPath, entry); err != nil {
return errMsg{err: err}
Expand All @@ -149,6 +158,17 @@ func (m Model) saveContext() tea.Cmd {
}
}

func parseRegionList(raw string) []string {
parts := strings.Split(raw, ",")
regions := make([]string, 0, len(parts))
for _, part := range parts {
if region := strings.TrimSpace(part); region != "" {
regions = append(regions, region)
}
}
return regions
}

func parseOptionalContextOrder(raw string) (int, error) {
value := strings.TrimSpace(raw)
if value == "" {
Expand Down Expand Up @@ -188,7 +208,20 @@ func (m Model) viewContextAdd() string {
// Show completed fields
b.WriteString(dimStyle.Render(fmt.Sprintf(" auth_type: %s", m.addValues["auth_type"])))
b.WriteString("\n")
for i := 0; i < len(m.addFields); i++ {
visibleLines := len(m.addFields)
if m.height > 0 {
visibleLines = max(m.height-8, 5)
}
end := len(m.addFields)
if m.addStep > 0 {
end = min(m.addFieldIdx+1, len(m.addFields))
}
start := max(end-visibleLines, 0)
if start > 0 {
b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d earlier fields", start)))
b.WriteString("\n")
}
for i := start; i < end; i++ {
field := m.addFields[i]
if m.addStep == -1 || i < m.addFieldIdx {
// Completed field
Expand Down
30 changes: 26 additions & 4 deletions internal/app/context_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,32 @@ func TestContextAddSelectsConsoleLoginFields(t *testing.T) {
if model.addValues["auth_type"] != "console_login" {
t.Fatalf("expected console_login selection, got %q", model.addValues["auth_type"])
}
if len(model.addFields) != 4 {
t.Fatalf("expected 4 fields for console_login, got %d", len(model.addFields))
if len(model.addFields) != 5 {
t.Fatalf("expected 5 fields for console_login, got %d", len(model.addFields))
}
if model.addFields[3].key != "profile" {
t.Fatalf("expected profile field, got %+v", model.addFields)
if model.addFields[3].key != "regions" || model.addFields[4].key != "profile" {
t.Fatalf("expected regions and profile fields, got %+v", model.addFields)
}
}

func TestContextAddKeepsCurrentFieldVisibleOnShortTerminal(t *testing.T) {
m := New(testConfig(), "", "dev")
m.screen = screenContextAdd
m.height = 10
m.addStep = 1
m.addFields = fieldsByAuthType["sso"]
m.addFieldIdx = len(m.addFields) - 1
m.addInput = "AdministratorAccess"
m.addValues = map[string]string{"auth_type": "sso"}
for i := 0; i < m.addFieldIdx; i++ {
m.addValues[m.addFields[i].key] = "configured"
}

view := m.viewContextAdd()
if !strings.Contains(view, "SSO Role Name") || !strings.Contains(view, "AdministratorAccess") {
t.Fatalf("expected focused field to remain visible, got %q", view)
}
if !strings.Contains(view, "earlier fields") {
t.Fatalf("expected windowing indicator on short terminal, got %q", view)
}
}
11 changes: 11 additions & 0 deletions internal/app/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func (m Model) globalHelpShortcuts() []helpShortcut {
m.screen != screenBedrockKeyConfirm {
shortcuts = append(shortcuts, helpShortcut{"S", "Open settings"})
}
if m.canSwitchResourceRegion() {
shortcuts = append(shortcuts, helpShortcut{"R", "Switch the active resource region"})
}
return shortcuts
}

Expand Down Expand Up @@ -716,6 +719,12 @@ func (m Model) currentScreenShortcuts() []helpShortcut {
{"esc", "Go back to the account list"},
{"q", "Cancel and return to the context picker"},
}
case screenRegionPicker:
return []helpShortcut{
{"↑/↓, j/k", "Move between configured resource regions"},
{"enter", "Switch to the highlighted region"},
{"q / esc", "Cancel the region switch"},
}
case screenLoading:
return []helpShortcut{
{"Wait", "The current AWS request is still loading"},
Expand Down Expand Up @@ -961,6 +970,8 @@ func (m Model) helpScreenTitle() string {
return "Select SSO Account"
case screenContextSSORoleList:
return "Select SSO Role"
case screenRegionPicker:
return "Resource Region Picker"
case screenSettings:
return "Settings"
case screenLoading:
Expand Down
5 changes: 5 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ type contextSwitchedMsg struct {
identity *awsservice.CallerIdentity
}

type regionSwitchedMsg struct {
region string
repo *awsservice.AwsRepository
}

type ssoLoginDoneMsg struct {
err error
}
Expand Down
8 changes: 8 additions & 0 deletions internal/app/screen_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
m.screen = m.ctxPrevScreen
return m, tea.ClearScreen, true

case regionSwitchedMsg:
m.cfg.Region = msg.region
m.awsRepo = msg.repo
// Region-scoped feature state may contain resources from the previous
// region, so return to the service catalog after switching.
m.screen = screenServiceList
return m, tea.ClearScreen, true

case contextSSOAccountsLoadedMsg:
m.contextSSOBase = msg.base
m.contextSSOAccounts = msg.accounts
Expand Down
Loading
Loading