feat(giriş): giriş ve kayıt ekranları
- Giriş: satır içi doğrulama, şifre göster/gizle, beni hatırla, hoş geldin bildirimi - Kayıt: yeni hane kur / davetle katıl, /kayit?davet=KOD bağlantısı - Sunucu hataları ilgili alanda gösterilir (kayıtlı e-posta, geçersiz davet) - react-hook-form + zod, sonner bildirimleri, shadcn input/label/checkbox/card - Ortak form bileşenleri (FormField, PasswordInput, FormError) - 8 form kuralı testi (toplam 47); api.http demo hesaplara güncellendi - Faz 2 tamamlandı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||
|
||||
/** Giriş / kayıt ekranlarının ortak çerçevesi: ortada kart, üstte logo. */
|
||||
export function AuthShell({
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
footer: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
useDocumentTitle(title)
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-muted/40">
|
||||
<div className="flex justify-end p-3">
|
||||
<ThemeToggle compact />
|
||||
</div>
|
||||
<main className="flex flex-1 flex-col items-center px-4 pb-10 sm:justify-center">
|
||||
<Logo size={52} withText className="mb-6" />
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
<h1>{title}</h1>
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
<p className="mt-6 text-sm text-muted-foreground">{footer}</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +1,107 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { LogIn } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField, PasswordInput } from '@/components/form-field'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useLogin } from '@/lib/auth'
|
||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||
import { t } from '@/lib/strings'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import { loginSchema, type LoginForm } from './schemas'
|
||||
|
||||
const s = t.auth.login
|
||||
|
||||
// GEÇİCİ: Faz 2 – Adım 5'te react-hook-form + zod ile gerçek giriş ekranı gelecek.
|
||||
export function LoginPage() {
|
||||
useDocumentTitle('Giriş')
|
||||
const login = useLogin()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [params] = useSearchParams()
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onTouched', // alan terk edilince doğrula, sonra yazarken güncelle
|
||||
defaultValues: { email: '', password: '', remember_me: true },
|
||||
})
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Başarılı olunca RequireGuest, ?donus= adresine yönlendirir
|
||||
login.mutate({ email, password, remember_me: true })
|
||||
}
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
try {
|
||||
const me = await login.mutateAsync(values)
|
||||
toast.success(s.welcome(me.user.name))
|
||||
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
// Kayıt sayfasına geçerken dönüş adresi kaybolmasın
|
||||
const donus = params.get('donus')
|
||||
const registerLink = donus ? `${paths.register}?donus=${encodeURIComponent(donus)}` : paths.register
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4">
|
||||
<Logo size={56} withText />
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3"
|
||||
type="email"
|
||||
placeholder="E-posta"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
<AuthShell
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
footer={
|
||||
<>
|
||||
{s.noAccount}{' '}
|
||||
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
{s.toRegister}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||
<FormField label={s.email} error={errors.email}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
{...register('email')}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label={s.password} error={errors.password}>
|
||||
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
|
||||
</FormField>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="remember_me"
|
||||
render={({ field }) => (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(v) => field.onChange(v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">{s.remember}</span>
|
||||
<span className="block text-xs text-muted-foreground">{s.rememberHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
|
||||
<Button type="submit" size="lg" disabled={login.isPending}>
|
||||
<LogIn /> Giriş
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
<Link to={paths.register} className="text-center text-sm text-muted-foreground underline">
|
||||
Hesabınız yok mu? Kayıt olun
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// GEÇİCİ: Adım 5'te kayıt formu (davet kodu dahil).
|
||||
export function RegisterPage() {
|
||||
useDocumentTitle('Kayıt')
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-6 px-4 text-center">
|
||||
<Logo size={56} withText className="justify-center" />
|
||||
<p className="text-muted-foreground">Kayıt ekranı Faz 2 – Adım 5'te geliştirilecek.</p>
|
||||
<Link to={paths.login} className="text-sm underline">
|
||||
Girişe dön
|
||||
</Link>
|
||||
</main>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Home, Loader2, UserPlus, Users } from 'lucide-react'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField, PasswordInput } from '@/components/form-field'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useRegister } from '@/lib/auth'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
|
||||
|
||||
const s = t.auth.register
|
||||
|
||||
export function RegisterPage() {
|
||||
const registerAccount = useRegister()
|
||||
const [params] = useSearchParams()
|
||||
// Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu
|
||||
const invite = params.get('davet') ?? ''
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
mode: invite ? 'join' : 'new',
|
||||
household_name: '',
|
||||
invite_code: invite,
|
||||
},
|
||||
})
|
||||
const mode = useWatch({ control, name: 'mode' })
|
||||
const name = useWatch({ control, name: 'name' }).trim()
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
const me = await registerAccount.mutateAsync({
|
||||
name: v.name,
|
||||
email: v.email,
|
||||
password: v.password,
|
||||
...(v.mode === 'join'
|
||||
? { invite_code: normalizeInviteCode(v.invite_code) }
|
||||
: { household_name: v.household_name || undefined }),
|
||||
})
|
||||
toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name))
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
|
||||
// Sunucu hatasını ilgili alana bağla
|
||||
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
|
||||
else if (v.mode === 'join' && e.code === 'bad_request' && e.message.includes('Davet'))
|
||||
setError('invite_code', { message: e.message }, { shouldFocus: true })
|
||||
else setError('root', { message: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
const donus = params.get('donus')
|
||||
const loginLink = donus ? `${paths.login}?donus=${encodeURIComponent(donus)}` : paths.login
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
footer={
|
||||
<>
|
||||
{s.hasAccount}{' '}
|
||||
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
{s.toLogin}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||
<FormField label={s.name} error={errors.name}>
|
||||
{(f) => <Input {...f} {...register('name')} autoComplete="given-name" autoFocus />}
|
||||
</FormField>
|
||||
|
||||
<FormField label={s.email} error={errors.email}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
{...register('email')}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label={s.password} error={errors.password} hint={s.passwordHint}>
|
||||
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="new-password" />}
|
||||
</FormField>
|
||||
|
||||
{/* Yeni hane mi, davetle katılma mı? */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
||||
<Controller
|
||||
control={control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<div role="radiogroup" className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1">
|
||||
{(
|
||||
[
|
||||
['new', s.modeNew, Home],
|
||||
['join', s.modeJoin, Users],
|
||||
] as const
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={field.value === value}
|
||||
onClick={() => field.onChange(value)}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
||||
field.value === value && 'bg-background text-foreground shadow-sm',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{mode === 'new' ? (
|
||||
<FormField label={s.householdName} error={errors.household_name} hint={s.householdHint(name)}>
|
||||
{(f) => <Input {...f} {...register('household_name')} placeholder={name ? `${name} Hanesi` : ''} />}
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
{...register('invite_code')}
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={12}
|
||||
className="font-mono tracking-widest uppercase"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loginSchema, normalizeInviteCode, registerSchema } from './schemas'
|
||||
|
||||
const base = { name: 'Suphi', email: 'a@b.co', password: '12345678', mode: 'new' as const, household_name: '', invite_code: '' }
|
||||
const issues = (r: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }) =>
|
||||
Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message]))
|
||||
|
||||
describe('loginSchema', () => {
|
||||
it('boş alanlarda Türkçe mesaj', () => {
|
||||
expect(issues(loginSchema.safeParse({ email: '', password: '', remember_me: true }))).toEqual({
|
||||
email: 'E-posta gerekli',
|
||||
password: 'Şifre gerekli',
|
||||
})
|
||||
})
|
||||
it('geçersiz e-posta', () => {
|
||||
expect(issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email).toBe(
|
||||
'Geçerli bir e-posta adresi girin',
|
||||
)
|
||||
})
|
||||
it('e-postadaki boşlukları kırpar', () => {
|
||||
const r = loginSchema.safeParse({ email: ' a@b.co ', password: 'x', remember_me: true })
|
||||
expect(r.success && r.data.email).toBe('a@b.co')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerSchema', () => {
|
||||
it('geçerli yeni hane kaydı', () => {
|
||||
expect(registerSchema.safeParse(base).success).toBe(true)
|
||||
})
|
||||
it('kısa şifre ve boş ad', () => {
|
||||
expect(issues(registerSchema.safeParse({ ...base, name: ' ', password: '123' }))).toEqual({
|
||||
name: 'Adını yaz',
|
||||
password: 'Şifre en az 8 karakter olmalı',
|
||||
})
|
||||
})
|
||||
it('davet modunda kod zorunlu ve 8 karakter', () => {
|
||||
expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe('Davet kodunu yaz')
|
||||
expect(issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code).toBe(
|
||||
'Davet kodu 8 karakter olmalı',
|
||||
)
|
||||
expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true)
|
||||
})
|
||||
it('yeni hane modunda davet kodu yok sayılır', () => {
|
||||
expect(registerSchema.safeParse({ ...base, invite_code: 'x' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizeInviteCode', () => {
|
||||
expect(normalizeInviteCode(' abcd-efgh ')).toBe('ABCDEFGH')
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Giriş / kayıt form kuralları. Backend'deki doğrulamayla aynı sınırlar
|
||||
* (`backend/src/routes/auth.rs`) — kullanıcı hatayı göndermeden görür, backend yine de kontrol eder.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const m = t.auth.validation
|
||||
|
||||
const email = z.string().trim().min(1, { error: m.emailRequired }).pipe(z.email({ error: m.emailInvalid }))
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email,
|
||||
password: z.string().min(1, { error: m.passwordRequired }),
|
||||
remember_me: z.boolean(),
|
||||
})
|
||||
export type LoginForm = z.infer<typeof loginSchema>
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { error: m.nameRequired }).max(80, { error: m.nameTooLong }),
|
||||
email,
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { error: m.passwordTooShort })
|
||||
.max(128, { error: m.passwordTooLong }),
|
||||
mode: z.enum(['new', 'join']),
|
||||
household_name: z.string().trim().max(80, { error: m.householdTooLong }),
|
||||
invite_code: z.string().trim(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.mode !== 'join') return
|
||||
const code = normalizeInviteCode(v.invite_code)
|
||||
if (!code) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteRequired })
|
||||
else if (code.length !== 8) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
|
||||
})
|
||||
export type RegisterForm = z.infer<typeof registerSchema>
|
||||
|
||||
/** Backend ile aynı: boşluk/tire atılır, büyük harf. "abcd-efgh" → "ABCDEFGH" */
|
||||
export function normalizeInviteCode(code: string): string {
|
||||
return code.replace(/[^a-z0-9]/gi, '').toUpperCase()
|
||||
}
|
||||
Reference in New Issue
Block a user