@cat-factory/app 0.153.3 → 0.153.4
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/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/package.json +1 -1
|
@@ -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'] } },
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { UpdateBlockInput } from '@cat-factory/contracts'
|
|
2
1
|
import type {
|
|
3
2
|
BlockType,
|
|
4
3
|
CreateTaskType,
|
|
@@ -6,16 +5,16 @@ import type {
|
|
|
6
5
|
TaskTypeFields,
|
|
7
6
|
Block,
|
|
8
7
|
} from '~/types/domain'
|
|
9
|
-
import { useServicesStore } from '~/stores/services'
|
|
10
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
11
9
|
import type { BoardWriteContext } from './context'
|
|
12
|
-
import { UNDO_WINDOW_MS } from './context'
|
|
13
10
|
|
|
14
11
|
/**
|
|
15
|
-
* The board's
|
|
16
|
-
* setup. Each closes over the shared
|
|
17
|
-
* by the API is applied via `upsert`)
|
|
18
|
-
* functions — the split is purely to keep
|
|
12
|
+
* The board's creation writes (services / modules / tasks / epics) plus the archive-restore
|
|
13
|
+
* lifecycle, extracted from the store setup. Each closes over the shared
|
|
14
|
+
* {@link BoardWriteContext} (the authoritative block returned by the API is applied via `upsert`)
|
|
15
|
+
* so behaviour is identical to the original in-closure functions — the split is purely to keep
|
|
16
|
+
* every function within the size budget. The placement + edit writes live alongside in
|
|
17
|
+
* {@link createBoardPlacement}.
|
|
19
18
|
*/
|
|
20
19
|
export function createBoardMutations(ctx: BoardWriteContext) {
|
|
21
20
|
const { getBlock, upsert, api, toast, tr } = ctx
|
|
@@ -152,69 +151,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
|
|
|
152
151
|
return block
|
|
153
152
|
}
|
|
154
153
|
|
|
155
|
-
/**
|
|
156
|
-
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
157
|
-
* silently on a small overshoot, so a successful move (into a *different* container,
|
|
158
|
-
* not an undo of one) offers a one-click undo back to its previous home.
|
|
159
|
-
*/
|
|
160
|
-
async function reparentBlock(
|
|
161
|
-
id: string,
|
|
162
|
-
newParentId: string,
|
|
163
|
-
position: { x: number; y: number },
|
|
164
|
-
opts: { undoable?: boolean } = { undoable: true },
|
|
165
|
-
) {
|
|
166
|
-
const b = getBlock(id)
|
|
167
|
-
const parent = getBlock(newParentId)
|
|
168
|
-
if (!b || !parent || b.id === newParentId) return
|
|
169
|
-
// tasks may live in services or modules; modules only in services
|
|
170
|
-
if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
|
|
171
|
-
if (b.level === 'module' && parent.level !== 'frame') return
|
|
172
|
-
// Optimistic: drop the block into the new container immediately so it doesn't
|
|
173
|
-
// briefly snap back to its old home while the request is in flight. Snapshot
|
|
174
|
-
// the old home so a rejected reparent restores it rather than leaving the
|
|
175
|
-
// block in the wrong container (a structural lie that survives until re-hydrate).
|
|
176
|
-
const prevParentId = b.parentId
|
|
177
|
-
const prevPosition = b.position
|
|
178
|
-
const name = b.title
|
|
179
|
-
b.parentId = newParentId
|
|
180
|
-
b.position = position
|
|
181
|
-
try {
|
|
182
|
-
upsert(
|
|
183
|
-
await api.reparentBlock(useWorkspaceStore().requireId(), id, {
|
|
184
|
-
parentId: newParentId,
|
|
185
|
-
position,
|
|
186
|
-
}),
|
|
187
|
-
)
|
|
188
|
-
// Offer an undo back to the previous container (a drag overshoot is easy). The undo
|
|
189
|
-
// move is itself non-undoable so the toast doesn't ping-pong.
|
|
190
|
-
if (opts.undoable && prevParentId) {
|
|
191
|
-
toast.add({
|
|
192
|
-
title: tr('board.toast.moved', { name }),
|
|
193
|
-
icon: 'i-lucide-move',
|
|
194
|
-
color: 'neutral',
|
|
195
|
-
duration: UNDO_WINDOW_MS,
|
|
196
|
-
actions: [
|
|
197
|
-
{
|
|
198
|
-
label: tr('common.undo'),
|
|
199
|
-
icon: 'i-lucide-undo-2',
|
|
200
|
-
onClick: () =>
|
|
201
|
-
void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
|
|
202
|
-
},
|
|
203
|
-
],
|
|
204
|
-
})
|
|
205
|
-
}
|
|
206
|
-
} catch (e) {
|
|
207
|
-
b.parentId = prevParentId
|
|
208
|
-
b.position = prevPosition
|
|
209
|
-
toast.add({
|
|
210
|
-
title: tr('board.toast.moveFailed'),
|
|
211
|
-
description: e instanceof Error ? e.message : String(e),
|
|
212
|
-
icon: 'i-lucide-triangle-alert',
|
|
213
|
-
color: 'error',
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
154
|
/**
|
|
219
155
|
* Archive a service (hide it + its subtree, restorable with no expiry) — the non-destructive
|
|
220
156
|
* alternative to deleting a service that still has unfinished tasks. The acting tab isn't
|
|
@@ -232,121 +168,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
|
|
|
232
168
|
await useWorkspaceStore().refresh()
|
|
233
169
|
}
|
|
234
170
|
|
|
235
|
-
/**
|
|
236
|
-
* Local-only optimistic position update during an active drag — no persistence.
|
|
237
|
-
* A drag fires this on every pointer move so the block tracks the cursor without
|
|
238
|
-
* a per-move API round-trip; the final position is committed once via
|
|
239
|
-
* {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
|
|
240
|
-
* raced: out-of-order responses to the burst of in-flight writes could land a
|
|
241
|
-
* stale position last, snapping the block back after the user let go.
|
|
242
|
-
*/
|
|
243
|
-
function previewMove(id: string, position: { x: number; y: number }) {
|
|
244
|
-
const b = getBlock(id)
|
|
245
|
-
if (b) b.position = position
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
async function moveBlock(id: string, position: { x: number; y: number }) {
|
|
249
|
-
const b = getBlock(id)
|
|
250
|
-
if (!b) return
|
|
251
|
-
const prevPosition = b.position
|
|
252
|
-
b.position = position // optimistic: keep the drag feeling instant
|
|
253
|
-
try {
|
|
254
|
-
// A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
|
|
255
|
-
// on the (shared) block — so route a frame drag there. Other moves write the block.
|
|
256
|
-
const services = useServicesStore()
|
|
257
|
-
const mount = services.serviceByFrameBlock[id]
|
|
258
|
-
? services.byServiceId[services.serviceByFrameBlock[id]!.id]
|
|
259
|
-
: undefined
|
|
260
|
-
if (mount) {
|
|
261
|
-
await services.updateLayout(mount.serviceId, position)
|
|
262
|
-
return
|
|
263
|
-
}
|
|
264
|
-
upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
|
|
265
|
-
} catch (e) {
|
|
266
|
-
// Restore the pre-drag position — a rejected move must not leave the block at a
|
|
267
|
-
// spot the server never stored (a lie that survives until the next re-hydrate).
|
|
268
|
-
b.position = prevPosition
|
|
269
|
-
toast.add({
|
|
270
|
-
title: tr('board.toast.moveFailed'),
|
|
271
|
-
description: e instanceof Error ? e.message : String(e),
|
|
272
|
-
icon: 'i-lucide-triangle-alert',
|
|
273
|
-
color: 'error',
|
|
274
|
-
})
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
279
|
-
async function updateBlock(id: string, patch: UpdateBlockInput) {
|
|
280
|
-
const b = getBlock(id)
|
|
281
|
-
if (!b) return
|
|
282
|
-
// Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
|
|
283
|
-
// (a patch may set several at once) rather than leaving a stale optimistic value stuck on
|
|
284
|
-
// screen with no feedback — the same rollback contract the other mutations here follow.
|
|
285
|
-
const prev: Record<string, unknown> = {}
|
|
286
|
-
const patchRecord = patch as Record<string, unknown>
|
|
287
|
-
const record = b as unknown as Record<string, unknown>
|
|
288
|
-
for (const key of Object.keys(patch)) prev[key] = record[key]
|
|
289
|
-
Object.assign(b, patch) // optimistic
|
|
290
|
-
try {
|
|
291
|
-
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
292
|
-
} catch (e) {
|
|
293
|
-
// Re-resolve the block: a live event may have replaced its object reference (`upsert`
|
|
294
|
-
// swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
|
|
295
|
-
// fields that still hold OUR optimistic value, so a newer server value that landed
|
|
296
|
-
// mid-flight isn't clobbered by the rollback.
|
|
297
|
-
const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
|
|
298
|
-
if (cur) {
|
|
299
|
-
for (const key of Object.keys(patch)) {
|
|
300
|
-
if (cur[key] === patchRecord[key]) cur[key] = prev[key]
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
toast.add({
|
|
304
|
-
title: tr('board.toast.updateFailed'),
|
|
305
|
-
description: e instanceof Error ? e.message : String(e),
|
|
306
|
-
icon: 'i-lucide-triangle-alert',
|
|
307
|
-
color: 'error',
|
|
308
|
-
})
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Toggle a dependency edge target -> source (target dependsOn source). The backend
|
|
314
|
-
* rejects an edge that would close a cycle (422) — surface that as a toast rather than
|
|
315
|
-
* letting it throw unhandled out of a board gesture.
|
|
316
|
-
*/
|
|
317
|
-
async function toggleDependency(targetId: string, sourceId: string) {
|
|
318
|
-
if (targetId === sourceId || !getBlock(targetId)) return
|
|
319
|
-
try {
|
|
320
|
-
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
321
|
-
} catch (e) {
|
|
322
|
-
toast.add({
|
|
323
|
-
title: tr('board.toast.linkFailed'),
|
|
324
|
-
description: e instanceof Error ? e.message : String(e),
|
|
325
|
-
icon: 'i-lucide-triangle-alert',
|
|
326
|
-
color: 'error',
|
|
327
|
-
})
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** Remove a dependency edge target -> source if it exists. */
|
|
332
|
-
async function removeDependency(targetId: string, sourceId: string) {
|
|
333
|
-
const t = getBlock(targetId)
|
|
334
|
-
if (!t || !t.dependsOn.includes(sourceId)) return
|
|
335
|
-
// the backend exposes a single toggle; the edge exists, so toggling removes it
|
|
336
|
-
try {
|
|
337
|
-
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
338
|
-
} catch (e) {
|
|
339
|
-
// Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
|
|
340
|
-
// than rejecting unhandled with no feedback.
|
|
341
|
-
toast.add({
|
|
342
|
-
title: tr('board.toast.unlinkFailed'),
|
|
343
|
-
description: e instanceof Error ? e.message : String(e),
|
|
344
|
-
icon: 'i-lucide-triangle-alert',
|
|
345
|
-
color: 'error',
|
|
346
|
-
})
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
171
|
return {
|
|
351
172
|
addBlock,
|
|
352
173
|
addServiceFromRepo,
|
|
@@ -354,13 +175,7 @@ export function createBoardMutations(ctx: BoardWriteContext) {
|
|
|
354
175
|
addModule,
|
|
355
176
|
addEpic,
|
|
356
177
|
assignToEpic,
|
|
357
|
-
reparentBlock,
|
|
358
178
|
archiveService,
|
|
359
179
|
restoreService,
|
|
360
|
-
previewMove,
|
|
361
|
-
moveBlock,
|
|
362
|
-
updateBlock,
|
|
363
|
-
toggleDependency,
|
|
364
|
-
removeDependency,
|
|
365
180
|
}
|
|
366
181
|
}
|