@open-mercato/enterprise 0.4.6-develop-15c18897fc → 0.4.6-develop-34aa847ce6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +2 -2
  3. package/dist/modules/sso/acl.js +11 -0
  4. package/dist/modules/sso/acl.js.map +7 -0
  5. package/dist/modules/sso/api/admin-context.js +27 -0
  6. package/dist/modules/sso/api/admin-context.js.map +7 -0
  7. package/dist/modules/sso/api/callback/oidc/route.js +103 -0
  8. package/dist/modules/sso/api/callback/oidc/route.js.map +7 -0
  9. package/dist/modules/sso/api/config/[id]/activate/route.js +49 -0
  10. package/dist/modules/sso/api/config/[id]/activate/route.js.map +7 -0
  11. package/dist/modules/sso/api/config/[id]/domains/route.js +96 -0
  12. package/dist/modules/sso/api/config/[id]/domains/route.js.map +7 -0
  13. package/dist/modules/sso/api/config/[id]/route.js +103 -0
  14. package/dist/modules/sso/api/config/[id]/route.js.map +7 -0
  15. package/dist/modules/sso/api/config/[id]/test/route.js +41 -0
  16. package/dist/modules/sso/api/config/[id]/test/route.js.map +7 -0
  17. package/dist/modules/sso/api/config/route.js +83 -0
  18. package/dist/modules/sso/api/config/route.js.map +7 -0
  19. package/dist/modules/sso/api/error-handler.js +28 -0
  20. package/dist/modules/sso/api/error-handler.js.map +7 -0
  21. package/dist/modules/sso/api/hrd/route.js +52 -0
  22. package/dist/modules/sso/api/hrd/route.js.map +7 -0
  23. package/dist/modules/sso/api/initiate/route.js +66 -0
  24. package/dist/modules/sso/api/initiate/route.js.map +7 -0
  25. package/dist/modules/sso/api/scim/context.js +68 -0
  26. package/dist/modules/sso/api/scim/context.js.map +7 -0
  27. package/dist/modules/sso/api/scim/logs/route.js +65 -0
  28. package/dist/modules/sso/api/scim/logs/route.js.map +7 -0
  29. package/dist/modules/sso/api/scim/tokens/[id]/route.js +42 -0
  30. package/dist/modules/sso/api/scim/tokens/[id]/route.js.map +7 -0
  31. package/dist/modules/sso/api/scim/tokens/route.js +83 -0
  32. package/dist/modules/sso/api/scim/tokens/route.js.map +7 -0
  33. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js +42 -0
  34. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js.map +7 -0
  35. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js +94 -0
  36. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js.map +7 -0
  37. package/dist/modules/sso/api/scim/v2/Users/route.js +86 -0
  38. package/dist/modules/sso/api/scim/v2/Users/route.js.map +7 -0
  39. package/dist/modules/sso/backend/page.js +173 -0
  40. package/dist/modules/sso/backend/page.js.map +7 -0
  41. package/dist/modules/sso/backend/page.meta.js +31 -0
  42. package/dist/modules/sso/backend/page.meta.js.map +7 -0
  43. package/dist/modules/sso/backend/sso/config/[id]/page.js +749 -0
  44. package/dist/modules/sso/backend/sso/config/[id]/page.js.map +7 -0
  45. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js +19 -0
  46. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js.map +7 -0
  47. package/dist/modules/sso/backend/sso/config/new/page.js +381 -0
  48. package/dist/modules/sso/backend/sso/config/new/page.js.map +7 -0
  49. package/dist/modules/sso/backend/sso/config/new/page.meta.js +19 -0
  50. package/dist/modules/sso/backend/sso/config/new/page.meta.js.map +7 -0
  51. package/dist/modules/sso/data/entities.js +299 -0
  52. package/dist/modules/sso/data/entities.js.map +7 -0
  53. package/dist/modules/sso/data/validators.js +114 -0
  54. package/dist/modules/sso/data/validators.js.map +7 -0
  55. package/dist/modules/sso/di.js +26 -0
  56. package/dist/modules/sso/di.js.map +7 -0
  57. package/dist/modules/sso/events.js +24 -0
  58. package/dist/modules/sso/events.js.map +7 -0
  59. package/dist/modules/sso/i18n/de.json +146 -0
  60. package/dist/modules/sso/i18n/en.json +146 -0
  61. package/dist/modules/sso/i18n/es.json +146 -0
  62. package/dist/modules/sso/i18n/pl.json +146 -0
  63. package/dist/modules/sso/index.js +11 -0
  64. package/dist/modules/sso/index.js.map +7 -0
  65. package/dist/modules/sso/lib/domains.js +30 -0
  66. package/dist/modules/sso/lib/domains.js.map +7 -0
  67. package/dist/modules/sso/lib/oidc-provider.js +140 -0
  68. package/dist/modules/sso/lib/oidc-provider.js.map +7 -0
  69. package/dist/modules/sso/lib/registry.js +15 -0
  70. package/dist/modules/sso/lib/registry.js.map +7 -0
  71. package/dist/modules/sso/lib/scim-filter.js +43 -0
  72. package/dist/modules/sso/lib/scim-filter.js.map +7 -0
  73. package/dist/modules/sso/lib/scim-mapper.js +49 -0
  74. package/dist/modules/sso/lib/scim-mapper.js.map +7 -0
  75. package/dist/modules/sso/lib/scim-patch.js +63 -0
  76. package/dist/modules/sso/lib/scim-patch.js.map +7 -0
  77. package/dist/modules/sso/lib/scim-response.js +34 -0
  78. package/dist/modules/sso/lib/scim-response.js.map +7 -0
  79. package/dist/modules/sso/lib/scim-utils.js +9 -0
  80. package/dist/modules/sso/lib/scim-utils.js.map +7 -0
  81. package/dist/modules/sso/lib/state-cookie.js +67 -0
  82. package/dist/modules/sso/lib/state-cookie.js.map +7 -0
  83. package/dist/modules/sso/lib/types.js +1 -0
  84. package/dist/modules/sso/lib/types.js.map +7 -0
  85. package/dist/modules/sso/migrations/Migration20260219000000_sso.js +20 -0
  86. package/dist/modules/sso/migrations/Migration20260219000000_sso.js.map +7 -0
  87. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js +13 -0
  88. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js.map +7 -0
  89. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js +15 -0
  90. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js.map +7 -0
  91. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js +22 -0
  92. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js.map +7 -0
  93. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js +15 -0
  94. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js.map +7 -0
  95. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js +17 -0
  96. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js.map +7 -0
  97. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js +13 -0
  98. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js.map +7 -0
  99. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js +23 -0
  100. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js.map +7 -0
  101. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js +14 -0
  102. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js.map +7 -0
  103. package/dist/modules/sso/services/accountLinkingService.js +298 -0
  104. package/dist/modules/sso/services/accountLinkingService.js.map +7 -0
  105. package/dist/modules/sso/services/hrdService.js +18 -0
  106. package/dist/modules/sso/services/hrdService.js.map +7 -0
  107. package/dist/modules/sso/services/scimService.js +372 -0
  108. package/dist/modules/sso/services/scimService.js.map +7 -0
  109. package/dist/modules/sso/services/scimTokenService.js +94 -0
  110. package/dist/modules/sso/services/scimTokenService.js.map +7 -0
  111. package/dist/modules/sso/services/ssoConfigService.js +254 -0
  112. package/dist/modules/sso/services/ssoConfigService.js.map +7 -0
  113. package/dist/modules/sso/services/ssoService.js +125 -0
  114. package/dist/modules/sso/services/ssoService.js.map +7 -0
  115. package/dist/modules/sso/setup.js +47 -0
  116. package/dist/modules/sso/setup.js.map +7 -0
  117. package/dist/modules/sso/subscribers/user-deleted-cleanup.js +21 -0
  118. package/dist/modules/sso/subscribers/user-deleted-cleanup.js.map +7 -0
  119. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js +106 -0
  120. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js.map +7 -0
  121. package/dist/modules/sso/widgets/injection/login-sso/widget.js +16 -0
  122. package/dist/modules/sso/widgets/injection/login-sso/widget.js.map +7 -0
  123. package/dist/modules/sso/widgets/injection-table.js +14 -0
  124. package/dist/modules/sso/widgets/injection-table.js.map +7 -0
  125. package/package.json +5 -4
  126. package/src/index.ts +1 -1
  127. package/src/modules/sso/acl.ts +7 -0
  128. package/src/modules/sso/api/admin-context.ts +36 -0
  129. package/src/modules/sso/api/callback/oidc/route.ts +115 -0
  130. package/src/modules/sso/api/config/[id]/activate/route.ts +53 -0
  131. package/src/modules/sso/api/config/[id]/domains/route.ts +107 -0
  132. package/src/modules/sso/api/config/[id]/route.ts +114 -0
  133. package/src/modules/sso/api/config/[id]/test/route.ts +44 -0
  134. package/src/modules/sso/api/config/route.ts +88 -0
  135. package/src/modules/sso/api/error-handler.ts +36 -0
  136. package/src/modules/sso/api/hrd/route.ts +55 -0
  137. package/src/modules/sso/api/initiate/route.ts +70 -0
  138. package/src/modules/sso/api/scim/context.ts +85 -0
  139. package/src/modules/sso/api/scim/logs/route.ts +69 -0
  140. package/src/modules/sso/api/scim/tokens/[id]/route.ts +45 -0
  141. package/src/modules/sso/api/scim/tokens/route.ts +89 -0
  142. package/src/modules/sso/api/scim/v2/ServiceProviderConfig/route.ts +40 -0
  143. package/src/modules/sso/api/scim/v2/Users/[id]/route.ts +103 -0
  144. package/src/modules/sso/api/scim/v2/Users/route.ts +94 -0
  145. package/src/modules/sso/backend/page.meta.ts +29 -0
  146. package/src/modules/sso/backend/page.tsx +232 -0
  147. package/src/modules/sso/backend/sso/config/[id]/page.meta.ts +15 -0
  148. package/src/modules/sso/backend/sso/config/[id]/page.tsx +1024 -0
  149. package/src/modules/sso/backend/sso/config/new/page.meta.ts +15 -0
  150. package/src/modules/sso/backend/sso/config/new/page.tsx +463 -0
  151. package/src/modules/sso/data/entities.ts +240 -0
  152. package/src/modules/sso/data/validators.ts +140 -0
  153. package/src/modules/sso/di.ts +25 -0
  154. package/src/modules/sso/docs/entra-id-setup.md +281 -0
  155. package/src/modules/sso/docs/google-workspace-setup.md +174 -0
  156. package/src/modules/sso/docs/sso-overview.md +218 -0
  157. package/src/modules/sso/docs/sso-security-audit-2026-02-27.md +118 -0
  158. package/src/modules/sso/docs/zitadel-setup.md +195 -0
  159. package/src/modules/sso/events.ts +21 -0
  160. package/src/modules/sso/i18n/de.json +146 -0
  161. package/src/modules/sso/i18n/en.json +146 -0
  162. package/src/modules/sso/i18n/es.json +146 -0
  163. package/src/modules/sso/i18n/pl.json +146 -0
  164. package/src/modules/sso/index.ts +7 -0
  165. package/src/modules/sso/lib/domains.ts +31 -0
  166. package/src/modules/sso/lib/oidc-provider.ts +196 -0
  167. package/src/modules/sso/lib/registry.ts +13 -0
  168. package/src/modules/sso/lib/scim-filter.ts +62 -0
  169. package/src/modules/sso/lib/scim-mapper.ts +88 -0
  170. package/src/modules/sso/lib/scim-patch.ts +88 -0
  171. package/src/modules/sso/lib/scim-response.ts +40 -0
  172. package/src/modules/sso/lib/scim-utils.ts +5 -0
  173. package/src/modules/sso/lib/state-cookie.ts +79 -0
  174. package/src/modules/sso/lib/types.ts +50 -0
  175. package/src/modules/sso/migrations/.snapshot-open-mercato.json +912 -0
  176. package/src/modules/sso/migrations/Migration20260219000000_sso.ts +21 -0
  177. package/src/modules/sso/migrations/Migration20260222000000_sso_add_name.ts +13 -0
  178. package/src/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.ts +15 -0
  179. package/src/modules/sso/migrations/Migration20260223000000_scim_tables.ts +24 -0
  180. package/src/modules/sso/migrations/Migration20260224000000_sso_external_id.ts +15 -0
  181. package/src/modules/sso/migrations/Migration20260224100000_sso_role_grants.ts +18 -0
  182. package/src/modules/sso/migrations/Migration20260224200000_drop_default_role_id.ts +13 -0
  183. package/src/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.ts +25 -0
  184. package/src/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.ts +14 -0
  185. package/src/modules/sso/services/accountLinkingService.ts +386 -0
  186. package/src/modules/sso/services/hrdService.ts +22 -0
  187. package/src/modules/sso/services/scimService.ts +461 -0
  188. package/src/modules/sso/services/scimTokenService.ts +136 -0
  189. package/src/modules/sso/services/ssoConfigService.ts +337 -0
  190. package/src/modules/sso/services/ssoService.ts +167 -0
  191. package/src/modules/sso/setup.ts +56 -0
  192. package/src/modules/sso/subscribers/user-deleted-cleanup.ts +33 -0
  193. package/src/modules/sso/widgets/injection/login-sso/widget.client.tsx +130 -0
  194. package/src/modules/sso/widgets/injection/login-sso/widget.ts +16 -0
  195. package/src/modules/sso/widgets/injection-table.ts +12 -0
