@cat-factory/app 0.37.3 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/auth/AuthGate.vue +8 -0
- package/app/components/auth/LoginScreen.vue +86 -8
- package/app/components/auth/ResetPasswordScreen.vue +106 -0
- package/app/composables/api/auth.ts +11 -0
- package/app/pages/reset-password.vue +7 -0
- package/app/stores/auth.ts +12 -0
- package/package.json +2 -2
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import LoginScreen from '~/components/auth/LoginScreen.vue'
|
|
3
4
|
|
|
4
5
|
// Resolves auth state once on mount, then either renders the app (auth off, or
|
|
5
6
|
// on with a signed-in user) or the login screen. The board's own bootstrap runs
|
|
6
7
|
// inside the default slot, so it only fires once the user is allowed in.
|
|
7
8
|
const auth = useAuthStore()
|
|
9
|
+
const route = useRoute()
|
|
10
|
+
|
|
11
|
+
// The password-reset page is public: a recipient of an emailed reset link is signed
|
|
12
|
+
// out, so it must render even when auth is required and there's no user.
|
|
13
|
+
const isPublicRoute = computed(() => route.path === '/reset-password')
|
|
8
14
|
|
|
9
15
|
onMounted(() => auth.bootstrap())
|
|
10
16
|
</script>
|
|
@@ -18,6 +24,8 @@ onMounted(() => auth.bootstrap())
|
|
|
18
24
|
<span class="text-sm">Loading…</span>
|
|
19
25
|
</div>
|
|
20
26
|
|
|
27
|
+
<slot v-else-if="isPublicRoute" />
|
|
28
|
+
|
|
21
29
|
<LoginScreen v-else-if="auth.required && !auth.user" />
|
|
22
30
|
|
|
23
31
|
<slot v-else />
|
|
@@ -12,13 +12,17 @@ const invite = computed(() => {
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
// Password form: signup creates a new user (invite or allowed-email-domain gated),
|
|
15
|
-
// login authenticates an existing one. Default to login;
|
|
16
|
-
|
|
15
|
+
// login authenticates an existing one, forgot requests a reset link. Default to login;
|
|
16
|
+
// flip to signup when invited.
|
|
17
|
+
const mode = ref<'login' | 'signup' | 'forgot'>(invite.value ? 'signup' : 'login')
|
|
17
18
|
const email = ref('')
|
|
18
19
|
const password = ref('')
|
|
19
20
|
const name = ref('')
|
|
20
21
|
const error = ref<string | null>(null)
|
|
21
22
|
const busy = ref(false)
|
|
23
|
+
// Set once a reset link has been requested, so we can show a generic confirmation
|
|
24
|
+
// (we never reveal whether the email is registered).
|
|
25
|
+
const forgotSent = ref(false)
|
|
22
26
|
|
|
23
27
|
async function submitPassword() {
|
|
24
28
|
error.value = null
|
|
@@ -44,6 +48,27 @@ async function submitPassword() {
|
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
async function submitForgot() {
|
|
52
|
+
error.value = null
|
|
53
|
+
busy.value = true
|
|
54
|
+
try {
|
|
55
|
+
await auth.forgotPassword(email.value)
|
|
56
|
+
forgotSent.value = true
|
|
57
|
+
} catch {
|
|
58
|
+
// The request endpoint is generic by design; only an infra error lands here.
|
|
59
|
+
error.value = 'Something went wrong. Please try again.'
|
|
60
|
+
} finally {
|
|
61
|
+
busy.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Switch modes, clearing any transient form state. */
|
|
66
|
+
function setMode(next: 'login' | 'signup' | 'forgot') {
|
|
67
|
+
mode.value = next
|
|
68
|
+
error.value = null
|
|
69
|
+
forgotSent.value = false
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
const showOAuthDivider = computed(
|
|
48
73
|
() => auth.providers.password && (auth.providers.github || auth.providers.google),
|
|
49
74
|
)
|
|
@@ -58,12 +83,15 @@ const showOAuthDivider = computed(
|
|
|
58
83
|
<UIcon name="i-lucide-layout-dashboard" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
59
84
|
<h1 class="mb-1 text-lg font-semibold text-white">Architecture Board</h1>
|
|
60
85
|
<p class="text-sm text-slate-400">
|
|
61
|
-
|
|
86
|
+
<template v-if="mode === 'forgot'">Reset your password.</template>
|
|
87
|
+
<template v-else>{{
|
|
88
|
+
invite ? 'Accept your invitation to continue.' : 'Sign in to continue.'
|
|
89
|
+
}}</template>
|
|
62
90
|
</p>
|
|
63
91
|
</div>
|
|
64
92
|
|
|
65
93
|
<!-- OAuth providers -->
|
|
66
|
-
<div class="space-y-2">
|
|
94
|
+
<div v-if="mode !== 'forgot'" class="space-y-2">
|
|
67
95
|
<UButton
|
|
68
96
|
v-if="auth.providers.github"
|
|
69
97
|
block
|
|
@@ -87,12 +115,19 @@ const showOAuthDivider = computed(
|
|
|
87
115
|
</UButton>
|
|
88
116
|
</div>
|
|
89
117
|
|
|
90
|
-
<div
|
|
118
|
+
<div
|
|
119
|
+
v-if="showOAuthDivider && mode !== 'forgot'"
|
|
120
|
+
class="my-4 flex items-center gap-3 text-xs text-slate-500"
|
|
121
|
+
>
|
|
91
122
|
<span class="h-px flex-1 bg-slate-800" /> or <span class="h-px flex-1 bg-slate-800" />
|
|
92
123
|
</div>
|
|
93
124
|
|
|
94
125
|
<!-- Email / password -->
|
|
95
|
-
<form
|
|
126
|
+
<form
|
|
127
|
+
v-if="auth.providers.password && mode !== 'forgot'"
|
|
128
|
+
class="space-y-3"
|
|
129
|
+
@submit.prevent="submitPassword"
|
|
130
|
+
>
|
|
96
131
|
<UInput
|
|
97
132
|
v-if="mode === 'signup'"
|
|
98
133
|
v-model="name"
|
|
@@ -126,17 +161,60 @@ const showOAuthDivider = computed(
|
|
|
126
161
|
<p class="text-center text-xs text-slate-400">
|
|
127
162
|
<template v-if="mode === 'login'">
|
|
128
163
|
Need an account?
|
|
129
|
-
<button
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="text-indigo-400 hover:underline"
|
|
167
|
+
@click="setMode('signup')"
|
|
168
|
+
>
|
|
130
169
|
Sign up
|
|
131
170
|
</button>
|
|
132
171
|
</template>
|
|
133
172
|
<template v-else>
|
|
134
173
|
Already have an account?
|
|
135
|
-
<button type="button" class="text-indigo-400 hover:underline" @click="
|
|
174
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
|
|
136
175
|
Sign in
|
|
137
176
|
</button>
|
|
138
177
|
</template>
|
|
139
178
|
</p>
|
|
179
|
+
<p v-if="mode === 'login'" class="text-center text-xs text-slate-400">
|
|
180
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('forgot')">
|
|
181
|
+
Forgot password?
|
|
182
|
+
</button>
|
|
183
|
+
</p>
|
|
184
|
+
</form>
|
|
185
|
+
|
|
186
|
+
<!-- Forgot password: request a reset link by email -->
|
|
187
|
+
<form
|
|
188
|
+
v-if="auth.providers.password && mode === 'forgot'"
|
|
189
|
+
class="space-y-3"
|
|
190
|
+
@submit.prevent="submitForgot"
|
|
191
|
+
>
|
|
192
|
+
<template v-if="forgotSent">
|
|
193
|
+
<p class="text-sm text-slate-300">
|
|
194
|
+
If an account exists for that email, a password reset link is on its way. Check your
|
|
195
|
+
inbox.
|
|
196
|
+
</p>
|
|
197
|
+
</template>
|
|
198
|
+
<template v-else>
|
|
199
|
+
<UInput
|
|
200
|
+
v-model="email"
|
|
201
|
+
type="email"
|
|
202
|
+
required
|
|
203
|
+
placeholder="Email"
|
|
204
|
+
icon="i-lucide-at-sign"
|
|
205
|
+
size="lg"
|
|
206
|
+
class="w-full"
|
|
207
|
+
/>
|
|
208
|
+
<p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
|
|
209
|
+
<UButton block size="lg" color="primary" type="submit" :loading="busy">
|
|
210
|
+
Send reset link
|
|
211
|
+
</UButton>
|
|
212
|
+
</template>
|
|
213
|
+
<p class="text-center text-xs text-slate-400">
|
|
214
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
|
|
215
|
+
Back to sign in
|
|
216
|
+
</button>
|
|
217
|
+
</p>
|
|
140
218
|
</form>
|
|
141
219
|
</div>
|
|
142
220
|
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Standalone full-screen reset form reached from the emailed link
|
|
5
|
+
// (`/reset-password?token=…`). It is a public route (see AuthGate), so a recipient who
|
|
6
|
+
// is signed out can still set a new password. On success we send them to the login.
|
|
7
|
+
const auth = useAuthStore()
|
|
8
|
+
|
|
9
|
+
const token = computed(() => {
|
|
10
|
+
if (typeof window === 'undefined') return ''
|
|
11
|
+
return new URLSearchParams(window.location.search).get('token') || ''
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const password = ref('')
|
|
15
|
+
const confirm = ref('')
|
|
16
|
+
const error = ref<string | null>(null)
|
|
17
|
+
const busy = ref(false)
|
|
18
|
+
const done = ref(false)
|
|
19
|
+
|
|
20
|
+
async function submit() {
|
|
21
|
+
error.value = null
|
|
22
|
+
if (password.value.length < 8) {
|
|
23
|
+
error.value = 'Password must be at least 8 characters.'
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (password.value !== confirm.value) {
|
|
27
|
+
error.value = 'Passwords do not match.'
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
busy.value = true
|
|
31
|
+
try {
|
|
32
|
+
await auth.resetPassword(token.value, password.value)
|
|
33
|
+
done.value = true
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error.value =
|
|
36
|
+
(e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
|
|
37
|
+
'This password reset link is invalid or has expired.'
|
|
38
|
+
} finally {
|
|
39
|
+
busy.value = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function goToLogin() {
|
|
44
|
+
if (typeof window !== 'undefined') window.location.assign('/')
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100">
|
|
50
|
+
<div
|
|
51
|
+
class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
52
|
+
>
|
|
53
|
+
<div class="mb-6 text-center">
|
|
54
|
+
<UIcon name="i-lucide-key-round" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
55
|
+
<h1 class="mb-1 text-lg font-semibold text-white">Reset password</h1>
|
|
56
|
+
<p class="text-sm text-slate-400">Choose a new password for your account.</p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<template v-if="done">
|
|
60
|
+
<p class="mb-4 text-sm text-slate-300">
|
|
61
|
+
Your password has been reset. You can now sign in with your new password.
|
|
62
|
+
</p>
|
|
63
|
+
<UButton block size="lg" color="primary" @click="goToLogin">Go to sign in</UButton>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<template v-else-if="!token">
|
|
67
|
+
<p class="mb-4 text-sm text-rose-400">
|
|
68
|
+
This reset link is missing its token. Request a new link from the sign-in screen.
|
|
69
|
+
</p>
|
|
70
|
+
<UButton block size="lg" color="neutral" variant="subtle" @click="goToLogin">
|
|
71
|
+
Back to sign in
|
|
72
|
+
</UButton>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<form v-else class="space-y-3" @submit.prevent="submit">
|
|
76
|
+
<UInput
|
|
77
|
+
v-model="password"
|
|
78
|
+
type="password"
|
|
79
|
+
required
|
|
80
|
+
placeholder="New password"
|
|
81
|
+
icon="i-lucide-lock"
|
|
82
|
+
size="lg"
|
|
83
|
+
class="w-full"
|
|
84
|
+
/>
|
|
85
|
+
<UInput
|
|
86
|
+
v-model="confirm"
|
|
87
|
+
type="password"
|
|
88
|
+
required
|
|
89
|
+
placeholder="Confirm new password"
|
|
90
|
+
icon="i-lucide-lock"
|
|
91
|
+
size="lg"
|
|
92
|
+
class="w-full"
|
|
93
|
+
/>
|
|
94
|
+
<p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
|
|
95
|
+
<UButton block size="lg" color="primary" type="submit" :loading="busy">
|
|
96
|
+
Reset password
|
|
97
|
+
</UButton>
|
|
98
|
+
<p class="text-center text-xs text-slate-400">
|
|
99
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="goToLogin">
|
|
100
|
+
Back to sign in
|
|
101
|
+
</button>
|
|
102
|
+
</p>
|
|
103
|
+
</form>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
acceptInvitationContract,
|
|
3
3
|
authConfigContract,
|
|
4
|
+
forgotPasswordContract,
|
|
4
5
|
logoutContract,
|
|
5
6
|
meContract,
|
|
6
7
|
passwordLoginContract,
|
|
7
8
|
peekInvitationContract,
|
|
9
|
+
resetPasswordContract,
|
|
8
10
|
signupContract,
|
|
9
11
|
} from '@cat-factory/contracts'
|
|
10
12
|
import type { ApiContext } from './context'
|
|
@@ -25,6 +27,15 @@ export function authApi({ http, send, ws }: ApiContext) {
|
|
|
25
27
|
passwordLogin: (body: { email: string; password: string }) =>
|
|
26
28
|
send(passwordLoginContract, { pathPrefix: '/auth', body }),
|
|
27
29
|
|
|
30
|
+
// Request a reset link. Always succeeds (204) regardless of whether the email is
|
|
31
|
+
// registered, so the response can't be used to enumerate accounts.
|
|
32
|
+
forgotPassword: (body: { email: string }) =>
|
|
33
|
+
send(forgotPasswordContract, { pathPrefix: '/auth', body }),
|
|
34
|
+
|
|
35
|
+
// Redeem a reset token + set a new password (throws 400 on an invalid/expired token).
|
|
36
|
+
resetPassword: (body: { token: string; password: string }) =>
|
|
37
|
+
send(resetPasswordContract, { pathPrefix: '/auth', body }),
|
|
38
|
+
|
|
28
39
|
peekInvite: (token: string) =>
|
|
29
40
|
send(peekInvitationContract, { pathPrefix: '/auth', pathParams: { token } }),
|
|
30
41
|
|
package/app/stores/auth.ts
CHANGED
|
@@ -133,6 +133,16 @@ export const useAuthStore = defineStore(
|
|
|
133
133
|
applySession(await api.passwordLogin(body))
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Request a password-reset link by email (always resolves; never reveals existence). */
|
|
137
|
+
async function forgotPassword(email: string) {
|
|
138
|
+
await api.forgotPassword({ email })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
|
|
142
|
+
async function resetPassword(token: string, password: string) {
|
|
143
|
+
await api.resetPassword({ token, password })
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
/** Drop the local session (sessions are stateless server-side). */
|
|
137
147
|
function logout() {
|
|
138
148
|
api.logout().catch(() => {})
|
|
@@ -159,6 +169,8 @@ export const useAuthStore = defineStore(
|
|
|
159
169
|
loginWithGoogle,
|
|
160
170
|
signup,
|
|
161
171
|
passwordLogin,
|
|
172
|
+
forgotPassword,
|
|
173
|
+
resetPassword,
|
|
162
174
|
logout,
|
|
163
175
|
handleUnauthorized,
|
|
164
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
33
33
|
"vue": "^3.5.38",
|
|
34
34
|
"wretch": "^3.0.9",
|
|
35
|
-
"@cat-factory/contracts": "0.
|
|
35
|
+
"@cat-factory/contracts": "0.37.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@toad-contracts/testing": "0.3.1",
|