feat(auth+onboarding): implement Create Account flow with TDD

This commit is contained in:
jableader 2025-11-01 14:11:40 +11:00
parent 44072a2363
commit 062163483c
8 changed files with 182 additions and 21 deletions

View file

@ -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.
--- ---

View file

@ -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>

View file

@ -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">

View file

@ -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 }
} }

View file

@ -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>

View file

@ -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">

View file

@ -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)

View 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')
})
})