@cat-factory/app 0.228.1 → 0.230.0

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.
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
6
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, VCS_PROVIDER_TOKEN_URLS } from '~/utils/vcs'
7
+ import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
7
8
 
8
9
  const auth = useAuthStore()
9
10
  const { t } = useI18n()
@@ -124,6 +125,18 @@ const showOAuthDivider = computed(
124
125
  () => auth.providers.password && (auth.providers.github || auth.providers.google),
125
126
  )
126
127
 
128
+ // Enterprise SSO. Led with, above the consumer providers, because on a deployment that configures
129
+ // it that is the intended way in — a person arriving at an org's board should not have to find
130
+ // their company's button under two they must not use. The label is the operator's own wording
131
+ // (it names their IdP), so it is rendered verbatim rather than through the catalog.
132
+ const ssoLabel = computed(() => auth.sso?.label ?? '')
133
+ // Translated copy for a refused round-trip, keyed off the machine-readable reason the backend
134
+ // handed back. Each reason has its own wording because the remedies differ: a missing directory
135
+ // group is something the user takes to IT, a failed code exchange is the operator's own config.
136
+ const ssoErrorMessage = computed(() =>
137
+ auth.ssoError ? t(SSO_ERROR_MESSAGE_KEYS[auth.ssoError]) : null,
138
+ )
139
+
127
140
  // Hosted (remote node) PAT login: the user pastes their OWN source-control PAT, which the
128
141
  // server resolves to an account and holds to its login/org/domain allowlist. The available
129
142
  // providers come from the server (`auth.patProviders`) — GitHub always, GitLab when configured,
@@ -157,6 +170,16 @@ async function submitRemotePat() {
157
170
  }
158
171
  }
159
172
 
173
+ // Only divide SSO from what follows when something actually follows it.
174
+ const showSsoDivider = computed(
175
+ () =>
176
+ auth.providers.sso &&
177
+ (auth.providers.github ||
178
+ auth.providers.google ||
179
+ auth.providers.password ||
180
+ remotePatProviders.value.length > 0),
181
+ )
182
+
160
183
  // A remote deployment (node service / Worker) that advertises no sign-in method at all:
161
184
  // no OAuth, no password, no PAT, and not local mode. The auth gate still routes here (a
162
185
  // remote facade has no anonymous tier), so instead of a blank card we explain that
@@ -167,6 +190,7 @@ const noSignInMethod = computed(
167
190
  !auth.providers.github &&
168
191
  !auth.providers.google &&
169
192
  !auth.providers.password &&
193
+ !auth.providers.sso &&
170
194
  remotePatProviders.value.length === 0,
171
195
  )
172
196
  </script>
