feat(auth+onboarding): implement Create Account flow with TDD
This commit is contained in:
parent
44072a2363
commit
062163483c
8 changed files with 182 additions and 21 deletions
|
|
@ -85,7 +85,7 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
|
||||||
- Build the `Welcome.vue` view for creating the first household.
|
- Build the `Welcome.vue` view for creating the first household.
|
||||||
- Build the `CreateAccount.vue` view.
|
- Build the `CreateAccount.vue` view.
|
||||||
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
|
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
|
||||||
- Status: Welcome page now implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account and Invitation Accept remain TODO.
|
- Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI is implemented with email/displayName/password form calling `useAuth.createAccount`; Invitation Accept remains TODO.
|
||||||
|
|
||||||
4. **[~] Integrate Household Context into the App**:
|
4. **[~] Integrate Household Context into the App**:
|
||||||
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
||||||
|
|
@ -123,8 +123,8 @@ What exists today
|
||||||
|
|
||||||
Gaps vs requirements
|
Gaps vs requirements
|
||||||
- Authentication & onboarding
|
- Authentication & onboarding
|
||||||
- Partial: Added `loginWithPassword` shim and `logout`. Still missing Google OAuth, account creation, JWT storage/refresh beyond cookie.
|
- Implemented: JWT `loginWithPassword`, `logout`, account creation via `createAccount` with token provider wiring.
|
||||||
- Missing `CreateAccount.vue`, `Welcome.vue`, invitation acceptance flow.
|
- UI: `LoginPage.vue` shows email/password form under feature flag; `CreateAccount.vue` view implemented; `Welcome.vue` creates household; Invitation Accept pending; Google OAuth pending.
|
||||||
- Routing & URL-based tenancy
|
- Routing & URL-based tenancy
|
||||||
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
|
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
|
||||||
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.
|
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.
|
||||||
|
|
@ -161,7 +161,8 @@ Progress Log (Nov 1, 2025)
|
||||||
- Implemented `useHousehold.ts`, header + auth providers in API client, minimal `HouseholdSwitcher.vue`, and mounted it. Added a header injection test.
|
- Implemented `useHousehold.ts`, header + auth providers in API client, minimal `HouseholdSwitcher.vue`, and mounted it. Added a header injection test.
|
||||||
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching.
|
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching.
|
||||||
- Router guard updated to handle public/multitenant routing and redirects.
|
- Router guard updated to handle public/multitenant routing and redirects.
|
||||||
- Next: Update `LoginPage.vue` to email/password UI using `useAuth.loginWithPassword`; implement Create Account UI; wire `Welcome.vue` to create household.
|
- Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation.
|
||||||
|
- Next: Implement Invitation Accept flow and Household Settings (invite members form), then remove legacy Person UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="enabled && households.length > 0" class="household-switcher">
|
<div
|
||||||
|
v-if="enabled && households.length > 0"
|
||||||
|
class="household-switcher"
|
||||||
|
>
|
||||||
<label>Household:</label>
|
<label>Household:</label>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="h in households" :key="h.slug">
|
<li
|
||||||
<router-link :to="toHousehold(h.slug)" :class="{ active: h.slug === activeSlug }">
|
v-for="h in households"
|
||||||
|
:key="h.slug"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="toHousehold(h.slug)"
|
||||||
|
:class="{ active: h.slug === activeSlug }"
|
||||||
|
>
|
||||||
{{ h.name }}
|
{{ h.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,34 @@
|
||||||
<form @submit.prevent="onSubmitLogin">
|
<form @submit.prevent="onSubmitLogin">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" v-model="email" type="email" required />
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" v-model="password" type="password" required />
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
<button
|
||||||
<router-link class="btn btn-link" :to="{ name: 'create-account' }">Create account</router-link>
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<router-link
|
||||||
|
class="btn btn-link"
|
||||||
|
:to="{ name: 'create-account' }"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</router-link>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -35,7 +55,6 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout } from '@/api/auth'
|
import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout, createAccount as apiCreateAccount } from '@/api/auth'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import type { User } from '@/domain/types'
|
import type { User } from '@/domain/types'
|
||||||
|
|
||||||
|
|
@ -70,5 +70,11 @@ export function useAuth() {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, households, activeHousehold, loadUser, login, loginWithPassword, logout, setHouseholds, setActiveHousehold, fetchHouseholds, createHousehold }
|
async function createAccount(email: string, displayName: string, password: string) {
|
||||||
|
const u = await apiCreateAccount(email, displayName, password)
|
||||||
|
user.value = u
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, households, activeHousehold, loadUser, login, loginWithPassword, logout, setHouseholds, setActiveHousehold, fetchHouseholds, createHousehold, createAccount }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,100 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="create-account">
|
||||||
<h1>Create Account</h1>
|
<h1>Create Account</h1>
|
||||||
<p>Placeholder for email, display name, password, and Google signup.</p>
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="displayName">Display Name</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
v-model="displayName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
<router-link
|
||||||
|
class="btn btn-link"
|
||||||
|
:to="{ name: 'login' }"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</router-link>
|
||||||
|
</form>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// TODO: Implement form per spec
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const { createAccount, user } = useAuth()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const displayName = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await createAccount(email.value.trim(), displayName.value.trim(), password.value)
|
||||||
|
if (user.value?.id !== undefined) {
|
||||||
|
const redirectPath = (route.query?.redirect as string | undefined) || '/'
|
||||||
|
router.push(redirectPath || '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = 'Sign up failed'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'An error occurred'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #a00;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@
|
||||||
<h2>Create a new household</h2>
|
<h2>Create a new household</h2>
|
||||||
<form @submit.prevent="onCreate">
|
<form @submit.prevent="onCreate">
|
||||||
<label for="name">Household Name</label>
|
<label for="name">Household Name</label>
|
||||||
<input id="name" v-model="name" required />
|
<input
|
||||||
<button type="submit">Create</button>
|
id="name"
|
||||||
|
v-model="name"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button type="submit">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -14,7 +20,6 @@
|
||||||
<p>To join a household, ask an existing member to send an invitation to your email address.</p>
|
<p>To join a household, ask an existing member to send an invitation to your email address.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe('router multitenant routing', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('nests routes under /:householdSlug when flag enabled', async () => {
|
it('nests routes under /:householdSlug when flag enabled', async () => {
|
||||||
;(process.env as any).VUE_APP_MULTITENANT_ENABLED = 'true'
|
(process.env as any).VUE_APP_MULTITENANT_ENABLED = 'true'
|
||||||
const { createAppRouter } = await import('@/router/index')
|
const { createAppRouter } = await import('@/router/index')
|
||||||
const router = createAppRouter(() => null)
|
const router = createAppRouter(() => null)
|
||||||
const paths = router.getRoutes().map((r) => r.path)
|
const paths = router.getRoutes().map((r) => r.path)
|
||||||
|
|
|
||||||
31
tests/useAuth.createAccount.test.ts
Normal file
31
tests/useAuth.createAccount.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', () => {
|
||||||
|
return {
|
||||||
|
currentUser: vi.fn(async () => null),
|
||||||
|
login: vi.fn(async () => ({ id: 1, email: 'legacy@example.com', displayName: 'Legacy' })),
|
||||||
|
loginWithPassword: vi.fn(async (email: string) => ({ id: 2, email, displayName: email })),
|
||||||
|
createAccount: vi.fn(async (email: string, displayName: string) => ({ id: 77, email, displayName })),
|
||||||
|
logout: vi.fn(async () => {}),
|
||||||
|
handleGoogleLogin: vi.fn(async () => {
|
||||||
|
throw new Error('handleGoogleLogin not implemented yet')
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useAuth.createAccount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates user after successful createAccount', async () => {
|
||||||
|
const { useAuth } = await import('@/composables/useAuth')
|
||||||
|
const { user, createHousehold, fetchHouseholds, ...rest } = useAuth() as any
|
||||||
|
expect(user.value).toBeNull()
|
||||||
|
// Implemented method should exist
|
||||||
|
expect(typeof rest.createAccount).toBe('function')
|
||||||
|
const newUser = await rest.createAccount('new@example.com', 'New User', 'pw')
|
||||||
|
expect(newUser.id).toBe(77)
|
||||||
|
expect(user.value?.displayName).toBe('New User')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue