@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.
- package/app/components/auth/LoginScreen.vue +59 -0
- package/app/components/board/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/TaskCard.vue +1 -0
- package/app/components/inputGate/InputGateNotice.vue +3 -0
- package/app/components/judge/JudgeResultView.vue +13 -0
- package/app/components/panels/inspector/TaskExecution.vue +1 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +8 -0
- package/app/components/settings/McpOAuthCallbackScreen.vue +103 -0
- package/app/components/settings/ToolServerChecklist.vue +111 -0
- package/app/composables/api/toolServers.ts +23 -1
- package/app/pages/mcp-oauth-callback.vue +7 -0
- package/app/stores/auth/mothership.ts +27 -1
- package/app/stores/auth/session.ts +11 -0
- package/app/stores/auth/ssoError.spec.ts +64 -0
- package/app/stores/auth.ts +36 -3
- package/app/stores/toolServers.ts +57 -1
- package/app/types/toolServers.ts +2 -0
- package/app/utils/sso.spec.ts +39 -0
- package/app/utils/sso.ts +41 -0
- package/i18n/locales/de.json +44 -2
- package/i18n/locales/en.json +50 -2
- package/i18n/locales/es.json +44 -2
- package/i18n/locales/fr.json +44 -2
- package/i18n/locales/he.json +44 -2
- package/i18n/locales/it.json +44 -2
- package/i18n/locales/ja.json +44 -2
- package/i18n/locales/pl.json +44 -2
- package/i18n/locales/tr.json +44 -2
- package/i18n/locales/uk.json +44 -2
- package/package.json +2 -2
|
@@ -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
|
|
@@ -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 {
|
|
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
|
}
|
|
@@ -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
|
+
})
|