@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,1024 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { useParams, useRouter, useSearchParams } from 'next/navigation'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
8
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
9
+ import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
11
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
14
+
15
+ type Tab = 'general' | 'domains' | 'roles' | 'scim' | 'activity'
16
+
17
+ interface SsoConfigDetail {
18
+ id: string
19
+ name: string | null
20
+ tenantId: string | null
21
+ organizationId: string
22
+ protocol: string
23
+ issuer: string | null
24
+ clientId: string | null
25
+ hasClientSecret: boolean
26
+ allowedDomains: string[]
27
+ jitEnabled: boolean
28
+ autoLinkByEmail: boolean
29
+ isActive: boolean
30
+ ssoRequired: boolean
31
+ appRoleMappings: Record<string, string>
32
+ hasActiveScimTokens: boolean
33
+ createdAt: string
34
+ updatedAt: string
35
+ }
36
+
37
+ interface SsoIdentityRow {
38
+ id: string
39
+ userId: string
40
+ idpEmail: string
41
+ idpName: string | null
42
+ provisioningMethod: string
43
+ lastLoginAt: string | null
44
+ createdAt: string
45
+ }
46
+
47
+ export default function SsoConfigDetailPage() {
48
+ const params = useParams()
49
+ const configId = (params?.slug && Array.isArray(params.slug))
50
+ ? params.slug[2]
51
+ : (Array.isArray(params?.id) ? params.id[0] : params?.id as string)
52
+ const router = useRouter()
53
+ const searchParams = useSearchParams()
54
+ const t = useT()
55
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
56
+
57
+ const [config, setConfig] = React.useState<SsoConfigDetail | null>(null)
58
+ const [isLoading, setIsLoading] = React.useState(true)
59
+ const [error, setError] = React.useState<string | null>(null)
60
+ const [activeTab, setActiveTab] = React.useState<Tab>('general')
61
+ const [showActivationBanner, setShowActivationBanner] = React.useState(searchParams?.get('created') === '1')
62
+ const [activationError, setActivationError] = React.useState<string | null>(null)
63
+ const [isActivating, setIsActivating] = React.useState(false)
64
+
65
+ const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
66
+ contextId: `sso-config:${configId ?? 'pending'}`,
67
+ })
68
+ const runMutationWithContext = React.useCallback(
69
+ async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
70
+ return runMutation({
71
+ operation,
72
+ mutationPayload,
73
+ context: { configId, retryLastMutation },
74
+ })
75
+ },
76
+ [configId, retryLastMutation, runMutation],
77
+ )
78
+
79
+ // General tab form state
80
+ const [name, setName] = React.useState('')
81
+ const [issuer, setIssuer] = React.useState('')
82
+ const [clientId, setClientId] = React.useState('')
83
+ const [newClientSecret, setNewClientSecret] = React.useState('')
84
+ const [showSecretField, setShowSecretField] = React.useState(false)
85
+ const [jitEnabled, setJitEnabled] = React.useState(true)
86
+ const [autoLinkByEmail, setAutoLinkByEmail] = React.useState(true)
87
+ const [isSaving, setIsSaving] = React.useState(false)
88
+
89
+ // Domains tab state
90
+ const [domainInput, setDomainInput] = React.useState('')
91
+ const [domainError, setDomainError] = React.useState('')
92
+
93
+ const fetchConfig = React.useCallback(async () => {
94
+ setIsLoading(true)
95
+ const call = await apiCall<SsoConfigDetail>(`/api/sso/config/${configId}`)
96
+ if (call.ok && call.result) {
97
+ const c = call.result
98
+ setConfig(c)
99
+ setName(c.name ?? '')
100
+ setIssuer(c.issuer ?? '')
101
+ setClientId(c.clientId ?? '')
102
+ setJitEnabled(c.jitEnabled)
103
+ setAutoLinkByEmail(c.autoLinkByEmail)
104
+ setError(null)
105
+ } else {
106
+ setError(t('sso.admin.error.loadFailed', 'Failed to load SSO configuration'))
107
+ }
108
+ setIsLoading(false)
109
+ }, [configId, t])
110
+
111
+ React.useEffect(() => { fetchConfig() }, [fetchConfig])
112
+
113
+ const handleSave = async () => {
114
+ setIsSaving(true)
115
+ try {
116
+ const payload: Record<string, unknown> = { name, issuer, clientId, jitEnabled, autoLinkByEmail }
117
+ if (newClientSecret) payload.clientSecret = newClientSecret
118
+
119
+ await runMutationWithContext(
120
+ () => apiCallOrThrow<SsoConfigDetail>(
121
+ `/api/sso/config/${configId}`,
122
+ {
123
+ method: 'PUT',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify(payload),
126
+ },
127
+ { errorMessage: t('sso.admin.error.saveFailed', 'Failed to save SSO configuration') },
128
+ ),
129
+ payload,
130
+ )
131
+ flash(t('sso.admin.saved', 'SSO configuration saved'), 'success')
132
+ setNewClientSecret('')
133
+ setShowSecretField(false)
134
+ fetchConfig()
135
+ } catch {
136
+ // apiCallOrThrow handles the error
137
+ } finally {
138
+ setIsSaving(false)
139
+ }
140
+ }
141
+
142
+ const handleToggleActivation = async () => {
143
+ if (!config) return
144
+ setActivationError(null)
145
+ setIsActivating(true)
146
+ try {
147
+ await runMutationWithContext(
148
+ () => apiCallOrThrow(
149
+ `/api/sso/config/${configId}/activate`,
150
+ {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({ active: !config.isActive }),
154
+ },
155
+ { errorMessage: t('sso.admin.error.activationFailed', 'Failed to update activation status') },
156
+ ),
157
+ { active: !config.isActive },
158
+ )
159
+ flash(
160
+ config.isActive
161
+ ? t('sso.admin.deactivated', 'SSO configuration deactivated')
162
+ : t('sso.admin.activated', 'SSO configuration activated'),
163
+ 'success',
164
+ )
165
+ setShowActivationBanner(false)
166
+ fetchConfig()
167
+ } catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err)
169
+ const isNoDomains = message.toLowerCase().includes('no allowed domains')
170
+ if (isNoDomains) {
171
+ setActivationError(t('sso.admin.error.noDomainsForActivation', 'Add at least one allowed email domain before activating'))
172
+ setActiveTab('domains')
173
+ } else {
174
+ setActivationError(message)
175
+ }
176
+ } finally {
177
+ setIsActivating(false)
178
+ }
179
+ }
180
+
181
+ const handleTestConnection = async () => {
182
+ try {
183
+ const call = await runMutationWithContext(
184
+ () => apiCallOrThrow<{ ok: boolean; error?: string }>(
185
+ `/api/sso/config/${configId}/test`,
186
+ { method: 'POST' },
187
+ { errorMessage: t('sso.admin.error.testFailed', 'Connection test failed') },
188
+ ),
189
+ )
190
+ if (call.result?.ok) {
191
+ flash(t('sso.admin.test.success', 'Discovery successful — issuer is reachable'), 'success')
192
+ } else {
193
+ flash(call.result?.error || t('sso.admin.test.failed', 'Discovery failed'), 'error')
194
+ }
195
+ } catch {
196
+ // handled by apiCallOrThrow
197
+ }
198
+ }
199
+
200
+ const handleDelete = async () => {
201
+ if (!config) return
202
+ if (config.isActive) {
203
+ flash(t('sso.admin.error.deleteActive', 'Cannot delete an active SSO configuration — deactivate it first'), 'error')
204
+ return
205
+ }
206
+ const confirmed = await confirm({
207
+ title: t('sso.admin.delete.title', 'Delete SSO Configuration'),
208
+ text: t('sso.admin.delete.confirm', 'Are you sure? This will remove the SSO configuration.'),
209
+ confirmText: t('common.delete', 'Delete'),
210
+ variant: 'destructive',
211
+ })
212
+ if (!confirmed) return
213
+
214
+ await runMutationWithContext(
215
+ () => apiCallOrThrow(`/api/sso/config/${configId}`, { method: 'DELETE' }, {
216
+ errorMessage: t('sso.admin.error.deleteFailed', 'Failed to delete SSO configuration'),
217
+ }),
218
+ { id: configId },
219
+ )
220
+ flash(t('sso.admin.delete.success', 'SSO configuration deleted'), 'success')
221
+ router.push('/backend/sso')
222
+ }
223
+
224
+ const handleAddDomain = async () => {
225
+ const normalized = domainInput.trim().toLowerCase()
226
+ if (!normalized) return
227
+
228
+ const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/
229
+ if (!domainRegex.test(normalized) || !normalized.includes('.')) {
230
+ setDomainError(t('sso.admin.wizard.domain.invalid', 'Invalid domain format'))
231
+ return
232
+ }
233
+
234
+ try {
235
+ await runMutationWithContext(
236
+ () => apiCallOrThrow(
237
+ `/api/sso/config/${configId}/domains`,
238
+ {
239
+ method: 'POST',
240
+ headers: { 'content-type': 'application/json' },
241
+ body: JSON.stringify({ domain: normalized }),
242
+ },
243
+ { errorMessage: t('sso.admin.error.domainAddFailed', 'Failed to add domain') },
244
+ ),
245
+ { domain: normalized },
246
+ )
247
+ setDomainInput('')
248
+ setDomainError('')
249
+ fetchConfig()
250
+ } catch {
251
+ // handled by apiCallOrThrow
252
+ }
253
+ }
254
+
255
+ const handleRemoveDomain = async (domain: string) => {
256
+ try {
257
+ await runMutationWithContext(
258
+ () => apiCallOrThrow(
259
+ `/api/sso/config/${configId}/domains?domain=${encodeURIComponent(domain)}`,
260
+ { method: 'DELETE' },
261
+ { errorMessage: t('sso.admin.error.domainRemoveFailed', 'Failed to remove domain') },
262
+ ),
263
+ { domain },
264
+ )
265
+ fetchConfig()
266
+ } catch {
267
+ // handled by apiCallOrThrow
268
+ }
269
+ }
270
+
271
+ if (isLoading) return <Page><PageBody><LoadingMessage label={t('common.loading', 'Loading...')} /></PageBody></Page>
272
+ if (error || !config) return <Page><PageBody><ErrorMessage label={error || t('common.notFound', 'Not found')} /></PageBody></Page>
273
+
274
+ const tabs: { id: Tab; label: string }[] = [
275
+ { id: 'general', label: t('sso.admin.tab.general', 'General') },
276
+ { id: 'domains', label: t('sso.admin.tab.domains', 'Domains') },
277
+ { id: 'roles', label: t('sso.admin.tab.roles', 'Role Mappings') },
278
+ { id: 'scim', label: t('sso.admin.tab.scim', 'Provisioning') },
279
+ { id: 'activity', label: t('sso.admin.tab.activity', 'Activity') },
280
+ ]
281
+
282
+ const statusBadge = (
283
+ <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.isActive ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
284
+ {config.isActive ? t('sso.admin.status.active', 'Active') : t('sso.admin.status.inactive', 'Inactive')}
285
+ </span>
286
+ )
287
+
288
+ return (
289
+ <Page>
290
+ <PageBody>
291
+ <div className="flex flex-col gap-6 max-w-3xl">
292
+ {/* Activation banner after creation */}
293
+ {showActivationBanner && !config.isActive && (
294
+ <div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
295
+ <p className="text-sm font-medium text-blue-900 mb-3">
296
+ {t('sso.admin.banner.created', 'Your SSO configuration has been created. Would you like to activate it now?')}
297
+ </p>
298
+ {activationError && (
299
+ <p className="text-sm text-destructive mb-3">{activationError}</p>
300
+ )}
301
+ <div className="flex items-center gap-2">
302
+ <Button size="sm" onClick={handleToggleActivation} disabled={isActivating}>
303
+ {isActivating
304
+ ? t('common.activating', 'Activating...')
305
+ : t('sso.admin.banner.activateNow', 'Activate Now')}
306
+ </Button>
307
+ <Button variant="outline" size="sm" onClick={() => setShowActivationBanner(false)}>
308
+ {t('sso.admin.banner.notYet', 'Not Yet')}
309
+ </Button>
310
+ </div>
311
+ </div>
312
+ )}
313
+
314
+ <FormHeader
315
+ mode="detail"
316
+ backHref="/backend/sso"
317
+ backLabel={t('sso.admin.detail.backToList', 'Back to SSO')}
318
+ title={config.name || config.issuer || t('sso.admin.detail.title', 'SSO Configuration')}
319
+ statusBadge={statusBadge}
320
+ actionsContent={
321
+ <div className="flex items-center gap-2">
322
+ <Button type="button" variant="outline" size="sm" onClick={handleTestConnection}>
323
+ {t('sso.admin.action.test', 'Verify Discovery')}
324
+ </Button>
325
+ <Button
326
+ type="button"
327
+ variant={config.isActive ? 'outline' : 'default'}
328
+ size="sm"
329
+ onClick={handleToggleActivation}
330
+ >
331
+ {config.isActive
332
+ ? t('sso.admin.action.deactivate', 'Deactivate')
333
+ : t('sso.admin.action.activate', 'Activate')}
334
+ </Button>
335
+ <Button type="button" variant="outline" size="sm" onClick={handleDelete} className="text-destructive">
336
+ {t('common.delete', 'Delete')}
337
+ </Button>
338
+ </div>
339
+ }
340
+ />
341
+
342
+ {/* Tabs */}
343
+ <div className="flex gap-1 border-b">
344
+ {tabs.map((tab) => (
345
+ <Button
346
+ key={tab.id}
347
+ type="button"
348
+ variant="ghost"
349
+ size="sm"
350
+ className={`h-auto rounded-none border-b-2 px-4 py-2 hover:bg-transparent ${
351
+ activeTab === tab.id
352
+ ? 'border-primary text-foreground'
353
+ : 'border-transparent text-muted-foreground'
354
+ }`}
355
+ onClick={() => setActiveTab(tab.id)}
356
+ >
357
+ {tab.label}
358
+ </Button>
359
+ ))}
360
+ </div>
361
+
362
+ {/* Tab content */}
363
+ {activeTab === 'general' && (
364
+ <div className="rounded-lg border bg-card p-4">
365
+ <h2 className="mb-4 text-sm font-semibold uppercase text-muted-foreground">
366
+ {t('sso.admin.section.oidcSettings', 'OIDC Settings')}
367
+ </h2>
368
+ <div className="space-y-4">
369
+ <div>
370
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.name', 'Configuration Name')}</label>
371
+ <input
372
+ type="text"
373
+ className="w-full rounded-md border px-3 py-2 text-sm"
374
+ value={name}
375
+ onChange={(e) => setName(e.target.value)}
376
+ />
377
+ </div>
378
+ <div>
379
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.protocol', 'Protocol')}</label>
380
+ <input type="text" className="w-full rounded-md border px-3 py-2 text-sm bg-muted" value={config.protocol.toUpperCase()} disabled />
381
+ </div>
382
+ <div>
383
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.issuer', 'Issuer URL')}</label>
384
+ <input
385
+ type="url"
386
+ className="w-full rounded-md border px-3 py-2 text-sm"
387
+ value={issuer}
388
+ onChange={(e) => setIssuer(e.target.value)}
389
+ />
390
+ </div>
391
+ <div>
392
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.clientId', 'Client ID')}</label>
393
+ <input
394
+ type="text"
395
+ className="w-full rounded-md border px-3 py-2 text-sm"
396
+ value={clientId}
397
+ onChange={(e) => setClientId(e.target.value)}
398
+ />
399
+ </div>
400
+ <div>
401
+ <label className="block text-sm font-medium mb-1">{t('sso.admin.field.clientSecret', 'Client Secret')}</label>
402
+ {config.hasClientSecret && !showSecretField ? (
403
+ <div className="flex items-center gap-2">
404
+ <span className="text-sm text-muted-foreground">{t('sso.admin.field.secretSet', 'Client secret is configured')}</span>
405
+ <Button type="button" variant="outline" size="sm" onClick={() => setShowSecretField(true)}>
406
+ {t('sso.admin.field.changeSecret', 'Change')}
407
+ </Button>
408
+ </div>
409
+ ) : (
410
+ <input
411
+ type="password"
412
+ className="w-full rounded-md border px-3 py-2 text-sm"
413
+ value={newClientSecret}
414
+ onChange={(e) => setNewClientSecret(e.target.value)}
415
+ placeholder={config.hasClientSecret
416
+ ? t('sso.admin.field.secretPlaceholder', 'Enter new secret to replace existing')
417
+ : t('sso.admin.field.secretRequired', 'Enter client secret')}
418
+ />
419
+ )}
420
+ </div>
421
+ <div className="space-y-3 pt-2">
422
+ <label className="flex items-center gap-3">
423
+ <input
424
+ type="checkbox"
425
+ checked={jitEnabled}
426
+ onChange={(e) => setJitEnabled(e.target.checked)}
427
+ disabled={config.hasActiveScimTokens}
428
+ className="accent-primary"
429
+ />
430
+ <div>
431
+ <span className="text-sm font-medium">{t('sso.admin.field.jitEnabled', 'Just-in-Time Provisioning')}</span>
432
+ <span className="text-xs text-muted-foreground ml-2">
433
+ {config.hasActiveScimTokens
434
+ ? t('sso.admin.field.jitDisabledByScim', 'Unavailable — SCIM directory sync is active. Revoke SCIM tokens to enable JIT.')
435
+ : t('sso.admin.field.jitEnabledDesc', 'Automatically create user accounts on first SSO login')}
436
+ </span>
437
+ </div>
438
+ </label>
439
+ <label className="flex items-center gap-3">
440
+ <input
441
+ type="checkbox"
442
+ checked={autoLinkByEmail}
443
+ onChange={(e) => setAutoLinkByEmail(e.target.checked)}
444
+ className="accent-primary"
445
+ />
446
+ <div>
447
+ <span className="text-sm font-medium">{t('sso.admin.field.autoLinkByEmail', 'Auto-link by Email')}</span>
448
+ <span className="text-xs text-muted-foreground ml-2">
449
+ {t('sso.admin.field.autoLinkByEmailDesc', 'Automatically link existing users by matching email address')}
450
+ </span>
451
+ </div>
452
+ </label>
453
+ </div>
454
+ <div className="pt-4">
455
+ <Button onClick={handleSave} disabled={isSaving}>
456
+ {isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
457
+ </Button>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ )}
462
+
463
+ {activeTab === 'domains' && (
464
+ <div className="rounded-lg border bg-card p-4">
465
+ <h2 className="mb-4 text-sm font-semibold uppercase text-muted-foreground">
466
+ {t('sso.admin.section.allowedDomains', 'Allowed Domains')}
467
+ </h2>
468
+ <p className="text-sm text-muted-foreground mb-4">
469
+ {t('sso.admin.wizard.domains.description', 'Users with email addresses matching these domains will be redirected to your SSO provider.')}
470
+ </p>
471
+ <div className="flex items-center gap-2 mb-4">
472
+ <input
473
+ type="text"
474
+ className="flex-1 rounded-md border px-3 py-2 text-sm"
475
+ placeholder={t('sso.admin.wizard.domains.placeholder', 'example.com')}
476
+ value={domainInput}
477
+ onChange={(e) => { setDomainInput(e.target.value); setDomainError('') }}
478
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDomain() } }}
479
+ />
480
+ <Button type="button" variant="outline" onClick={handleAddDomain}>
481
+ {t('common.add', 'Add')}
482
+ </Button>
483
+ </div>
484
+ {domainError && <p className="text-sm text-destructive mb-2">{domainError}</p>}
485
+ {config.allowedDomains.length > 0 ? (
486
+ <div className="space-y-2">
487
+ {config.allowedDomains.map((domain) => (
488
+ <div key={domain} className="flex items-center justify-between p-3 border rounded-md">
489
+ <code className="text-sm font-mono">{domain}</code>
490
+ <Button type="button" variant="ghost" size="sm" onClick={() => handleRemoveDomain(domain)}>
491
+ {t('common.remove', 'Remove')}
492
+ </Button>
493
+ </div>
494
+ ))}
495
+ </div>
496
+ ) : (
497
+ <p className="text-sm text-muted-foreground py-4 text-center">
498
+ {t('sso.admin.domains.empty', 'No domains configured. Add at least one domain before activating SSO.')}
499
+ </p>
500
+ )}
501
+ </div>
502
+ )}
503
+
504
+ {activeTab === 'roles' && config && (
505
+ <div className="rounded-lg border bg-card p-4">
506
+ <RoleMappingsTab
507
+ configId={configId}
508
+ appRoleMappings={config.appRoleMappings ?? {}}
509
+ onSaved={fetchConfig}
510
+ runMutationWithContext={runMutationWithContext}
511
+ />
512
+ </div>
513
+ )}
514
+
515
+ {activeTab === 'scim' && (
516
+ <div className="rounded-lg border bg-card p-4">
517
+ <ScimProvisioningTab configId={configId} jitEnabled={config.jitEnabled} issuer={config.issuer ?? undefined} onProvisioningChange={fetchConfig} runMutationWithContext={runMutationWithContext} />
518
+ </div>
519
+ )}
520
+
521
+ {activeTab === 'activity' && (
522
+ <div className="rounded-lg border bg-card p-4">
523
+ <SsoActivityTab configId={configId} />
524
+ </div>
525
+ )}
526
+ </div>
527
+ {ConfirmDialogElement}
528
+ </PageBody>
529
+ </Page>
530
+ )
531
+ }
532
+
533
+ function RoleMappingsTab({
534
+ configId,
535
+ appRoleMappings,
536
+ onSaved,
537
+ runMutationWithContext,
538
+ }: {
539
+ configId: string
540
+ appRoleMappings: Record<string, string>
541
+ onSaved: () => void
542
+ runMutationWithContext: <T>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>) => Promise<T>
543
+ }) {
544
+ const t = useT()
545
+ const [mappings, setMappings] = React.useState<Record<string, string>>(appRoleMappings)
546
+ const [idpRoleInput, setIdpRoleInput] = React.useState('')
547
+ const [localRoleInput, setLocalRoleInput] = React.useState('')
548
+ const [roleOptions, setRoleOptions] = React.useState<{ value: string; label: string }[]>([])
549
+ const [isSaving, setIsSaving] = React.useState(false)
550
+ const [inputError, setInputError] = React.useState('')
551
+
552
+ React.useEffect(() => {
553
+ setMappings(appRoleMappings)
554
+ }, [appRoleMappings])
555
+
556
+ React.useEffect(() => {
557
+ const loadRoles = async () => {
558
+ const call = await apiCall<{ items?: Array<{ id?: string | null; name?: string | null }> }>(
559
+ '/api/auth/roles?page=1&pageSize=50',
560
+ undefined,
561
+ { fallback: { items: [] } },
562
+ )
563
+ if (call.ok && Array.isArray(call.result?.items)) {
564
+ const options = call.result.items
565
+ .map((item) => {
566
+ const name = typeof item?.name === 'string' ? item.name.trim() : ''
567
+ if (!name || name === 'superadmin') return null
568
+ return { value: name, label: name }
569
+ })
570
+ .filter((opt): opt is { value: string; label: string } => !!opt)
571
+ setRoleOptions(options)
572
+ if (options.length > 0 && !localRoleInput) {
573
+ setLocalRoleInput(options[0].value)
574
+ }
575
+ }
576
+ }
577
+ loadRoles()
578
+ }, [])
579
+
580
+ const handleAdd = () => {
581
+ const trimmed = idpRoleInput.trim()
582
+ if (!trimmed) {
583
+ setInputError(t('sso.admin.roles.error.emptyIdpRole', 'IdP role name is required'))
584
+ return
585
+ }
586
+ if (!localRoleInput) {
587
+ setInputError(t('sso.admin.roles.error.emptyLocalRole', 'Select a local role'))
588
+ return
589
+ }
590
+ if (mappings[trimmed]) {
591
+ setInputError(t('sso.admin.roles.error.duplicate', 'This IdP role is already mapped'))
592
+ return
593
+ }
594
+ setMappings({ ...mappings, [trimmed]: localRoleInput })
595
+ setIdpRoleInput('')
596
+ setInputError('')
597
+ }
598
+
599
+ const handleRemove = (idpRole: string) => {
600
+ const updated = { ...mappings }
601
+ delete updated[idpRole]
602
+ setMappings(updated)
603
+ }
604
+
605
+ const handleSave = async () => {
606
+ setIsSaving(true)
607
+ try {
608
+ await runMutationWithContext(
609
+ () => apiCallOrThrow(
610
+ `/api/sso/config/${configId}`,
611
+ {
612
+ method: 'PUT',
613
+ headers: { 'content-type': 'application/json' },
614
+ body: JSON.stringify({ appRoleMappings: mappings }),
615
+ },
616
+ { errorMessage: t('sso.admin.roles.error.saveFailed', 'Failed to save role mappings') },
617
+ ),
618
+ { appRoleMappings: mappings },
619
+ )
620
+ flash(t('sso.admin.roles.saved', 'Role mappings saved'), 'success')
621
+ onSaved()
622
+ } catch {
623
+ // handled by apiCallOrThrow
624
+ } finally {
625
+ setIsSaving(false)
626
+ }
627
+ }
628
+
629
+ const mappingEntries = Object.entries(mappings)
630
+
631
+ return (
632
+ <div>
633
+ <p className="text-sm text-muted-foreground mb-4">
634
+ {t('sso.admin.roles.description', 'Map IdP app role names to local roles. On each SSO login, SSO-sourced roles are synced — roles no longer sent by the IdP are removed, while manually-assigned roles are preserved.')}
635
+ </p>
636
+ <div className="flex items-end gap-2 mb-4">
637
+ <div className="flex-1">
638
+ <label className="block text-xs font-medium mb-1">{t('sso.admin.roles.idpRole', 'IdP Role Name')}</label>
639
+ <input
640
+ type="text"
641
+ className="w-full rounded-md border px-3 py-2 text-sm"
642
+ placeholder={t('sso.admin.roles.idpRolePlaceholder', 'e.g. OpenMercato.Admin')}
643
+ value={idpRoleInput}
644
+ onChange={(e) => { setIdpRoleInput(e.target.value); setInputError('') }}
645
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }}
646
+ />
647
+ </div>
648
+ <div className="flex-1">
649
+ <label className="block text-xs font-medium mb-1">{t('sso.admin.roles.localRole', 'Local Role')}</label>
650
+ <select
651
+ className="w-full rounded-md border px-3 py-2 text-sm bg-background"
652
+ value={localRoleInput}
653
+ onChange={(e) => setLocalRoleInput(e.target.value)}
654
+ >
655
+ {roleOptions.map((opt) => (
656
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
657
+ ))}
658
+ </select>
659
+ </div>
660
+ <Button variant="outline" onClick={handleAdd}>
661
+ {t('common.add', 'Add')}
662
+ </Button>
663
+ </div>
664
+ {inputError && <p className="text-sm text-destructive mb-2">{inputError}</p>}
665
+
666
+ {mappingEntries.length > 0 ? (
667
+ <div className="space-y-2 mb-4">
668
+ {mappingEntries.map(([idpRole, localRole]) => (
669
+ <div key={idpRole} className="flex items-center justify-between p-3 border rounded-md">
670
+ <div className="flex items-center gap-2">
671
+ <code className="text-sm font-mono">{idpRole}</code>
672
+ <span className="text-muted-foreground text-sm">&rarr;</span>
673
+ <span className="text-sm font-medium">{localRole}</span>
674
+ </div>
675
+ <Button variant="ghost" size="sm" onClick={() => handleRemove(idpRole)}>
676
+ {t('common.remove', 'Remove')}
677
+ </Button>
678
+ </div>
679
+ ))}
680
+ </div>
681
+ ) : (
682
+ <p className="text-sm text-muted-foreground py-4 text-center mb-4">
683
+ {t('sso.admin.roles.empty', 'No role mappings configured. IdP role names will be matched directly against local role names.')}
684
+ </p>
685
+ )}
686
+
687
+ <Button onClick={handleSave} disabled={isSaving}>
688
+ {isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
689
+ </Button>
690
+ </div>
691
+ )
692
+ }
693
+
694
+ interface ScimTokenRow {
695
+ id: string
696
+ ssoConfigId: string
697
+ name: string
698
+ tokenPrefix: string
699
+ isActive: boolean
700
+ createdAt: string
701
+ }
702
+
703
+ interface ScimLogRow {
704
+ id: string
705
+ operation: string
706
+ resourceType: string
707
+ resourceId: string | null
708
+ scimExternalId: string | null
709
+ responseStatus: number
710
+ errorMessage: string | null
711
+ createdAt: string
712
+ }
713
+
714
+ function ScimProvisioningTab({ configId, jitEnabled, issuer, onProvisioningChange, runMutationWithContext }: { configId: string; jitEnabled: boolean; issuer?: string; onProvisioningChange: () => void; runMutationWithContext: <T>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>) => Promise<T> }) {
715
+ const isGoogleProvider = issuer?.includes('accounts.google.com') === true
716
+ const t = useT()
717
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
718
+
719
+ const [tokens, setTokens] = React.useState<ScimTokenRow[]>([])
720
+ const [logs, setLogs] = React.useState<ScimLogRow[]>([])
721
+ const [isLoading, setIsLoading] = React.useState(true)
722
+ const [showCreateForm, setShowCreateForm] = React.useState(false)
723
+ const [tokenName, setTokenName] = React.useState('')
724
+ const [isCreating, setIsCreating] = React.useState(false)
725
+ const [newlyCreatedToken, setNewlyCreatedToken] = React.useState<string | null>(null)
726
+
727
+ const fetchData = React.useCallback(async () => {
728
+ setIsLoading(true)
729
+ const [tokensCall, logsCall] = await Promise.all([
730
+ apiCall<{ items: ScimTokenRow[] }>(`/api/sso/scim/tokens?ssoConfigId=${configId}`),
731
+ apiCall<{ items: ScimLogRow[] }>(`/api/sso/scim/logs?ssoConfigId=${configId}`),
732
+ ])
733
+ if (tokensCall.ok && tokensCall.result) setTokens(tokensCall.result.items)
734
+ if (logsCall.ok && logsCall.result) setLogs(logsCall.result.items)
735
+ setIsLoading(false)
736
+ }, [configId])
737
+
738
+ React.useEffect(() => { fetchData() }, [fetchData])
739
+
740
+ const handleCreateToken = async () => {
741
+ if (!tokenName.trim()) return
742
+ setIsCreating(true)
743
+ try {
744
+ const result = await runMutationWithContext(
745
+ () => apiCallOrThrow<{ id: string; token: string; prefix: string; name: string }>(
746
+ '/api/sso/scim/tokens',
747
+ {
748
+ method: 'POST',
749
+ headers: { 'content-type': 'application/json' },
750
+ body: JSON.stringify({ ssoConfigId: configId, name: tokenName.trim() }),
751
+ },
752
+ { errorMessage: t('sso.admin.scim.error.createFailed', 'Failed to create SCIM token') },
753
+ ),
754
+ { ssoConfigId: configId, name: tokenName.trim() },
755
+ )
756
+ if (result.result) {
757
+ setNewlyCreatedToken(result.result.token)
758
+ setShowCreateForm(false)
759
+ setTokenName('')
760
+ fetchData()
761
+ onProvisioningChange()
762
+ }
763
+ } catch {
764
+ // handled by apiCallOrThrow
765
+ } finally {
766
+ setIsCreating(false)
767
+ }
768
+ }
769
+
770
+ const handleRevokeToken = async (tokenId: string) => {
771
+ const confirmed = await confirm({
772
+ title: t('sso.admin.scim.revoke.title', 'Revoke Token'),
773
+ text: t('sso.admin.scim.revoke.confirm', 'Are you sure? This token will no longer authenticate SCIM requests.'),
774
+ confirmText: t('sso.admin.scim.revoke.action', 'Revoke'),
775
+ variant: 'destructive',
776
+ })
777
+ if (!confirmed) return
778
+
779
+ try {
780
+ await runMutationWithContext(
781
+ () => apiCallOrThrow(`/api/sso/scim/tokens/${tokenId}`, { method: 'DELETE' }, {
782
+ errorMessage: t('sso.admin.scim.error.revokeFailed', 'Failed to revoke token'),
783
+ }),
784
+ { tokenId },
785
+ )
786
+ flash(t('sso.admin.scim.revoked', 'Token revoked'), 'success')
787
+ fetchData()
788
+ onProvisioningChange()
789
+ } catch {
790
+ // handled
791
+ }
792
+ }
793
+
794
+ const handleCopyEndpoint = () => {
795
+ const url = `${window.location.origin}/api/sso/scim/v2`
796
+ navigator.clipboard.writeText(url).then(() => {
797
+ flash(t('sso.admin.scim.endpointCopied', 'SCIM endpoint URL copied'), 'success')
798
+ })
799
+ }
800
+
801
+ const handleCopyToken = () => {
802
+ if (!newlyCreatedToken) return
803
+ navigator.clipboard.writeText(newlyCreatedToken).then(() => {
804
+ flash(t('sso.admin.scim.tokenCopied', 'Token copied to clipboard'), 'success')
805
+ })
806
+ }
807
+
808
+ if (isLoading) return <LoadingMessage label={t('common.loading', 'Loading...')} />
809
+
810
+ return (
811
+ <div className="space-y-6">
812
+ {/* Google provider banner — SCIM not supported */}
813
+ {isGoogleProvider && (
814
+ <div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
815
+ <p className="text-sm text-blue-900">
816
+ {t('sso.admin.scim.googleNotSupported', 'Google Workspace does not support SCIM provisioning. Users are provisioned via Just-In-Time (JIT) on first login.')}
817
+ </p>
818
+ </div>
819
+ )}
820
+
821
+ {/* JIT active banner */}
822
+ {!isGoogleProvider && jitEnabled && (
823
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
824
+ <p className="text-sm text-amber-900">
825
+ {t('sso.admin.scim.jitActiveWarning', 'SCIM provisioning is unavailable while JIT provisioning is enabled. Disable JIT in the General tab to configure SCIM.')}
826
+ </p>
827
+ </div>
828
+ )}
829
+
830
+ {isGoogleProvider ? null : <>
831
+ {/* SCIM Endpoint URL */}
832
+ <div>
833
+ <h3 className="text-sm font-medium mb-2">{t('sso.admin.scim.endpointUrl', 'SCIM Endpoint URL')}</h3>
834
+ <div className="flex items-center gap-2">
835
+ <code className="flex-1 rounded-md border bg-muted px-3 py-2 text-sm font-mono">
836
+ {typeof window !== 'undefined' ? `${window.location.origin}/api/sso/scim/v2` : '/api/sso/scim/v2'}
837
+ </code>
838
+ <Button variant="outline" size="sm" onClick={handleCopyEndpoint}>
839
+ {t('common.copy', 'Copy')}
840
+ </Button>
841
+ </div>
842
+ </div>
843
+
844
+ {/* Newly created token banner */}
845
+ {newlyCreatedToken && (
846
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
847
+ <p className="text-sm font-medium text-amber-900 mb-2">
848
+ {t('sso.admin.scim.tokenCreated', 'Your SCIM token has been created. Copy it now — it will not be shown again.')}
849
+ </p>
850
+ <div className="flex items-center gap-2 mb-2">
851
+ <code className="flex-1 rounded-md border bg-white px-3 py-2 text-xs font-mono break-all">
852
+ {newlyCreatedToken}
853
+ </code>
854
+ <Button variant="outline" size="sm" onClick={handleCopyToken}>
855
+ {t('common.copy', 'Copy')}
856
+ </Button>
857
+ </div>
858
+ <Button variant="ghost" size="sm" onClick={() => setNewlyCreatedToken(null)}>
859
+ {t('common.dismiss', 'Dismiss')}
860
+ </Button>
861
+ </div>
862
+ )}
863
+
864
+ {/* Token management */}
865
+ <div>
866
+ <div className="flex items-center justify-between mb-3">
867
+ <h3 className="text-sm font-medium">{t('sso.admin.scim.tokens', 'Bearer Tokens')}</h3>
868
+ {!showCreateForm && (
869
+ <Button variant="outline" size="sm" onClick={() => setShowCreateForm(true)} disabled={jitEnabled}>
870
+ {t('sso.admin.scim.generateToken', 'Generate Token')}
871
+ </Button>
872
+ )}
873
+ </div>
874
+
875
+ {showCreateForm && (
876
+ <div className="flex items-center gap-2 mb-4">
877
+ <input
878
+ type="text"
879
+ className="flex-1 rounded-md border px-3 py-2 text-sm"
880
+ placeholder={t('sso.admin.scim.tokenNamePlaceholder', 'Token name (e.g., Entra ID Production)')}
881
+ value={tokenName}
882
+ onChange={(e) => setTokenName(e.target.value)}
883
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleCreateToken() } }}
884
+ autoFocus
885
+ />
886
+ <Button size="sm" onClick={handleCreateToken} disabled={isCreating || !tokenName.trim()}>
887
+ {isCreating ? t('common.creating', 'Creating...') : t('common.create', 'Create')}
888
+ </Button>
889
+ <Button variant="ghost" size="sm" onClick={() => { setShowCreateForm(false); setTokenName('') }}>
890
+ {t('common.cancel', 'Cancel')}
891
+ </Button>
892
+ </div>
893
+ )}
894
+
895
+ {tokens.length === 0 ? (
896
+ <div className="text-center py-8 border rounded-md">
897
+ <p className="text-sm text-muted-foreground">
898
+ {t('sso.admin.scim.noTokens', 'SCIM provisioning is not configured. Generate a bearer token to enable your identity provider to sync users automatically.')}
899
+ </p>
900
+ </div>
901
+ ) : (
902
+ <div className="space-y-2">
903
+ {tokens.map((token) => (
904
+ <div key={token.id} className="flex items-center justify-between p-3 border rounded-md">
905
+ <div className="flex items-center gap-3">
906
+ <div>
907
+ <span className="text-sm font-medium">{token.name}</span>
908
+ <span className="text-xs text-muted-foreground ml-2 font-mono">{token.tokenPrefix}...</span>
909
+ </div>
910
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
911
+ token.isActive ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
912
+ }`}>
913
+ {token.isActive ? t('sso.admin.scim.tokenActive', 'Active') : t('sso.admin.scim.tokenRevoked', 'Revoked')}
914
+ </span>
915
+ </div>
916
+ <div className="flex items-center gap-2">
917
+ <span className="text-xs text-muted-foreground">
918
+ {new Date(token.createdAt).toLocaleDateString()}
919
+ </span>
920
+ {token.isActive && (
921
+ <Button variant="ghost" size="sm" onClick={() => handleRevokeToken(token.id)} className="text-destructive">
922
+ {t('sso.admin.scim.revoke.action', 'Revoke')}
923
+ </Button>
924
+ )}
925
+ </div>
926
+ </div>
927
+ ))}
928
+ </div>
929
+ )}
930
+ </div>
931
+
932
+ {/* Provisioning log */}
933
+ {logs.length > 0 && (
934
+ <div>
935
+ <h3 className="text-sm font-medium mb-3">{t('sso.admin.scim.recentActivity', 'Recent Provisioning Activity')}</h3>
936
+ <div className="border rounded-md overflow-hidden">
937
+ <table className="w-full text-sm">
938
+ <thead className="bg-muted/50">
939
+ <tr>
940
+ <th className="text-left px-3 py-2 font-medium">{t('sso.admin.scim.log.time', 'Time')}</th>
941
+ <th className="text-left px-3 py-2 font-medium">{t('sso.admin.scim.log.operation', 'Operation')}</th>
942
+ <th className="text-left px-3 py-2 font-medium">{t('sso.admin.scim.log.resource', 'Resource')}</th>
943
+ <th className="text-left px-3 py-2 font-medium">{t('sso.admin.scim.log.status', 'Status')}</th>
944
+ <th className="text-left px-3 py-2 font-medium">{t('sso.admin.scim.log.error', 'Error')}</th>
945
+ </tr>
946
+ </thead>
947
+ <tbody>
948
+ {logs.map((log) => (
949
+ <tr key={log.id} className="border-t">
950
+ <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">
951
+ {new Date(log.createdAt).toLocaleString()}
952
+ </td>
953
+ <td className="px-3 py-2">
954
+ <span className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium bg-muted">
955
+ {log.operation}
956
+ </span>
957
+ </td>
958
+ <td className="px-3 py-2 text-xs">{log.resourceType}</td>
959
+ <td className="px-3 py-2">
960
+ <span className={`text-xs font-medium ${log.responseStatus < 300 ? 'text-green-700' : 'text-red-600'}`}>
961
+ {log.responseStatus}
962
+ </span>
963
+ </td>
964
+ <td className="px-3 py-2 text-xs text-muted-foreground truncate max-w-[200px]">
965
+ {log.errorMessage || '—'}
966
+ </td>
967
+ </tr>
968
+ ))}
969
+ </tbody>
970
+ </table>
971
+ </div>
972
+ </div>
973
+ )}
974
+
975
+ </>}
976
+
977
+ {ConfirmDialogElement}
978
+ </div>
979
+ )
980
+ }
981
+
982
+ function SsoActivityTab({ configId }: { configId: string }) {
983
+ const t = useT()
984
+ const [identities, setIdentities] = React.useState<SsoIdentityRow[]>([])
985
+ const [isLoading, setIsLoading] = React.useState(true)
986
+
987
+ React.useEffect(() => {
988
+ const load = async () => {
989
+ setIsLoading(true)
990
+ // For now, just show a placeholder — the identities list API is an M3 deliverable
991
+ setIdentities([])
992
+ setIsLoading(false)
993
+ }
994
+ load()
995
+ }, [configId])
996
+
997
+ if (isLoading) return <LoadingMessage label={t('common.loading', 'Loading...')} />
998
+
999
+ if (identities.length === 0) {
1000
+ return (
1001
+ <div className="text-center py-8">
1002
+ <p className="text-sm text-muted-foreground">
1003
+ {t('sso.admin.activity.empty', 'No SSO login activity yet. Activity will appear here once users start logging in via SSO.')}
1004
+ </p>
1005
+ </div>
1006
+ )
1007
+ }
1008
+
1009
+ return (
1010
+ <div className="space-y-2">
1011
+ {identities.map((identity) => (
1012
+ <div key={identity.id} className="flex items-center justify-between p-3 border rounded-md">
1013
+ <div>
1014
+ <span className="text-sm font-medium">{identity.idpEmail}</span>
1015
+ {identity.idpName && <span className="text-sm text-muted-foreground ml-2">({identity.idpName})</span>}
1016
+ </div>
1017
+ <span className="text-xs text-muted-foreground">
1018
+ {identity.lastLoginAt ? new Date(identity.lastLoginAt).toLocaleString() : '—'}
1019
+ </span>
1020
+ </div>
1021
+ ))}
1022
+ </div>
1023
+ )
1024
+ }