@cat-factory/app 0.153.3 → 0.154.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/settings/WorkspaceSettingsPanel.vue +19 -0
- package/app/composables/useRunDeepLink.spec.ts +51 -0
- package/app/composables/useRunDeepLink.ts +91 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/auth/mothership.ts +108 -0
- package/app/stores/auth/session.ts +118 -0
- package/app/stores/auth.ts +17 -167
- package/app/stores/board/mutations.ts +6 -191
- package/app/stores/board/placement.ts +203 -0
- package/app/stores/board.ts +5 -1
- package/app/stores/execution/commands.ts +197 -0
- package/app/stores/execution.ts +12 -171
- package/app/stores/github/connection.ts +60 -0
- package/app/stores/github/context.ts +46 -0
- package/app/stores/github/repoActions.ts +151 -0
- package/app/stores/github.ts +30 -183
- package/app/stores/initiative/curation.ts +89 -0
- package/app/stores/initiative/planning.ts +121 -0
- package/app/stores/initiative.ts +14 -175
- package/app/stores/workspace/hydrate.ts +105 -0
- package/app/stores/workspace.ts +6 -94
- package/app/stores/workspaceSettings.ts +1 -0
- package/i18n/locales/de.json +5 -0
- package/i18n/locales/en.json +5 -0
- package/i18n/locales/es.json +5 -0
- package/i18n/locales/fr.json +5 -0
- package/i18n/locales/he.json +5 -0
- package/i18n/locales/it.json +5 -0
- package/i18n/locales/ja.json +5 -0
- package/i18n/locales/pl.json +5 -0
- package/i18n/locales/tr.json +5 -0
- package/i18n/locales/uk.json +5 -0
- package/package.json +2 -2
|
@@ -149,6 +149,7 @@ const draft = reactive({
|
|
|
149
149
|
taskLimitShared: 5 as number,
|
|
150
150
|
perType: {} as Record<LimitTaskType, number>,
|
|
151
151
|
storeAgentContext: true,
|
|
152
|
+
publishPrVerificationReport: true,
|
|
152
153
|
artifactRetentionDays: 14,
|
|
153
154
|
kaizenEnabled: true,
|
|
154
155
|
reviewFrictionMode: 'off' as ReviewFrictionMode,
|
|
@@ -167,6 +168,7 @@ function hydrate() {
|
|
|
167
168
|
const pt = s.taskLimitPerType ?? {}
|
|
168
169
|
for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
|
|
169
170
|
draft.storeAgentContext = s.storeAgentContext
|
|
171
|
+
draft.publishPrVerificationReport = s.publishPrVerificationReport
|
|
170
172
|
draft.artifactRetentionDays = s.artifactRetentionDays
|
|
171
173
|
draft.kaizenEnabled = s.kaizenEnabled
|
|
172
174
|
draft.reviewFrictionMode = s.reviewFrictionMode
|
|
@@ -222,6 +224,7 @@ async function save() {
|
|
|
222
224
|
)
|
|
223
225
|
: null,
|
|
224
226
|
storeAgentContext: draft.storeAgentContext,
|
|
227
|
+
publishPrVerificationReport: draft.publishPrVerificationReport,
|
|
225
228
|
artifactRetentionDays: draft.artifactRetentionDays,
|
|
226
229
|
kaizenEnabled: draft.kaizenEnabled,
|
|
227
230
|
reviewFrictionMode: draft.reviewFrictionMode,
|
|
@@ -421,6 +424,22 @@ async function save() {
|
|
|
421
424
|
</label>
|
|
422
425
|
</section>
|
|
423
426
|
|
|
427
|
+
<!-- Engine-maintained PR verification report -->
|
|
428
|
+
<section class="space-y-2">
|
|
429
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
430
|
+
{{ t('settings.workspaceSettings.prReport.heading') }}
|
|
431
|
+
</h3>
|
|
432
|
+
<p class="text-[11px] text-slate-400">
|
|
433
|
+
{{ t('settings.workspaceSettings.prReport.body') }}
|
|
434
|
+
</p>
|
|
435
|
+
<label class="flex items-center gap-2">
|
|
436
|
+
<USwitch v-model="draft.publishPrVerificationReport" size="sm" />
|
|
437
|
+
<span class="text-sm text-slate-200">{{
|
|
438
|
+
t('settings.workspaceSettings.prReport.toggle')
|
|
439
|
+
}}</span>
|
|
440
|
+
</label>
|
|
441
|
+
</section>
|
|
442
|
+
|
|
424
443
|
<!-- Visual-confirmation artifact retention -->
|
|
425
444
|
<section class="space-y-2">
|
|
426
445
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { captureRunDeepLink } from './useRunDeepLink'
|
|
4
|
+
|
|
5
|
+
/** Point `window.location` + `history` at a URL the capture can read and rewrite. */
|
|
6
|
+
function setUrl(search: string): void {
|
|
7
|
+
history.replaceState(null, '', `/${search}`)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('captureRunDeepLink', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
setActivePinia(createPinia())
|
|
13
|
+
setUrl('')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns null when the URL carries no run link', () => {
|
|
17
|
+
setUrl('?other=1')
|
|
18
|
+
expect(captureRunDeepLink()).toBeNull()
|
|
19
|
+
// An unrelated param is left alone.
|
|
20
|
+
expect(window.location.search).toBe('?other=1')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('parses the report link the engine emits', () => {
|
|
24
|
+
setUrl('?ws=ws_1&block=blk_1&run=exec_1&view=observability')
|
|
25
|
+
expect(captureRunDeepLink()).toEqual({
|
|
26
|
+
workspaceId: 'ws_1',
|
|
27
|
+
blockId: 'blk_1',
|
|
28
|
+
runId: 'exec_1',
|
|
29
|
+
view: 'observability',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('strips its own params so a reload does not re-open the panel', () => {
|
|
34
|
+
setUrl('?ws=ws_1&run=exec_1&view=observability&keep=yes')
|
|
35
|
+
captureRunDeepLink()
|
|
36
|
+
// Only the link's own params are consumed; anything else survives.
|
|
37
|
+
expect(window.location.search).toBe('?keep=yes')
|
|
38
|
+
// …and a second read finds nothing left to replay.
|
|
39
|
+
expect(captureRunDeepLink()).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('tolerates a partial link (run id alone still resolves)', () => {
|
|
43
|
+
setUrl('?run=exec_1')
|
|
44
|
+
expect(captureRunDeepLink()).toEqual({
|
|
45
|
+
workspaceId: null,
|
|
46
|
+
blockId: null,
|
|
47
|
+
runId: 'exec_1',
|
|
48
|
+
view: null,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time replay of a RUN deep link — `?ws=<id>&block=<id>&run=<id>&view=observability`.
|
|
3
|
+
*
|
|
4
|
+
* The engine's PR verification report links each PR back to the run's observability panel
|
|
5
|
+
* (Model activity / Provided context), built from the deployment's public app URL. The SPA is a
|
|
6
|
+
* single canvas with no URL identity for anything, so this is the narrow consumer that makes
|
|
7
|
+
* that link resolve: pin the board before the snapshot loads, then — once the board is ready —
|
|
8
|
+
* select the task and open the panel for the run.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow. The GENERAL parser (every entity, every window, plus state→URL sync) is
|
|
11
|
+
* slice 4 of `docs/initiatives/global-search-and-deep-links.md`; when it lands, this composable
|
|
12
|
+
* is deleted rather than kept alongside it.
|
|
13
|
+
*/
|
|
14
|
+
import { watch } from 'vue'
|
|
15
|
+
import { useUiStore } from '../stores/ui'
|
|
16
|
+
import { useWorkspaceStore } from '../stores/workspace'
|
|
17
|
+
|
|
18
|
+
/** The parsed link, or null when the URL carried none. */
|
|
19
|
+
interface PendingRunLink {
|
|
20
|
+
workspaceId: string | null
|
|
21
|
+
blockId: string | null
|
|
22
|
+
runId: string
|
|
23
|
+
view: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RUN_LINK_PARAMS = ['ws', 'block', 'run', 'view'] as const
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read (and strip) a run deep link from the current URL. Called BEFORE `workspace.init()` so
|
|
30
|
+
* the pinned board id is in place when `resolveActiveBoard` picks which board to open. The
|
|
31
|
+
* params are removed from the URL — mirroring the `?invite=` / `?infraSetup=` handling — so a
|
|
32
|
+
* reload doesn't re-open the panel and the link isn't left sitting in history.
|
|
33
|
+
*/
|
|
34
|
+
export function captureRunDeepLink(): PendingRunLink | null {
|
|
35
|
+
if (typeof window === 'undefined') return null
|
|
36
|
+
const params = new URLSearchParams(window.location.search)
|
|
37
|
+
const runId = params.get('run')
|
|
38
|
+
if (!runId) return null
|
|
39
|
+
|
|
40
|
+
const link: PendingRunLink = {
|
|
41
|
+
workspaceId: params.get('ws'),
|
|
42
|
+
blockId: params.get('block'),
|
|
43
|
+
runId,
|
|
44
|
+
view: params.get('view'),
|
|
45
|
+
}
|
|
46
|
+
for (const key of RUN_LINK_PARAMS) params.delete(key)
|
|
47
|
+
const qs = params.toString()
|
|
48
|
+
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
49
|
+
return link
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Capture a run deep link and, once the board has hydrated, apply it. Returns nothing; the
|
|
54
|
+
* caller mounts it once, next to `workspace.init()`.
|
|
55
|
+
*
|
|
56
|
+
* Applying WAITS for `workspace.ready`: opening the panel against a half-loaded board would
|
|
57
|
+
* read an execution the snapshot hasn't delivered yet. The watcher is one-shot — a later
|
|
58
|
+
* refresh must not re-open a panel the user closed.
|
|
59
|
+
*/
|
|
60
|
+
export function useRunDeepLink(): void {
|
|
61
|
+
const link = captureRunDeepLink()
|
|
62
|
+
if (!link) return
|
|
63
|
+
|
|
64
|
+
const workspace = useWorkspaceStore()
|
|
65
|
+
const ui = useUiStore()
|
|
66
|
+
|
|
67
|
+
// Pin the board the link names BEFORE init resolves the active one, so the run's own board
|
|
68
|
+
// opens rather than whichever one was last used on this device.
|
|
69
|
+
if (link.workspaceId) workspace.workspaceId = link.workspaceId
|
|
70
|
+
|
|
71
|
+
// Applied at most once — a later refresh must not re-open a panel the user closed. The guard
|
|
72
|
+
// is a flag rather than `{ once: true }` (which would burn the single run on the immediate
|
|
73
|
+
// not-ready tick and never navigate) and `stop` is a `let` rather than a `const` (with
|
|
74
|
+
// `immediate`, the callback runs synchronously BEFORE the binding initialises, so an
|
|
75
|
+
// already-hydrated board would hit the temporal dead zone instead of navigating).
|
|
76
|
+
let applied = false
|
|
77
|
+
let stop: (() => void) | undefined
|
|
78
|
+
stop = watch(
|
|
79
|
+
() => workspace.ready,
|
|
80
|
+
(ready) => {
|
|
81
|
+
if (!ready || applied) return
|
|
82
|
+
applied = true
|
|
83
|
+
if (link.blockId) ui.select(link.blockId)
|
|
84
|
+
// `observability` is the only view this narrow parser serves — an unknown view still
|
|
85
|
+
// lands the user on the right board and task rather than failing the navigation.
|
|
86
|
+
if (link.view === 'observability') ui.openObservability(link.runId)
|
|
87
|
+
stop?.()
|
|
88
|
+
},
|
|
89
|
+
{ immediate: true },
|
|
90
|
+
)
|
|
91
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -145,6 +145,10 @@ useKeyboardShortcuts()
|
|
|
145
145
|
|
|
146
146
|
// Load the board from the backend before rendering it.
|
|
147
147
|
onMounted(() => {
|
|
148
|
+
// Consume a run deep link (`?ws=…&block=…&run=…&view=observability`) BEFORE init, so the
|
|
149
|
+
// board it names is the one that opens. This is what makes the observability link on an
|
|
150
|
+
// engine-published PR verification report resolve.
|
|
151
|
+
useRunDeepLink()
|
|
148
152
|
void workspace.init()
|
|
149
153
|
// Honour a `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`): open the Infrastructure
|
|
150
154
|
// window pre-seeded with the provisioned connection so the user only pastes the token + saves.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { LocalModeConfig } from '@cat-factory/contracts'
|
|
3
|
+
import type { AuthUser } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared reactive state + injected dependencies the auth-store redirect factory closes over.
|
|
7
|
+
* Created once in the `auth` store setup and threaded into {@link createAuthRedirectActions} so
|
|
8
|
+
* the split operations stay behaviourally identical to the original single-closure store — a
|
|
9
|
+
* size-only extraction mirroring `stores/board/` and `stores/pipelines/`, not a new seam.
|
|
10
|
+
*/
|
|
11
|
+
export interface AuthRedirectContext {
|
|
12
|
+
api: ReturnType<typeof useApi>
|
|
13
|
+
token: Ref<string | null>
|
|
14
|
+
localMode: Ref<LocalModeConfig | null>
|
|
15
|
+
mothershipError: Ref<string | null>
|
|
16
|
+
/** Apply a freshly-minted token + user (the session factory's setter). */
|
|
17
|
+
applySession: (result: { token: string; user: AuthUser }) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The URL-fragment / redirect handling the boot handshake runs before it resolves a session:
|
|
22
|
+
* capturing a token handed back by our OWN backend's OAuth round-trip, and — in mothership
|
|
23
|
+
* mode — exchanging a returning MOTHERSHIP session for a local one.
|
|
24
|
+
*/
|
|
25
|
+
export function createAuthRedirectActions(ctx: AuthRedirectContext) {
|
|
26
|
+
const { api, token, localMode, mothershipError, applySession } = ctx
|
|
27
|
+
|
|
28
|
+
/** Pull a token handed back in the post-login URL fragment (#token=…). */
|
|
29
|
+
function consumeRedirectToken() {
|
|
30
|
+
if (typeof window === 'undefined') return
|
|
31
|
+
const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
|
|
32
|
+
if (!match) return
|
|
33
|
+
token.value = decodeURIComponent(match[1]!)
|
|
34
|
+
// Strip the token from the URL so it isn't left in history or shared.
|
|
35
|
+
history.replaceState(null, '', window.location.pathname + window.location.search)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mothership mode: when the mothership OAuth redirect returns here (flagged
|
|
40
|
+
* `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
|
|
41
|
+
* Hand it to our OWN node, which exchanges it for a cached machine token and returns a LOCAL
|
|
42
|
+
* session for the same user. Returns true when it handled the redirect (so the caller skips
|
|
43
|
+
* the normal `consumeRedirectToken`, which would wrongly store the mothership session locally).
|
|
44
|
+
*/
|
|
45
|
+
async function maybeConnectMothership(): Promise<boolean> {
|
|
46
|
+
if (typeof window === 'undefined') return false
|
|
47
|
+
const params = new URLSearchParams(window.location.search)
|
|
48
|
+
if (params.get('mothership_connect') !== '1') return false
|
|
49
|
+
const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
|
|
50
|
+
const session = match ? decodeURIComponent(match[1]!) : null
|
|
51
|
+
// Clean the flag + fragment from the URL regardless of outcome, so it isn't left in history.
|
|
52
|
+
params.delete('mothership_connect')
|
|
53
|
+
const qs = params.toString()
|
|
54
|
+
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
55
|
+
if (!session) return true
|
|
56
|
+
try {
|
|
57
|
+
const result = await api.connectMothership(session)
|
|
58
|
+
applySession({ token: result.session, user: result.user })
|
|
59
|
+
mothershipError.value = null
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Surface the failure so the login screen shows it, rather than silently dropping the
|
|
62
|
+
// user back on the sign-in button as if the click did nothing. The captured session is
|
|
63
|
+
// already stripped from the URL, so recovery is a fresh "Sign in via mothership".
|
|
64
|
+
mothershipError.value =
|
|
65
|
+
err instanceof Error ? err.message : 'Could not connect to the mothership'
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mothership mode: sign in through the hosted mothership. The mothership owns identity + the
|
|
72
|
+
* allowlist, so we send the browser to ITS OAuth and return here flagged for the connect
|
|
73
|
+
* exchange ({@link maybeConnectMothership}). No-op if the mothership URL isn't known.
|
|
74
|
+
*/
|
|
75
|
+
function signInViaMothership() {
|
|
76
|
+
if (typeof window === 'undefined') return
|
|
77
|
+
const base = localMode.value?.mothershipUrl
|
|
78
|
+
if (!base) return
|
|
79
|
+
mothershipError.value = null
|
|
80
|
+
const here = new URL(window.location.origin + window.location.pathname)
|
|
81
|
+
here.searchParams.set('mothership_connect', '1')
|
|
82
|
+
const redirect = new URLSearchParams({ redirect: here.toString() })
|
|
83
|
+
window.location.href = `${base.replace(/\/$/, '')}/auth/login?${redirect}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Redeem an `?invite=` token in the URL for the signed-in user, then clean the URL. */
|
|
87
|
+
async function maybeAcceptInvite() {
|
|
88
|
+
if (typeof window === 'undefined') return
|
|
89
|
+
const params = new URLSearchParams(window.location.search)
|
|
90
|
+
const inviteToken = params.get('invite')
|
|
91
|
+
if (!inviteToken) return
|
|
92
|
+
try {
|
|
93
|
+
await api.acceptInvite(inviteToken)
|
|
94
|
+
} catch {
|
|
95
|
+
// Stale/already-accepted invite — ignore and let the app load normally.
|
|
96
|
+
}
|
|
97
|
+
params.delete('invite')
|
|
98
|
+
const qs = params.toString()
|
|
99
|
+
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
consumeRedirectToken,
|
|
104
|
+
maybeConnectMothership,
|
|
105
|
+
signInViaMothership,
|
|
106
|
+
maybeAcceptInvite,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { AuthUser } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared reactive state + injected dependencies the auth-store sign-in factory closes over.
|
|
6
|
+
* Created once in the `auth` store setup and threaded into {@link createAuthSessionActions} so
|
|
7
|
+
* the split operations stay behaviourally identical to the original single-closure store — a
|
|
8
|
+
* size-only extraction mirroring `stores/board/` and `stores/pipelines/`, not a new seam.
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthSessionContext {
|
|
11
|
+
api: ReturnType<typeof useApi>
|
|
12
|
+
/** The backend's base URL, for the browser-navigating OAuth entry points. */
|
|
13
|
+
apiBase: string
|
|
14
|
+
token: Ref<string | null>
|
|
15
|
+
user: Ref<AuthUser | null>
|
|
16
|
+
autoLoginProvider: Ref<'github' | 'gitlab' | null>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The sign-in / sign-out operations: the browser-navigating OAuth entry points, the
|
|
21
|
+
* email-password + PAT credential flows, and the session teardown paths (explicit logout vs the
|
|
22
|
+
* API client's 401 handler, which differ in whether they forget the remembered PAT provider).
|
|
23
|
+
*/
|
|
24
|
+
export function createAuthSessionActions(ctx: AuthSessionContext) {
|
|
25
|
+
const { api, apiBase, token, user, autoLoginProvider } = ctx
|
|
26
|
+
|
|
27
|
+
/** Build a post-login redirect back to the current page, with an optional invite. */
|
|
28
|
+
function redirectTarget(invite?: string): string {
|
|
29
|
+
const here = window.location.origin + window.location.pathname
|
|
30
|
+
const params = new URLSearchParams({ redirect: here })
|
|
31
|
+
if (invite) params.set('invite', invite)
|
|
32
|
+
return params.toString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Send the browser to the backend's GitHub login, returning here after. */
|
|
36
|
+
function login(invite?: string) {
|
|
37
|
+
if (typeof window === 'undefined') return
|
|
38
|
+
window.location.href = `${apiBase}/auth/login?${redirectTarget(invite)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Send the browser to the backend's Google login, returning here after. */
|
|
42
|
+
function loginWithGoogle(invite?: string) {
|
|
43
|
+
if (typeof window === 'undefined') return
|
|
44
|
+
window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Apply a freshly-minted token + user (from password signup/login). */
|
|
48
|
+
function applySession(result: { token: string; user: AuthUser }) {
|
|
49
|
+
token.value = result.token
|
|
50
|
+
user.value = result.user
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register a new email/password user (optionally redeeming an invite). */
|
|
54
|
+
async function signup(body: { email: string; password: string; name?: string; invite?: string }) {
|
|
55
|
+
applySession(await api.signup(body))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sign in with email/password. */
|
|
59
|
+
async function passwordLogin(body: { email: string; password: string }) {
|
|
60
|
+
applySession(await api.passwordLogin(body))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Local mode: sign in as the account a source-control PAT belongs to. `token` omitted
|
|
65
|
+
* uses the server-configured PAT (one-click); otherwise a pasted token. Resolves to the
|
|
66
|
+
* SAME canonical user as GitHub OAuth would (keyed on the provider's numeric id).
|
|
67
|
+
*/
|
|
68
|
+
async function patLogin(body: { provider: 'github' | 'gitlab'; token?: string }) {
|
|
69
|
+
applySession(await api.patLogin(body))
|
|
70
|
+
// Remember the choice so a later load re-mints the session from the env PAT silently.
|
|
71
|
+
autoLoginProvider.value = body.provider
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Request a password-reset link by email (always resolves; never reveals existence). */
|
|
75
|
+
async function forgotPassword(email: string) {
|
|
76
|
+
await api.forgotPassword({ email })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
|
|
80
|
+
async function resetPassword(token: string, password: string) {
|
|
81
|
+
await api.resetPassword({ token, password })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Drop the local session (sessions are stateless server-side). */
|
|
85
|
+
function logout() {
|
|
86
|
+
api.logout().catch(() => {})
|
|
87
|
+
token.value = null
|
|
88
|
+
user.value = null
|
|
89
|
+
// Forget the remembered provider so logout sticks (otherwise bootstrap would
|
|
90
|
+
// immediately re-mint a session from the env PAT).
|
|
91
|
+
autoLoginProvider.value = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Called by the API client when a request comes back 401. Drops the dead session but KEEPS
|
|
96
|
+
* the remembered provider (unlike logout): a 401 from an expired/rotated token or a
|
|
97
|
+
* transient blip should let the next load silently re-mint from the env PAT, not force the
|
|
98
|
+
* login screen. The guarded re-mint in `bootstrap` clears the choice itself if it genuinely
|
|
99
|
+
* fails (PAT removed/revoked), so there's no re-login loop.
|
|
100
|
+
*/
|
|
101
|
+
function handleUnauthorized() {
|
|
102
|
+
token.value = null
|
|
103
|
+
user.value = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
applySession,
|
|
108
|
+
login,
|
|
109
|
+
loginWithGoogle,
|
|
110
|
+
signup,
|
|
111
|
+
passwordLogin,
|
|
112
|
+
patLogin,
|
|
113
|
+
forgotPassword,
|
|
114
|
+
resetPassword,
|
|
115
|
+
logout,
|
|
116
|
+
handleUnauthorized,
|
|
117
|
+
}
|
|
118
|
+
}
|
package/app/stores/auth.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { defineStore } from 'pinia'
|
|
|
7
7
|
import { computed, ref } from 'vue'
|
|
8
8
|
import type { AuthUser } from '~/types/domain'
|
|
9
9
|
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
10
|
+
import { createAuthSessionActions } from '~/stores/auth/session'
|
|
11
|
+
import { createAuthRedirectActions } from '~/stores/auth/mothership'
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* "Login with GitHub" session state. The backend mints a signed session token
|
|
@@ -121,63 +123,19 @@ export const useAuthStore = defineStore(
|
|
|
121
123
|
return true
|
|
122
124
|
})
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* Hand it to our OWN node, which exchanges it for a cached machine token and returns a LOCAL
|
|
138
|
-
* session for the same user. Returns true when it handled the redirect (so the caller skips
|
|
139
|
-
* the normal `consumeRedirectToken`, which would wrongly store the mothership session locally).
|
|
140
|
-
*/
|
|
141
|
-
async function maybeConnectMothership(): Promise<boolean> {
|
|
142
|
-
if (typeof window === 'undefined') return false
|
|
143
|
-
const params = new URLSearchParams(window.location.search)
|
|
144
|
-
if (params.get('mothership_connect') !== '1') return false
|
|
145
|
-
const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
|
|
146
|
-
const session = match ? decodeURIComponent(match[1]!) : null
|
|
147
|
-
// Clean the flag + fragment from the URL regardless of outcome, so it isn't left in history.
|
|
148
|
-
params.delete('mothership_connect')
|
|
149
|
-
const qs = params.toString()
|
|
150
|
-
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
151
|
-
if (!session) return true
|
|
152
|
-
try {
|
|
153
|
-
const result = await api.connectMothership(session)
|
|
154
|
-
applySession({ token: result.session, user: result.user })
|
|
155
|
-
mothershipError.value = null
|
|
156
|
-
} catch (err) {
|
|
157
|
-
// Surface the failure so the login screen shows it, rather than silently dropping the
|
|
158
|
-
// user back on the sign-in button as if the click did nothing. The captured session is
|
|
159
|
-
// already stripped from the URL, so recovery is a fresh "Sign in via mothership".
|
|
160
|
-
mothershipError.value =
|
|
161
|
-
err instanceof Error ? err.message : 'Could not connect to the mothership'
|
|
162
|
-
}
|
|
163
|
-
return true
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Mothership mode: sign in through the hosted mothership. The mothership owns identity + the
|
|
168
|
-
* allowlist, so we send the browser to ITS OAuth and return here flagged for the connect
|
|
169
|
-
* exchange (`maybeConnectMothership`). No-op if the mothership URL isn't known.
|
|
170
|
-
*/
|
|
171
|
-
function signInViaMothership() {
|
|
172
|
-
if (typeof window === 'undefined') return
|
|
173
|
-
const base = localMode.value?.mothershipUrl
|
|
174
|
-
if (!base) return
|
|
175
|
-
mothershipError.value = null
|
|
176
|
-
const here = new URL(window.location.origin + window.location.pathname)
|
|
177
|
-
here.searchParams.set('mothership_connect', '1')
|
|
178
|
-
const redirect = new URLSearchParams({ redirect: here.toString() })
|
|
179
|
-
window.location.href = `${base.replace(/\/$/, '')}/auth/login?${redirect}`
|
|
180
|
-
}
|
|
126
|
+
// The sign-in / sign-out operations and the redirect-fragment handling, split into cohesive
|
|
127
|
+
// factories sharing the state above (a size-only extraction mirroring `stores/board/` —
|
|
128
|
+
// behaviour is identical to the former in-closure functions). The redirect factory applies a
|
|
129
|
+
// mothership-exchanged session through the session factory's setter.
|
|
130
|
+
const { applySession, ...session } = createAuthSessionActions({
|
|
131
|
+
api,
|
|
132
|
+
apiBase,
|
|
133
|
+
token,
|
|
134
|
+
user,
|
|
135
|
+
autoLoginProvider,
|
|
136
|
+
})
|
|
137
|
+
const { consumeRedirectToken, maybeConnectMothership, signInViaMothership, maybeAcceptInvite } =
|
|
138
|
+
createAuthRedirectActions({ api, token, localMode, mothershipError, applySession })
|
|
181
139
|
|
|
182
140
|
/** Resolve auth state: capture any redirect token, then check the backend. */
|
|
183
141
|
async function bootstrap() {
|
|
@@ -237,7 +195,7 @@ export const useAuthStore = defineStore(
|
|
|
237
195
|
localMode.value.patLogin?.configured.includes(autoLoginProvider.value)
|
|
238
196
|
) {
|
|
239
197
|
try {
|
|
240
|
-
await patLogin({ provider: autoLoginProvider.value })
|
|
198
|
+
await session.patLogin({ provider: autoLoginProvider.value })
|
|
241
199
|
} catch {
|
|
242
200
|
autoLoginProvider.value = null
|
|
243
201
|
}
|
|
@@ -248,106 +206,6 @@ export const useAuthStore = defineStore(
|
|
|
248
206
|
ready.value = true
|
|
249
207
|
}
|
|
250
208
|
|
|
251
|
-
/** Redeem an `?invite=` token in the URL for the signed-in user, then clean the URL. */
|
|
252
|
-
async function maybeAcceptInvite() {
|
|
253
|
-
if (typeof window === 'undefined') return
|
|
254
|
-
const params = new URLSearchParams(window.location.search)
|
|
255
|
-
const inviteToken = params.get('invite')
|
|
256
|
-
if (!inviteToken) return
|
|
257
|
-
try {
|
|
258
|
-
await api.acceptInvite(inviteToken)
|
|
259
|
-
} catch {
|
|
260
|
-
// Stale/already-accepted invite — ignore and let the app load normally.
|
|
261
|
-
}
|
|
262
|
-
params.delete('invite')
|
|
263
|
-
const qs = params.toString()
|
|
264
|
-
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Build a post-login redirect back to the current page, with an optional invite. */
|
|
268
|
-
function redirectTarget(invite?: string): string {
|
|
269
|
-
const here = window.location.origin + window.location.pathname
|
|
270
|
-
const params = new URLSearchParams({ redirect: here })
|
|
271
|
-
if (invite) params.set('invite', invite)
|
|
272
|
-
return params.toString()
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Send the browser to the backend's GitHub login, returning here after. */
|
|
276
|
-
function login(invite?: string) {
|
|
277
|
-
if (typeof window === 'undefined') return
|
|
278
|
-
window.location.href = `${apiBase}/auth/login?${redirectTarget(invite)}`
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Send the browser to the backend's Google login, returning here after. */
|
|
282
|
-
function loginWithGoogle(invite?: string) {
|
|
283
|
-
if (typeof window === 'undefined') return
|
|
284
|
-
window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Apply a freshly-minted token + user (from password signup/login). */
|
|
288
|
-
function applySession(result: { token: string; user: AuthUser }) {
|
|
289
|
-
token.value = result.token
|
|
290
|
-
user.value = result.user
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Register a new email/password user (optionally redeeming an invite). */
|
|
294
|
-
async function signup(body: {
|
|
295
|
-
email: string
|
|
296
|
-
password: string
|
|
297
|
-
name?: string
|
|
298
|
-
invite?: string
|
|
299
|
-
}) {
|
|
300
|
-
applySession(await api.signup(body))
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Sign in with email/password. */
|
|
304
|
-
async function passwordLogin(body: { email: string; password: string }) {
|
|
305
|
-
applySession(await api.passwordLogin(body))
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Local mode: sign in as the account a source-control PAT belongs to. `token` omitted
|
|
310
|
-
* uses the server-configured PAT (one-click); otherwise a pasted token. Resolves to the
|
|
311
|
-
* SAME canonical user as GitHub OAuth would (keyed on the provider's numeric id).
|
|
312
|
-
*/
|
|
313
|
-
async function patLogin(body: { provider: 'github' | 'gitlab'; token?: string }) {
|
|
314
|
-
applySession(await api.patLogin(body))
|
|
315
|
-
// Remember the choice so a later load re-mints the session from the env PAT silently.
|
|
316
|
-
autoLoginProvider.value = body.provider
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Request a password-reset link by email (always resolves; never reveals existence). */
|
|
320
|
-
async function forgotPassword(email: string) {
|
|
321
|
-
await api.forgotPassword({ email })
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
|
|
325
|
-
async function resetPassword(token: string, password: string) {
|
|
326
|
-
await api.resetPassword({ token, password })
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Drop the local session (sessions are stateless server-side). */
|
|
330
|
-
function logout() {
|
|
331
|
-
api.logout().catch(() => {})
|
|
332
|
-
token.value = null
|
|
333
|
-
user.value = null
|
|
334
|
-
// Forget the remembered provider so logout sticks (otherwise bootstrap would
|
|
335
|
-
// immediately re-mint a session from the env PAT).
|
|
336
|
-
autoLoginProvider.value = null
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Called by the API client when a request comes back 401. Drops the dead session but KEEPS
|
|
341
|
-
* the remembered provider (unlike logout): a 401 from an expired/rotated token or a
|
|
342
|
-
* transient blip should let the next load silently re-mint from the env PAT, not force the
|
|
343
|
-
* login screen. The guarded re-mint in `bootstrap` clears the choice itself if it genuinely
|
|
344
|
-
* fails (PAT removed/revoked), so there's no re-login loop.
|
|
345
|
-
*/
|
|
346
|
-
function handleUnauthorized() {
|
|
347
|
-
token.value = null
|
|
348
|
-
user.value = null
|
|
349
|
-
}
|
|
350
|
-
|
|
351
209
|
return {
|
|
352
210
|
token,
|
|
353
211
|
user,
|
|
@@ -367,16 +225,8 @@ export const useAuthStore = defineStore(
|
|
|
367
225
|
isMisconfigured,
|
|
368
226
|
needsLogin,
|
|
369
227
|
bootstrap,
|
|
370
|
-
login,
|
|
371
|
-
loginWithGoogle,
|
|
372
228
|
signInViaMothership,
|
|
373
|
-
|
|
374
|
-
passwordLogin,
|
|
375
|
-
patLogin,
|
|
376
|
-
forgotPassword,
|
|
377
|
-
resetPassword,
|
|
378
|
-
logout,
|
|
379
|
-
handleUnauthorized,
|
|
229
|
+
...session,
|
|
380
230
|
}
|
|
381
231
|
},
|
|
382
232
|
{ persist: { pick: ['token', 'autoLoginProvider'] } },
|