@cat-factory/app 0.317.1 → 0.317.2

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.
@@ -5,7 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
6
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
7
7
  import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
8
- import { postSignInUrl } from '~/utils/postSignIn'
8
+ import { signInThenReload } from '~/utils/postSignIn'
9
9
 
10
10
  const auth = useAuthStore()
11
11
  const { t } = useI18n()
@@ -62,8 +62,7 @@ async function submitPat(provider: PatProvider) {
62
62
  patError.value = null
63
63
  patBusy.value = true
64
64
  try {
65
- await auth.patLogin({ provider })
66
- if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
65
+ await signInThenReload(() => auth.patLogin({ provider }))
67
66
  } catch (e) {
68
67
  patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
69
68
  } finally {
@@ -95,18 +94,17 @@ async function submitPassword() {
95
94
  error.value = null
96
95
  busy.value = true
97
96
  try {
98
- if (mode.value === 'signup') {
99
- await auth.signup({
100
- email: email.value,
101
- password: password.value,
102
- name: name.value || undefined,
103
- invite: invite.value,
104
- })
105
- } else {
106
- await auth.passwordLogin({ email: email.value, password: password.value })
107
- }
108
97
  // Reload so the app boots with the new session.
109
- if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
98
+ await signInThenReload(() =>
99
+ mode.value === 'signup'
100
+ ? auth.signup({
101
+ email: email.value,
102
+ password: password.value,
103
+ name: name.value || undefined,
104
+ invite: invite.value,
105
+ })
106
+ : auth.passwordLogin({ email: email.value, password: password.value }),
107
+ )
110
108
  } catch (e) {
111
109
  error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
112
110
  } finally {
@@ -177,8 +175,9 @@ async function submitRemotePat() {
177
175
  remotePatError.value = null
178
176
  remotePatBusy.value = true
179
177
  try {
180
- await auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() })
181
- if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
178
+ await signInThenReload(() =>
179
+ auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() }),
180
+ )
182
181
  } catch (e) {
183
182
  remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
184
183
  } finally {
@@ -306,11 +306,25 @@ watch(
306
306
  if (!isOpen) return
307
307
  resetSelection()
308
308
  void loadRepos()
309
- if (ui.addServicePrefill) repoSearch.value = ui.addServicePrefill.repo
310
309
  },
311
310
  { immediate: true },
312
311
  )
313
312
 
313
+ // A deep link's repository is typed into the search once the picker's menu has MOUNTED, and after
314
+ // the tick it mounted in: Nuxt UI's InputMenu clears its own search term on that tick, so a term set
315
+ // on open is wiped (the picker mounts with the window, or later behind the connect prompt). The
316
+ // prefill is spent once its repository is selected below, so nothing is typed after that.
317
+ const repoMenu = ref<unknown>(null)
318
+ watch(
319
+ repoMenu,
320
+ async (menu) => {
321
+ if (menu === null || !ui.addServicePrefill) return
322
+ await nextTick()
323
+ if (ui.addServicePrefill) repoSearch.value = ui.addServicePrefill.repo
324
+ },
325
+ { flush: 'post' },
326
+ )
327
+
314
328
  // A deep link names one repository: select it as soon as the search lists it, once. A repository
315
329
  // the connection cannot reach never lists, which leaves the search's own empty state (and its
316
330
  // grant-access remedy) in front of the person.
@@ -497,6 +511,7 @@ function done() {
497
511
  to forward the attribute to. -->
498
512
  <div class="space-y-1.5" data-testid="add-service-repo-search">
499
513
  <UInputMenu
514
+ ref="repoMenu"
500
515
  v-model="selectedRepoId"
501
516
  v-model:search-term="repoSearch"
502
517
  :items="repoMenuItems"
@@ -10,7 +10,7 @@
10
10
  * /api/v1/deployment`; the format is published at
11
11
  * https://catfactory.ai/extend/public-api.html#sending-a-person-to-add-a-repository
12
12
  *
13
- * Signing in first keeps the query (`postSignInUrl`), so a signed-out person lands here too.
13
+ * Signing in first keeps the query (`signInThenReload`), so a signed-out person lands here too.
14
14
  */
15
15
  import { watch } from 'vue'
16
16
  import type { GitHubAvailableRepo } from '~/types/domain'
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { postSignInUrl } from './postSignIn'
2
+ import { postSignInUrl, signInThenReload } from './postSignIn'
3
3
 
4
4
  // The regression this exists for: every sign-in path reloaded to `location.pathname`, so a flow
5
5
  // whose subject rides the query string lost it the moment a person signed in. The MCP consent
@@ -27,3 +27,30 @@ describe('postSignInUrl', () => {
27
27
  expect(postSignInUrl({ pathname: '/boards', search: '?a=1' })).toBe('/boards?a=1')
28
28
  })
29
29
  })
30
+
31
+ describe('signInThenReload', () => {
32
+ function location(search: string) {
33
+ const assigned: string[] = []
34
+ const at = { pathname: '/', search, assign: (url: string) => void assigned.push(url) }
35
+ return { at, assigned }
36
+ }
37
+
38
+ it('reloads to the URL as it was BEFORE the sign-in, query included', async () => {
39
+ // What the board does as the session lands: it consumes `?addRepo=` and strips it from the URL.
40
+ const { at, assigned } = location('?addRepo=octo%2Fdemo&provider=github')
41
+ await signInThenReload(async () => {
42
+ at.search = ''
43
+ }, at)
44
+ expect(assigned).toEqual(['/?addRepo=octo%2Fdemo&provider=github'])
45
+ })
46
+
47
+ it('does not reload when the sign-in is refused', async () => {
48
+ const { at, assigned } = location('?addRepo=octo%2Fdemo')
49
+ await expect(
50
+ signInThenReload(async () => {
51
+ throw new Error('refused')
52
+ }, at),
53
+ ).rejects.toThrow('refused')
54
+ expect(assigned).toEqual([])
55
+ })
56
+ })
@@ -27,3 +27,23 @@ export function postSignInUrl(location: { pathname: string; search: string }): s
27
27
  const query = params.toString()
28
28
  return query ? `${location.pathname}?${query}` : location.pathname
29
29
  }
30
+
31
+ /** The part of `window.location` a sign-in reads and reloads through. */
32
+ type SignInLocation = Pick<Location, 'pathname' | 'search' | 'assign'>
33
+
34
+ /**
35
+ * Run a sign-in, then reload to where the person was.
36
+ *
37
+ * The destination is read BEFORE the call. A sign-in that succeeds mounts the board in place, and
38
+ * the board's deep-link handlers strip the query they act on (`?addRepo=` among them) as they
39
+ * consume it, so a destination read after the call is the stripped URL and the reload lands on a
40
+ * board that no longer knows what the link asked for.
41
+ */
42
+ export async function signInThenReload(
43
+ signIn: () => Promise<unknown>,
44
+ location: SignInLocation | null = typeof window === 'undefined' ? null : window.location,
45
+ ): Promise<void> {
46
+ const destination = location === null ? null : postSignInUrl(location)
47
+ await signIn()
48
+ if (location !== null && destination !== null) location.assign(destination)
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.317.1",
3
+ "version": "0.317.2",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",