@@ -261,6 +285,41 @@ const noSignInMethod = computed(
261
285
  <span class="h-px flex-1 bg-slate-800" />
262
286
  </div>
263
287
 
288
+ <!-- A refused SSO round-trip: name the rule that refused, don't return the user to an
289
+ unchanged sign-in button. -->
290
+ <UAlert
291
+ v-if="ssoErrorMessage && mode !== 'forgot'"
292
+ class="mb-4"
293
+ color="error"
294
+ variant="subtle"
295
+ icon="i-lucide-shield-x"
296
+ :title="t('auth.sso.failedTitle')"
297
+ :description="ssoErrorMessage"
298
+ data-testid="sso-error"
299
+ />
300
+
301
+ <!-- Enterprise SSO: the deployment's OWN identity provider, led with where configured. -->
302
+ <div v-if="auth.providers.sso && mode !== 'forgot'" class="mb-2 space-y-2">
303
+ <UButton
304
+ block
305
+ size="lg"
306
+ color="primary"
307
+ icon="i-lucide-building-2"
308
+ data-testid="sso-signin"
309
+ @click="auth.loginWithSso(invite)"
310
+ >
311
+ {{ t('auth.sso.continueWith', { provider: ssoLabel }) }}
312
+ </UButton>
313
+ </div>
314
+
315
+ <div
316
+ v-if="showSsoDivider && mode !== 'forgot'"
317
+ class="my-4 flex items-center gap-3 text-xs text-slate-500"
318
+ >
319
+ <span class="h-px flex-1 bg-slate-800" /> {{ t('auth.login.or') }}
320
+ <span class="h-px flex-1 bg-slate-800" />
321
+ </div>
322
+
264
323
  <!-- OAuth providers -->
265
324
  <div v-if="mode !== 'forgot'" class="space-y-2">
266
325
  <UButton
@@ -895,6 +895,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
895
895
  : t('board.addTask.descriptionPlaceholder')
896
896
  "
897
897
  class="w-full"
898
+ data-testid="add-task-description"
898
899
  />
899
900
  </UFormField>
900
901
  </template>
@@ -406,6 +406,7 @@ function selectTask() {
406
406
  :icon="!runnable ? 'i-lucide-lock' : sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
407
407
  :loading="starting"
408
408
  :disabled="!runnable || starting"
409
+ data-testid="task-start"
409
410
  :title="
410
411
  !runnable
411
412
  ? t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
@@ -148,6 +148,9 @@ async function resolve(choice: 'recheck' | 'proceed') {
148
148
  v-for="issue in issues"
149
149
  :key="`${issue.code}:${issue.field?.key ?? ''}`"
150
150
  class="flex items-start gap-2 text-xs"
151
+ data-testid="input-gate-issue"
152
+ :data-issue-code="issue.code"
153
+ :data-issue-severity="issue.severity"
151
154
  >
152
155
  <UBadge
153
156
  :color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
@@ -170,6 +170,17 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
170
170
  </span>
171
171
  </div>
172
172
 
173
+ <!-- The rubric asked for a model this deployment cannot serve, so something else scored
174
+ the work. Only this case is shown: a pin that was honoured says nothing the model
175
+ name doesn't, and being overridden by the task's own choice is the normal outcome. -->
176
+ <p
177
+ v-if="judge.modelPin?.status === 'unavailable'"
178
+ class="mt-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[12px] leading-relaxed text-amber-200"
179
+ data-testid="judge-model-pin"
180
+ >
181
+ {{ t('judge.modelPinUnavailable', { model: judge.modelPin.requested }) }}
182
+ </p>
183
+
173
184
  <!-- Why the judge did nothing, when it did nothing. A skipped judge must never read
174
185
  like a clean pass. -->
175
186
  <p
@@ -288,6 +299,8 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
288
299
  v-for="round in rounds"
289
300
  :key="round.round"
290
301
  class="flex flex-wrap items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-1.5 text-[12px] text-slate-300"
302
+ data-testid="judge-round"
303
+ :data-round-disposition="round.disposition"
291
304
  >
292
305
  <span class="text-slate-500">{{ t('judge.round', { round: round.round }) }}</span>
293
306
  <span class="font-medium text-slate-100">{{
@@ -373,6 +373,7 @@ async function mergePr() {
373
373
  <button
374
374
  type="button"
375
375
  class="flex min-w-0 cursor-pointer items-center gap-2 text-start transition hover:text-white"
376
+ data-testid="run-step-open"
376
377
  :title="
377
378
  s.output
378
379
  ? t('inspector.execution.viewDetailsOutput')
@@ -708,6 +708,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
708
708
  <div
709
709
  v-if="incorporated"
710
710
  class="mb-4 flex items-center gap-2 rounded-lg border border-emerald-900/60 bg-emerald-950/30 p-4 text-sm text-emerald-300"
711
+ data-testid="requirements-settled"
711
712
  >
712
713
  <UIcon name="i-lucide-circle-check" class="h-5 w-5 shrink-0" />
713
714
  {{ t('requirements.settled') }}
@@ -760,6 +761,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
760
761
  <div
761
762
  class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
762
763
  :class="{ 'opacity-60': item.status === 'dismissed' }"
764
+ data-testid="requirements-finding"
765
+ :data-finding-status="item.status"
766
+ :data-finding-severity="item.severity"
763
767
  >
764
768
  <div class="flex items-start gap-2">
765
769
  <UIcon
@@ -831,6 +835,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
831
835
  size="xs"
832
836
  :icon="opt.icon"
833
837
  :disabled="frozen"
838
+ :data-testid="`requirements-mode-${opt.mode}`"
834
839
  @click="setMode(item, opt.mode)"
835
840
  >
836
841
  {{ t(opt.labelKey) }}
@@ -881,6 +886,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
881
886
  class="mt-2 w-full"
882
887
  :placeholder="t('requirements.answerPlaceholder')"
883
888
  :disabled="frozen"
889
+ data-testid="requirements-answer"
884
890
  @blur="persistDraft(item)"
885
891
  />
886
892
  <p
@@ -1170,6 +1176,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1170
1176
  :loading="acting"
1171
1177
  :disabled="!access.canExecuteRuns.value"
1172
1178
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1179
+ data-testid="requirements-proceed"
1173
1180
  @click="proceed"
1174
1181
  >
1175
1182
  {{ t('requirements.actions.proceedNothing') }}
@@ -1183,6 +1190,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1183
1190
  :loading="reworking"
1184
1191
  :disabled="!canIncorporate || !access.canExecuteRuns.value"
1185
1192
  :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1193
+ data-testid="requirements-incorporate"
1186
1194
  @click="incorporate()"
1187
1195
  >
1188
1196
  {{ t('requirements.actions.incorporateAnswers') }}
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+
4
+ // Where a vendor's authorization server sends the operator's browser back to after they approve a
5
+ // remote MCP tool server (`/mcp-oauth-callback?code=…&state=…`).
6
+ //
7
+ // A page in the APP rather than a route on the backend, which is the security shape of this flow
8
+ // rather than a routing preference: a redirect is a third-party navigation carrying no bearer
9
+ // token, so a backend receiver could never tell WHO was completing the grant. This page re-presents
10
+ // the two values over the authenticated API, where the session, the "same user who started it"
11
+ // binding and the `secrets.manage` re-check all actually run.
12
+ //
13
+ // It is NOT a public route (unlike the password reset beside it): an expired session renders the
14
+ // login screen on this same URL, and once the operator signs in the query string is still here and
15
+ // the grant completes. That is the correct behaviour, not a gap.
16
+
17
+ const api = useApi()
18
+ const { t } = useI18n()
19
+
20
+ const state = ref<'working' | 'done' | 'failed'>('working')
21
+ const detail = ref<string | null>(null)
22
+ const serverId = ref<string | null>(null)
23
+
24
+ function query(name: string): string {
25
+ if (typeof window === 'undefined') return ''
26
+ return new URLSearchParams(window.location.search).get(name) ?? ''
27
+ }
28
+
29
+ onMounted(async () => {
30
+ // An authorization server that REFUSED reports it here rather than on the token endpoint, so the
31
+ // operator's own "Deny" and a misconfigured client both arrive as this. Named rather than folded
32
+ // into "no code": one is nothing to fix and the other is the client registration.
33
+ const denied = query('error')
34
+ if (denied) {
35
+ state.value = 'failed'
36
+ detail.value = query('error_description') || denied
37
+ return
38
+ }
39
+ const code = query('code')
40
+ const sealed = query('state')
41
+ if (!code || !sealed) {
42
+ state.value = 'failed'
43
+ detail.value = t('settings.toolServers.oauth.callback.missingParams')
44
+ return
45
+ }
46
+ try {
47
+ const result = await api.completeToolServerOAuth({ code, state: sealed })
48
+ serverId.value = result.serverId
49
+ state.value = 'done'
50
+ } catch (e) {
51
+ state.value = 'failed'
52
+ detail.value =
53
+ (e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
54
+ t('settings.toolServers.oauth.callback.failed')
55
+ }
56
+ })
57
+
58
+ function backToApp() {
59
+ if (typeof window !== 'undefined') window.location.assign('/')
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <div
65
+ class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100"
66
+ data-testid="mcp-oauth-callback"
67
+ >
68
+ <div
69
+ class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 text-center backdrop-blur"
70
+ >
71
+ <template v-if="state === 'working'">
72
+ <UIcon name="i-lucide-loader" class="mx-auto mb-3 h-10 w-10 animate-spin text-indigo-400" />
73
+ <h1 class="mb-1 text-lg font-semibold text-white">
74
+ {{ t('settings.toolServers.oauth.callback.working') }}
75
+ </h1>
76
+ </template>
77
+
78
+ <template v-else-if="state === 'done'">
79
+ <UIcon name="i-lucide-check-circle" class="mx-auto mb-3 h-10 w-10 text-emerald-400" />
80
+ <h1 class="mb-1 text-lg font-semibold text-white" data-testid="mcp-oauth-callback-done">
81
+ {{ t('settings.toolServers.oauth.callback.done', { server: serverId }) }}
82
+ </h1>
83
+ <p class="mb-6 text-sm text-slate-400">
84
+ {{ t('settings.toolServers.oauth.callback.doneHint') }}
85
+ </p>
86
+ <UButton block color="primary" @click="backToApp">
87
+ {{ t('settings.toolServers.oauth.callback.back') }}
88
+ </UButton>
89
+ </template>
90
+
91
+ <template v-else>
92
+ <UIcon name="i-lucide-alert-triangle" class="mx-auto mb-3 h-10 w-10 text-red-400" />
93
+ <h1 class="mb-1 text-lg font-semibold text-white" data-testid="mcp-oauth-callback-failed">
94
+ {{ t('settings.toolServers.oauth.callback.failedTitle') }}
95
+ </h1>
96
+ <p class="mb-6 text-sm break-words text-slate-400">{{ detail }}</p>
97
+ <UButton block color="neutral" variant="subtle" @click="backToApp">
98
+ {{ t('settings.toolServers.oauth.callback.back') }}
99
+ </UButton>
100
+ </template>
101
+ </div>
102
+ </div>
103
+ </template>
@@ -40,6 +40,8 @@ const STATUS_LABELS = computed<Record<ToolServerProbeStatus, string>>(() => ({
40
40
  ok: t('settings.toolServers.status.ok'),
41
41
  credentials_missing: t('settings.toolServers.status.credentialsMissing'),
42
42
  credential_refused: t('settings.toolServers.status.credentialRefused'),
43
+ oauth_not_connected: t('settings.toolServers.status.oauthNotConnected'),
44
+ oauth_token_failed: t('settings.toolServers.status.oauthTokenFailed'),
43
45
  unreachable: t('settings.toolServers.status.unreachable'),
44
46
  http_error: t('settings.toolServers.status.httpError'),
45
47
  protocol_error: t('settings.toolServers.status.protocolError'),
@@ -53,6 +55,36 @@ const NOT_PROBEABLE_LABELS = computed<Record<ToolServerNotProbeableReason, strin
53
55
 
54
56
  const servers = computed<ToolServerView[]>(() => store.view?.servers ?? [])
55
57
 
58
+ /**
59
+ * Whether a row offers the Connect / Disconnect pair.
60
+ *
61
+ * Only the INTERACTIVE grant does. A `client_credentials` declaration authenticates as the
62
+ * deployment's own client and mints its token on the first dispatch that needs one, so there is
63
+ * nothing for a person to authorise and a button would promise an action that does not exist.
64
+ */
65
+ function isInteractive(server: ToolServerView): boolean {
66
+ return server.oauth?.grant === 'authorization_code'
67
+ }
68
+
69
+ async function connect(id: string) {
70
+ try {
71
+ await store.connectOAuth(id)
72
+ } catch (e) {
73
+ // Everything that can refuse does so BEFORE the browser leaves the app: an unconfigured
74
+ // redirect URL, a deployment with no grant store, an authorization server that publishes no
75
+ // metadata. Each carries a `details.reason` the shared funnel maps to translated copy.
76
+ present(e, 'settings.toolServers.toast.connectFailed')
77
+ }
78
+ }
79
+
80
+ async function disconnect(id: string) {
81
+ try {
82
+ await store.disconnectOAuth(id)
83
+ } catch (e) {
84
+ present(e, 'settings.toolServers.toast.disconnectFailed')
85
+ }
86
+ }
87
+
56
88
  function resultFor(id: string) {
57
89
  return store.results[id]
58
90
  }
@@ -138,6 +170,85 @@ async function runProbe(id: string) {
138
170
  }}
139
171
  </p>
140
172
 
173
+ <!-- OAuth. Absent for a server that authenticates with a static credential, so the Connect
174
+ affordance never appears on a row it does not apply to. `connected` and `lastError` are
175
+ rendered TOGETHER rather than as alternatives: a grant that is on file and no longer
176
+ producing tokens is exactly the state that reads as working and is not. -->
177
+ <div
178
+ v-if="server.oauth"
179
+ class="space-y-1 rounded-md border border-slate-800 bg-slate-900/40 p-2"
180
+ :data-testid="`tool-server-oauth-${server.id}`"
181
+ >
182
+ <div class="flex flex-wrap items-center gap-2">
183
+ <UBadge
184
+ :color="server.oauth.connected ? 'success' : 'neutral'"
185
+ variant="soft"
186
+ size="sm"
187
+ :data-testid="`tool-server-oauth-state-${server.id}`"
188
+ >
189
+ {{
190
+ server.oauth.connected
191
+ ? t('settings.toolServers.oauth.connected')
192
+ : isInteractive(server)
193
+ ? t('settings.toolServers.oauth.notConnected')
194
+ : t('settings.toolServers.oauth.machineGrant')
195
+ }}
196
+ </UBadge>
197
+ <span v-if="server.oauth.connectedBy" class="text-[11px] text-slate-400">
198
+ {{ t('settings.toolServers.oauth.connectedBy', { user: server.oauth.connectedBy }) }}
199
+ </span>
200
+ </div>
201
+
202
+ <p v-if="server.oauth.scopes?.length" class="text-[11px] text-slate-400">
203
+ {{ t('settings.toolServers.oauth.scopes', { scopes: server.oauth.scopes.join(', ') }) }}
204
+ </p>
205
+ <!-- A grant with no refresh token works until its access token expires and then needs
206
+ granting again by hand. Said BEFORE it happens, which is the only time it is useful. -->
207
+ <p
208
+ v-if="server.oauth.connected && server.oauth.refreshable === false"
209
+ class="text-[11px] text-amber-400"
210
+ >
211
+ {{ t('settings.toolServers.oauth.notRefreshable') }}
212
+ </p>
213
+ <p
214
+ v-if="server.oauth.lastError"
215
+ class="text-[11px] text-red-400"
216
+ :data-testid="`tool-server-oauth-error-${server.id}`"
217
+ >
218
+ {{ t('settings.toolServers.oauth.lastError', { detail: server.oauth.lastError }) }}
219
+ </p>
220
+
221
+ <div v-if="isInteractive(server)" class="flex flex-wrap items-center gap-2 pt-1">
222
+ <UButton
223
+ size="xs"
224
+ variant="subtle"
225
+ icon="i-lucide-link"
226
+ :loading="store.connecting === server.id"
227
+ :disabled="store.connecting !== null"
228
+ :data-testid="`tool-server-connect-${server.id}`"
229
+ @click="connect(server.id)"
230
+ >
231
+ {{
232
+ server.oauth.connected
233
+ ? t('settings.toolServers.oauth.reconnect')
234
+ : t('settings.toolServers.oauth.connect')
235
+ }}
236
+ </UButton>
237
+ <UButton
238
+ v-if="server.oauth.connected"
239
+ size="xs"
240
+ variant="ghost"
241
+ color="error"
242
+ :loading="store.connecting === server.id"
243
+ :disabled="store.connecting !== null"
244
+ :data-testid="`tool-server-disconnect-${server.id}`"
245
+ @click="disconnect(server.id)"
246
+ >
247
+ {{ t('settings.toolServers.oauth.disconnect') }}
248
+ </UButton>
249
+ </div>
250
+ </div>
251
+
141
252
  <div class="flex flex-wrap items-center gap-2 pt-1">
142
253
  <UButton
143
254
  v-if="server.probeable"
@@ -1,4 +1,10 @@
1
- import { listToolServersContract, probeToolServerContract } from '@cat-factory/contracts'
1
+ import {
2
+ completeToolServerOAuthContract,
3
+ disconnectToolServerOAuthContract,
4
+ listToolServersContract,
5
+ probeToolServerContract,
6
+ startToolServerOAuthContract,
7
+ } from '@cat-factory/contracts'
2
8
  import type { ApiContext } from './context'
3
9
 
4
10
  /**
@@ -17,5 +23,21 @@ export function toolServersApi({ send, ws }: ApiContext) {
17
23
 
18
24
  probeToolServer: (workspaceId: string, id: string) =>
19
25
  send(probeToolServerContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
26
+
27
+ // Begin an interactive OAuth grant: answers with the VENDOR's authorization URL for the
28
+ // operator's browser to follow, rather than redirecting, since a redirect from a `fetch` lands
29
+ // in a cross-origin document this app cannot observe.
30
+ startToolServerOAuth: (workspaceId: string, id: string) =>
31
+ send(startToolServerOAuthContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
32
+
33
+ disconnectToolServerOAuth: (workspaceId: string, id: string) =>
34
+ send(disconnectToolServerOAuthContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
35
+
36
+ // Finish a grant with what the vendor's redirect carried. NOT workspace-prefixed: the board is
37
+ // sealed into the `state`, so the caller does not know it and could not be trusted with it
38
+ // anyway. This is the request that makes the flow's session, user binding and permission
39
+ // re-check enforceable, which a vendor's redirect landing on the backend never could.
40
+ completeToolServerOAuth: (body: { code: string; state: string }) =>
41
+ send(completeToolServerOAuthContract, { body }),
20
42
  }
21
43
  }
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import McpOAuthCallbackScreen from '~/components/settings/McpOAuthCallbackScreen.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <McpOAuthCallbackScreen />
7
+ </template>
@@ -1,6 +1,8 @@
1
1
  import type { Ref } from 'vue'
2
+ import { SSO_ERROR_FRAGMENT_KEY, parseSsoErrorReason } from '@cat-factory/contracts'
2
3
  import type { LocalModeConfig } from '@cat-factory/contracts'
3
4
  import type { AuthUser } from '~/types/domain'
5
+ import type { SsoLoginFailure } from '~/utils/sso'
4
6
 
5
7
  /**
6
8
  * Shared reactive state + injected dependencies the auth-store redirect factory closes over.
@@ -13,6 +15,8 @@ export interface AuthRedirectContext {
13
15
  token: Ref<string | null>
14
16
  localMode: Ref<LocalModeConfig | null>
15
17
  mothershipError: Ref<string | null>
18
+ /** Why the last enterprise-SSO round-trip produced no session, when one was refused. */
19
+ ssoError: Ref<SsoLoginFailure | null>
16
20
  /** Apply a freshly-minted token + user (the session factory's setter). */
17
21
  applySession: (result: { token: string; user: AuthUser }) => void
18
22
  }
@@ -23,7 +27,7 @@ export interface AuthRedirectContext {
23
27
  * mode — exchanging a returning MOTHERSHIP session for a local one.
24
28
  */
25
29
  export function createAuthRedirectActions(ctx: AuthRedirectContext) {
26
- const { api, token, localMode, mothershipError, applySession } = ctx
30
+ const { api, token, localMode, mothershipError, ssoError, applySession } = ctx
27
31
 
28
32
  /** Pull a token handed back in the post-login URL fragment (#token=…). */
29
33
  function consumeRedirectToken() {
@@ -35,6 +39,27 @@ export function createAuthRedirectActions(ctx: AuthRedirectContext) {
35
39
  history.replaceState(null, '', window.location.pathname + window.location.search)
36
40
  }
37
41
 
42
+ /**
43
+ * Pull the reason a REFUSED enterprise-SSO round-trip handed back (`#sso_error=<reason>`), the
44
+ * sibling of the `#token=` a successful one sets.
45
+ *
46
+ * A fragment rather than a query for the same reason the token is: it never reaches a server log
47
+ * or a `Referer`, and the reason names the deployment's own admission rules. Stripped from the
48
+ * URL afterwards so a reload doesn't resurrect a stale failure.
49
+ */
50
+ function consumeSsoError() {
51
+ if (typeof window === 'undefined') return
52
+ const match = new RegExp(`(?:^#|[#&])${SSO_ERROR_FRAGMENT_KEY}=([^&]+)`).exec(
53
+ window.location.hash,
54
+ )
55
+ if (!match) return
56
+ // An unrecognised value came from a newer backend than this build: reported as `unknown`
57
+ // rather than rendered raw, and never dropped — the user clicked a button and is owed an
58
+ // answer about why they are back here.
59
+ ssoError.value = parseSsoErrorReason(decodeURIComponent(match[1]!)) ?? 'unknown'
60
+ history.replaceState(null, '', window.location.pathname + window.location.search)
61
+ }
62
+
38
63
  /**
39
64
  * Mothership mode: when the mothership OAuth redirect returns here (flagged
40
65
  * `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
@@ -101,6 +126,7 @@ export function createAuthRedirectActions(ctx: AuthRedirectContext) {
101
126
 
102
127
  return {
103
128
  consumeRedirectToken,
129
+ consumeSsoError,
104
130
  maybeConnectMothership,
105
131
  signInViaMothership,
106
132
  maybeAcceptInvite,
@@ -44,6 +44,16 @@ export function createAuthSessionActions(ctx: AuthSessionContext) {
44
44
  window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
45
45
  }
46
46
 
47
+ /**
48
+ * Send the browser to the deployment's OWN identity provider (enterprise SSO), returning here
49
+ * after. One entry point whichever IdP is configured: the backend resolves the provider from its
50
+ * discovery document, so there is nothing per-vendor for the SPA to know.
51
+ */
52
+ function loginWithSso(invite?: string) {
53
+ if (typeof window === 'undefined') return
54
+ window.location.href = `${apiBase}/auth/sso/login?${redirectTarget(invite)}`
55
+ }
56
+
47
57
  /** Apply a freshly-minted token + user (from password signup/login). */
48
58
  function applySession(result: { token: string; user: AuthUser }) {
49
59
  token.value = result.token
@@ -107,6 +117,7 @@ export function createAuthSessionActions(ctx: AuthSessionContext) {
107
117
  applySession,
108
118
  login,
109
119
  loginWithGoogle,
120
+ loginWithSso,
110
121
  signup,
111
122
  passwordLogin,
112
123
  patLogin,
@@ -0,0 +1,64 @@
1
+ import { ref } from 'vue'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+ import { createAuthRedirectActions } from './mothership'
4
+ import type { SsoLoginFailure } from '~/utils/sso'
5
+
6
+ // The other half of the SSO refusal contract: the backend lands the browser here with a
7
+ // machine-readable reason in the fragment, and this is what turns it into state the login screen
8
+ // renders. Worth pinning because the failure mode is silent — a reason the SPA drops leaves the
9
+ // user back on the same sign-in button with no explanation for the click that just failed.
10
+
11
+ /** Point `window.location` + `history` at a URL the consumer can read and rewrite. */
12
+ function setUrl(path: string): void {
13
+ history.replaceState(null, '', path)
14
+ }
15
+
16
+ function actions() {
17
+ const ssoError = ref<SsoLoginFailure | null>(null)
18
+ const factory = createAuthRedirectActions({
19
+ api: {} as never,
20
+ token: ref<string | null>(null),
21
+ localMode: ref(null),
22
+ mothershipError: ref<string | null>(null),
23
+ ssoError,
24
+ applySession: () => {},
25
+ })
26
+ return { ...factory, ssoError }
27
+ }
28
+
29
+ describe('consumeSsoError', () => {
30
+ beforeEach(() => setUrl('/'))
31
+
32
+ it('reads a known reason off the fragment', () => {
33
+ setUrl('/#sso_error=group_required')
34
+ const a = actions()
35
+ a.consumeSsoError()
36
+ expect(a.ssoError.value).toBe('group_required')
37
+ })
38
+
39
+ it('strips the fragment so a reload does not resurrect a stale failure', () => {
40
+ setUrl('/board?ws=ws_1#sso_error=state_invalid')
41
+ actions().consumeSsoError()
42
+ expect(window.location.hash).toBe('')
43
+ // The rest of the URL is left alone.
44
+ expect(window.location.pathname + window.location.search).toBe('/board?ws=ws_1')
45
+ })
46
+
47
+ it('reports an unrecognised reason as `unknown` rather than dropping it', () => {
48
+ // A reason from a NEWER backend than this build. Rendering the raw wire token to a user is
49
+ // wrong; showing nothing after a failed sign-in is worse.
50
+ setUrl('/#sso_error=reason_from_the_future')
51
+ const a = actions()
52
+ a.consumeSsoError()
53
+ expect(a.ssoError.value).toBe('unknown')
54
+ })
55
+
56
+ it('leaves the state untouched when the fragment carries no SSO error', () => {
57
+ setUrl('/#token=abc')
58
+ const a = actions()
59
+ a.consumeSsoError()
60
+ expect(a.ssoError.value).toBeNull()
61
+ // The session token is another consumer's to read, so the fragment must survive.
62
+ expect(window.location.hash).toBe('#token=abc')
63
+ })
64
+ })