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 `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.
|
||||
- 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**:
|
||||
- **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
|
||||
- Authentication & onboarding
|
||||
- Partial: Added `loginWithPassword` shim and `logout`. Still missing Google OAuth, account creation, JWT storage/refresh beyond cookie.
|
||||
- Missing `CreateAccount.vue`, `Welcome.vue`, invitation acceptance flow.
|
||||
- Implemented: JWT `loginWithPassword`, `logout`, account creation via `createAccount` with token provider wiring.
|
||||
- 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
|
||||
- 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.
|
||||
|
|
@ -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 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.
|
||||
- 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>
|
||||
<div v-if="enabled && households.length > 0" class="household-switcher">
|
||||
<div
|
||||
v-if="enabled && households.length > 0"
|
||||
class="household-switcher"
|
||||
>
|
||||
<label>Household:</label>
|
||||
<ul>
|
||||
<li v-for="h in households" :key="h.slug">
|
||||
<router-link :to="toHousehold(h.slug)" :class="{ active: h.slug === activeSlug }">
|
||||
<li
|
||||
v-for="h in households"
|
||||
:key="h.slug"
|
||||
>
|
||||
<router-link
|
||||
:to="toHousehold(h.slug)"
|
||||
:class="{ active: h.slug === activeSlug }"
|
||||
>
|
||||
{{ h.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,34 @@
|
|||
<form @submit.prevent="onSubmitLogin">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" v-model="password" type="password" required />
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
<router-link class="btn btn-link" :to="{ name: 'create-account' }">Create account</router-link>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<router-link
|
||||
class="btn btn-link"
|
||||
:to="{ name: 'create-account' }"
|
||||
>
|
||||
Create account
|
||||
</router-link>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -35,7 +55,6 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 type { User } from '@/domain/types'
|
||||
|
||||
|
|
@ -70,5 +70,11 @@ export function useAuth() {
|
|||
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>
|
||||
<div>
|
||||
<div class="create-account">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.error {
|
||||
color: #a00;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@
|
|||
<h2>Create a new household</h2>
|
||||
<form @submit.prevent="onCreate">
|
||||
<label for="name">Household Name</label>
|
||||
<input id="name" v-model="name" required />
|
||||
<button type="submit">Create</button>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
required
|
||||
>
|
||||
<button type="submit">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
|
|
@ -14,7 +20,6 @@
|
|||
<p>To join a household, ask an existing member to send an invitation to your email address.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe('router multitenant routing', () => {
|
|||
})
|
||||
|
||||
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 router = createAppRouter(() => null)
|
||||
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