@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
- /** Pull a token handed back in the post-login URL fragment (#token=…). */
125
- function consumeRedirectToken() {
126
- if (typeof window === 'undefined') return
127
- const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
128
- if (!match) return
129
- token.value = decodeURIComponent(match[1]!)
130
- // Strip the token from the URL so it isn't left in history or shared.
131
- history.replaceState(null, '', window.location.pathname + window.location.search)
132
- }
133
-
134
- /**
135
- * Mothership mode: when the mothership OAuth redirect returns here (flagged
136
- * `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
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
- signup,
374
- passwordLogin,
375
- patLogin,
376
- forgotPassword,
377
- resetPassword,
378
- logout,
379
- handleUnauthorized,
229
+ ...session,
380
230
  }
381
231
  },
382
232
  { persist: { pick: ['token', 'autoLoginProvider'] } },