@@ -0,0 +1,15 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['sso.config.manage'],
4
+ pageTitle: 'Configure SSO',
5
+ pageTitleKey: 'sso.admin.create.title',
6
+ pageGroup: 'Auth',
7
+ pageGroupKey: 'settings.sections.auth',
8
+ pageOrder: 521,
9
+ pageContext: 'settings' as const,
10
+ navHidden: true,
11
+ breadcrumb: [
12
+ { label: 'Single Sign-On', labelKey: 'sso.admin.title', href: '/backend/sso' },
13
+ { label: 'Configure SSO', labelKey: 'sso.admin.create.title' },
14
+ ],
15
+ }
@@ -0,0 +1,463 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
11
+
12
+ type WizardStep = 'protocol' | 'credentials' | 'domains' | 'options' | 'review'
13
+
14
+ const STEPS: WizardStep[] = ['protocol', 'credentials', 'domains', 'options', 'review']
15
+
16
+ interface WizardState {
17
+ name: string
18
+ protocol: 'oidc'
19
+ issuer: string
20
+ clientId: string
21
+ clientSecret: string
22
+ domains: string[]
23
+ jitEnabled: boolean
24
+ autoLinkByEmail: boolean
25
+ }
26
+
27
+ const initialState: WizardState = {
28
+ name: '',
29
+ protocol: 'oidc',
30
+ issuer: '',
31
+ clientId: '',
32
+ clientSecret: '',
33
+ domains: [],
34
+ jitEnabled: true,
35
+ autoLinkByEmail: true,
36
+ }
37
+
38
+ export default function SsoConfigCreateWizard() {
39
+ const router = useRouter()
40
+ const t = useT()
41
+ const [step, setStep] = React.useState<WizardStep>('protocol')
42
+ const [state, setState] = React.useState<WizardState>(initialState)
43
+ const [domainInput, setDomainInput] = React.useState('')
44
+ const [domainError, setDomainError] = React.useState('')
45
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
46
+ const [testResult, setTestResult] = React.useState<{ ok: boolean; error?: string } | null>(null)
47
+ const [isTesting, setIsTesting] = React.useState(false)
48
+
49
+ const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
50
+ contextId: 'sso-config-create',
51
+ })
52
+ const runMutationWithContext = React.useCallback(
53
+ async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
54
+ return runMutation({
55
+ operation,
56
+ mutationPayload,
57
+ context: { retryLastMutation },
58
+ })
59
+ },
60
+ [retryLastMutation, runMutation],
61
+ )
62
+
63
+ React.useEffect(() => {
64
+ const checkExisting = async () => {
65
+ const call = await apiCall<{ items: { id: string }[] }>('/api/sso/config?pageSize=1')
66
+ if (call.ok && call.result && call.result.items.length > 0) {
67
+ flash(t('sso.admin.error.alreadyExists', 'An SSO configuration already exists for this organization'), 'error')
68
+ router.replace(`/backend/sso/config/${call.result.items[0].id}`)
69
+ }
70
+ }
71
+ checkExisting()
72
+ }, [router, t])
73
+
74
+ const currentStepIndex = STEPS.indexOf(step)
75
+
76
+ const callbackUrl = typeof window !== 'undefined'
77
+ ? `${window.location.origin}/api/sso/callback/oidc`
78
+ : '/api/sso/callback/oidc'
79
+
80
+ const goNext = () => {
81
+ const nextIndex = currentStepIndex + 1
82
+ if (nextIndex < STEPS.length) setStep(STEPS[nextIndex])
83
+ }
84
+
85
+ const goBack = () => {
86
+ const prevIndex = currentStepIndex - 1
87
+ if (prevIndex >= 0) setStep(STEPS[prevIndex])
88
+ }
89
+
90
+ const handleAddDomain = () => {
91
+ const normalized = domainInput.trim().toLowerCase()
92
+ if (!normalized) return
93
+
94
+ const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/
95
+ if (!domainRegex.test(normalized) || !normalized.includes('.')) {
96
+ setDomainError(t('sso.admin.wizard.domain.invalid', 'Invalid domain format'))
97
+ return
98
+ }
99
+
100
+ if (state.domains.includes(normalized)) {
101
+ setDomainError(t('sso.admin.wizard.domain.duplicate', 'Domain already added'))
102
+ return
103
+ }
104
+
105
+ if (state.domains.length >= 20) {
106
+ setDomainError(t('sso.admin.wizard.domain.limit', 'Maximum 20 domains per configuration'))
107
+ return
108
+ }
109
+
110
+ setState((prev) => ({ ...prev, domains: [...prev.domains, normalized] }))
111
+ setDomainInput('')
112
+ setDomainError('')
113
+ }
114
+
115
+ const handleRemoveDomain = (domain: string) => {
116
+ setState((prev) => ({ ...prev, domains: prev.domains.filter((d) => d !== domain) }))
117
+ }
118
+
119
+ const handleSubmit = async () => {
120
+ setIsSubmitting(true)
121
+ try {
122
+ const payload = {
123
+ name: state.name,
124
+ protocol: state.protocol,
125
+ issuer: state.issuer,
126
+ clientId: state.clientId,
127
+ clientSecret: state.clientSecret,
128
+ allowedDomains: state.domains,
129
+ jitEnabled: state.jitEnabled,
130
+ autoLinkByEmail: state.autoLinkByEmail,
131
+ }
132
+ const call = await runMutationWithContext(
133
+ () => apiCallOrThrow<{ id: string }>(
134
+ '/api/sso/config',
135
+ {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify(payload),
139
+ },
140
+ { errorMessage: t('sso.admin.error.createFailed', 'Failed to create SSO configuration') },
141
+ ),
142
+ payload,
143
+ )
144
+ flash(t('sso.admin.created', 'SSO configuration created'), 'success')
145
+ router.push(`/backend/sso/config/${call.result?.id}?created=1`)
146
+ } catch {
147
+ // apiCallOrThrow handles the error
148
+ } finally {
149
+ setIsSubmitting(false)
150
+ }
151
+ }
152
+
153
+ const handleTestConnection = async () => {
154
+ setIsTesting(true)
155
+ setTestResult(null)
156
+ try {
157
+ // Raw fetch is intentional: this is a pre-save OIDC discovery probe against an
158
+ // external IdP URL, not an internal API call, so apiCall is not applicable here.
159
+ const response = await fetch(state.issuer + '/.well-known/openid-configuration')
160
+ if (response.ok) {
161
+ setTestResult({ ok: true })
162
+ flash(t('sso.admin.test.success', 'Discovery successful — issuer is reachable'), 'success')
163
+ } else {
164
+ setTestResult({ ok: false, error: `HTTP ${response.status}` })
165
+ flash(t('sso.admin.test.failed', 'Discovery failed'), 'error')
166
+ }
167
+ } catch (err) {
168
+ setTestResult({ ok: false, error: String(err) })
169
+ flash(t('sso.admin.test.failed', 'Discovery failed — issuer is not reachable'), 'error')
170
+ } finally {
171
+ setIsTesting(false)
172
+ }
173
+ }
174
+
175
+ const canProceed = (): boolean => {
176
+ switch (step) {
177
+ case 'protocol': return true
178
+ case 'credentials': return !!(state.name && state.issuer && state.clientId && state.clientSecret)
179
+ case 'domains': return true
180
+ case 'options': return true
181
+ case 'review': return !isSubmitting
182
+ default: return false
183
+ }
184
+ }
185
+
186
+ return (
187
+ <Page>
188
+ <PageBody>
189
+ <div className="max-w-2xl mx-auto">
190
+ {/* Step indicator */}
191
+ <div className="flex items-center gap-2 mb-8">
192
+ {STEPS.map((s, i) => (
193
+ <React.Fragment key={s}>
194
+ <div
195
+ className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
196
+ i <= currentStepIndex
197
+ ? 'bg-primary text-primary-foreground'
198
+ : 'bg-muted text-muted-foreground'
199
+ }`}
200
+ >
201
+ {i + 1}
202
+ </div>
203
+ {i < STEPS.length - 1 && (
204
+ <div className={`flex-1 h-0.5 ${i < currentStepIndex ? 'bg-primary' : 'bg-muted'}`} />
205
+ )}
206
+ </React.Fragment>
207
+ ))}
208
+ </div>
209
+
210
+ {/* Step content */}
211
+ {step === 'protocol' && (
212
+ <div>
213
+ <h2 className="text-lg font-semibold mb-4">{t('sso.admin.wizard.protocol.title', 'Select Protocol')}</h2>
214
+ <div className="space-y-3">
215
+ <label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer bg-accent/50 border-primary">
216
+ <input type="radio" name="protocol" value="oidc" checked readOnly className="accent-primary" />
217
+ <div>
218
+ <div className="font-medium">OpenID Connect (OIDC)</div>
219
+ <div className="text-sm text-muted-foreground">
220
+ {t('sso.admin.wizard.protocol.oidcDesc', 'Works with Zitadel, Microsoft Entra ID, Google Workspace, Okta, and more')}
221
+ </div>
222
+ </div>
223
+ </label>
224
+ <div className="flex items-center gap-3 p-4 border rounded-lg opacity-50 cursor-not-allowed bg-muted/30">
225
+ <input type="radio" name="protocol" value="saml" disabled className="accent-primary" />
226
+ <div>
227
+ <div className="font-medium">SAML 2.0</div>
228
+ <div className="text-sm text-muted-foreground">
229
+ {t('sso.admin.wizard.protocol.samlDesc', 'Coming soon')}
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ )}
236
+
237
+ {step === 'credentials' && (
238
+ <div>
239
+ <h2 className="text-lg font-semibold mb-4">{t('sso.admin.wizard.credentials.title', 'OIDC Credentials')}</h2>
240
+ <div className="space-y-4">
241
+ <div>
242
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.name', 'Configuration Name')}</label>
243
+ <input
244
+ type="text"
245
+ className="w-full rounded-md border px-3 py-2 text-sm"
246
+ placeholder={t('sso.admin.wizard.credentials.namePlaceholder', 'e.g., Zitadel Production')}
247
+ value={state.name}
248
+ onChange={(e) => setState((prev) => ({ ...prev, name: e.target.value }))}
249
+ />
250
+ </div>
251
+ <div>
252
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.issuer', 'Issuer URL')}</label>
253
+ <input
254
+ type="url"
255
+ className="w-full rounded-md border px-3 py-2 text-sm"
256
+ placeholder="https://your-idp.example.com"
257
+ value={state.issuer}
258
+ onChange={(e) => setState((prev) => ({ ...prev, issuer: e.target.value }))}
259
+ />
260
+ </div>
261
+ <div>
262
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.clientId', 'Client ID')}</label>
263
+ <input
264
+ type="text"
265
+ className="w-full rounded-md border px-3 py-2 text-sm"
266
+ value={state.clientId}
267
+ onChange={(e) => setState((prev) => ({ ...prev, clientId: e.target.value }))}
268
+ />
269
+ </div>
270
+ <div>
271
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.clientSecret', 'Client Secret')}</label>
272
+ <input
273
+ type="password"
274
+ className="w-full rounded-md border px-3 py-2 text-sm"
275
+ value={state.clientSecret}
276
+ onChange={(e) => setState((prev) => ({ ...prev, clientSecret: e.target.value }))}
277
+ />
278
+ </div>
279
+ <div className="rounded-md bg-muted/50 p-3">
280
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.wizard.credentials.callbackUrl', 'Redirect URI (copy to your IdP)')}</label>
281
+ <div className="flex items-center gap-2">
282
+ <code className="flex-1 text-sm bg-background p-2 rounded border font-mono break-all">{callbackUrl}</code>
283
+ <Button
284
+ variant="outline"
285
+ size="sm"
286
+ onClick={() => {
287
+ navigator.clipboard.writeText(callbackUrl)
288
+ flash(t('common.copied', 'Copied to clipboard'), 'success')
289
+ }}
290
+ >
291
+ {t('common.copy', 'Copy')}
292
+ </Button>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ )}
298
+
299
+ {step === 'domains' && (
300
+ <div>
301
+ <h2 className="text-lg font-semibold mb-4">{t('sso.admin.wizard.domains.title', 'Allowed Email Domains')}</h2>
302
+ <p className="text-sm text-muted-foreground mb-4">
303
+ {t('sso.admin.wizard.domains.description', 'Users with email addresses matching these domains will be redirected to your SSO provider.')}
304
+ </p>
305
+ <div className="flex items-center gap-2 mb-4">
306
+ <input
307
+ type="text"
308
+ className="flex-1 rounded-md border px-3 py-2 text-sm"
309
+ placeholder={t('sso.admin.wizard.domains.placeholder', 'example.com')}
310
+ value={domainInput}
311
+ onChange={(e) => { setDomainInput(e.target.value); setDomainError('') }}
312
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDomain() } }}
313
+ />
314
+ <Button variant="outline" onClick={handleAddDomain}>
315
+ {t('common.add', 'Add')}
316
+ </Button>
317
+ </div>
318
+ {domainError && <p className="text-sm text-destructive mb-2">{domainError}</p>}
319
+ {state.domains.length > 0 && (
320
+ <div className="space-y-2">
321
+ {state.domains.map((domain) => (
322
+ <div key={domain} className="flex items-center justify-between p-2 border rounded-md">
323
+ <code className="text-sm font-mono">{domain}</code>
324
+ <Button variant="ghost" size="sm" onClick={() => handleRemoveDomain(domain)}>
325
+ {t('common.remove', 'Remove')}
326
+ </Button>
327
+ </div>
328
+ ))}
329
+ </div>
330
+ )}
331
+ </div>
332
+ )}
333
+
334
+ {step === 'options' && (
335
+ <div>
336
+ <h2 className="text-lg font-semibold mb-4">{t('sso.admin.wizard.options.title', 'Options')}</h2>
337
+ <div className="space-y-4">
338
+ <label className="flex items-center gap-3">
339
+ <input
340
+ type="checkbox"
341
+ checked={state.jitEnabled}
342
+ onChange={(e) => setState((prev) => ({ ...prev, jitEnabled: e.target.checked }))}
343
+ className="accent-primary"
344
+ />
345
+ <div>
346
+ <div className="font-medium text-sm">{t('sso.admin.field.jitEnabled', 'Just-in-Time Provisioning')}</div>
347
+ <div className="text-xs text-muted-foreground">
348
+ {t('sso.admin.field.jitEnabledDesc', 'Automatically create user accounts on first SSO login')}
349
+ </div>
350
+ </div>
351
+ </label>
352
+ <label className="flex items-center gap-3">
353
+ <input
354
+ type="checkbox"
355
+ checked={state.autoLinkByEmail}
356
+ onChange={(e) => setState((prev) => ({ ...prev, autoLinkByEmail: e.target.checked }))}
357
+ className="accent-primary"
358
+ />
359
+ <div>
360
+ <div className="font-medium text-sm">{t('sso.admin.field.autoLinkByEmail', 'Auto-link by Email')}</div>
361
+ <div className="text-xs text-muted-foreground">
362
+ {t('sso.admin.field.autoLinkByEmailDesc', 'Automatically link existing users by matching email address')}
363
+ </div>
364
+ </div>
365
+ </label>
366
+ </div>
367
+ </div>
368
+ )}
369
+
370
+ {step === 'review' && (
371
+ <div>
372
+ <h2 className="text-lg font-semibold mb-4">{t('sso.admin.wizard.review.title', 'Review & Save')}</h2>
373
+ <div className="space-y-4">
374
+ <div className="border rounded-lg divide-y">
375
+ <div className="flex justify-between p-3">
376
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.name', 'Name')}</span>
377
+ <span className="text-sm font-medium">{state.name}</span>
378
+ </div>
379
+ <div className="flex justify-between p-3">
380
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.protocol', 'Protocol')}</span>
381
+ <span className="text-sm font-medium">{state.protocol.toUpperCase()}</span>
382
+ </div>
383
+ <div className="flex justify-between p-3">
384
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.issuer', 'Issuer')}</span>
385
+ <span className="text-sm font-medium break-all">{state.issuer}</span>
386
+ </div>
387
+ <div className="flex justify-between p-3">
388
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.clientId', 'Client ID')}</span>
389
+ <span className="text-sm font-medium">{state.clientId}</span>
390
+ </div>
391
+ <div className="flex justify-between p-3">
392
+ <span className="text-sm text-muted-foreground">{t('sso.admin.wizard.domains.title', 'Domains')}</span>
393
+ <span className="text-sm font-medium">{state.domains.join(', ') || '—'}</span>
394
+ </div>
395
+ <div className="flex justify-between p-3">
396
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.jitEnabled', 'JIT Provisioning')}</span>
397
+ <span className="text-sm font-medium">{state.jitEnabled ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}</span>
398
+ </div>
399
+ <div className="flex justify-between p-3">
400
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.autoLinkByEmail', 'Auto-link')}</span>
401
+ <span className="text-sm font-medium">{state.autoLinkByEmail ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}</span>
402
+ </div>
403
+ </div>
404
+
405
+ {/* Test connection before saving */}
406
+ <div className="flex items-center gap-3 p-4 border rounded-lg bg-muted/30">
407
+ <Button
408
+ variant="outline"
409
+ onClick={handleTestConnection}
410
+ disabled={isTesting}
411
+ >
412
+ {isTesting
413
+ ? t('sso.admin.wizard.review.testing', 'Testing...')
414
+ : t('sso.admin.action.test', 'Verify Discovery')}
415
+ </Button>
416
+ {testResult && (
417
+ <span className={`text-sm ${testResult.ok ? 'text-green-600' : 'text-destructive'}`}>
418
+ {testResult.ok
419
+ ? t('sso.admin.test.success', 'Discovery successful')
420
+ : testResult.error || t('sso.admin.test.failed', 'Discovery failed')}
421
+ </span>
422
+ )}
423
+ </div>
424
+
425
+ <p className="text-sm text-muted-foreground">
426
+ {t('sso.admin.wizard.review.note', 'The configuration will be created as inactive. You can activate it from the detail page after verifying everything is correct.')}
427
+ </p>
428
+ </div>
429
+ </div>
430
+ )}
431
+
432
+ {/* Navigation */}
433
+ <div className="flex justify-between mt-8 pt-4 border-t">
434
+ <div>
435
+ {currentStepIndex > 0 ? (
436
+ <Button variant="outline" onClick={goBack}>
437
+ {t('common.back', 'Back')}
438
+ </Button>
439
+ ) : (
440
+ <Button variant="outline" onClick={() => router.push('/backend/sso')}>
441
+ {t('common.cancel', 'Cancel')}
442
+ </Button>
443
+ )}
444
+ </div>
445
+ <div>
446
+ {step === 'review' ? (
447
+ <Button onClick={handleSubmit} disabled={!canProceed()}>
448
+ {isSubmitting
449
+ ? t('common.saving', 'Saving...')
450
+ : t('sso.admin.wizard.review.save', 'Create Configuration')}
451
+ </Button>
452
+ ) : (
453
+ <Button onClick={goNext} disabled={!canProceed()}>
454
+ {t('common.next', 'Next')}
455
+ </Button>
456
+ )}
457
+ </div>
458
+ </div>
459
+ </div>
460
+ </PageBody>
461
+ </Page>
462
+ )
463
+ }