feat(kayıt): yeni hane kaydı yalnızca davetle (REGISTRATION=invite_only)

- REGISTRATION ortam değişkeni: invite_only (varsayılan) | open
- invite_only: ilk hane serbest, sonrasında yalnızca davet koduyla katılım
- Backend'de zorunlu: 403 registration_closed; GET /api/auth/registration
- Kayıt ekranı: kayıt kapalıyken yalnızca davet kodu ve açıklama
- Entegrasyon testleri: kayıt politikası (2)
- Prettier ayarı eklendi (proje stiliyle uyumlu), kaynaklar biçimlendirildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 01:04:48 +03:00
parent 9cbeaaaf76
commit e188922c0f
29 changed files with 547 additions and 230 deletions
+10 -3
View File
@@ -43,7 +43,9 @@ export function LoginPage() {
// 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
const registerLink = donus
? `${paths.register}?donus=${encodeURIComponent(donus)}`
: paths.register
return (
<AuthShell
@@ -52,7 +54,10 @@ export function LoginPage() {
footer={
<>
{s.noAccount}{' '}
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
<Link
to={registerLink}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{s.toRegister}
</Link>
</>
@@ -74,7 +79,9 @@ export function LoginPage() {
</FormField>
<FormField label={s.password} error={errors.password}>
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
{(f) => (
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
)}
</FormField>
<Controller
+81 -35
View File
@@ -1,5 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Home, Loader2, UserPlus, Users } from 'lucide-react'
import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Home, Info, Loader2, UserPlus, Users } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { Link, useSearchParams } from 'react-router'
import { toast } from 'sonner'
@@ -7,11 +9,13 @@ 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 { api, ApiError } from '@/lib/api'
import { useRegister } from '@/lib/auth'
import { queryKeys } from '@/lib/query'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import { AuthShell } from './auth-shell'
import type { RegistrationInfo } from '@/types/RegistrationInfo'
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
const s = t.auth.register
@@ -22,11 +26,20 @@ export function RegisterPage() {
// Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu
const invite = params.get('davet') ?? ''
// Sunucu politikası (REGISTRATION): yeni hane kurulabilir mi?
const qc = useQueryClient()
const info = useQuery({
queryKey: queryKeys.registration,
queryFn: () => api.get<RegistrationInfo>('/auth/registration'),
})
const newAllowed = info.data?.new_household_allowed ?? true
const {
register,
control,
handleSubmit,
setError,
setValue,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
@@ -40,6 +53,11 @@ export function RegisterPage() {
invite_code: invite,
},
})
// Kayıt kapalıysa yalnızca "Davetle katıl"
useEffect(() => {
if (!newAllowed) setValue('mode', 'join')
}, [newAllowed, setValue])
const mode = useWatch({ control, name: 'mode' })
const name = useWatch({ control, name: 'name' }).trim()
@@ -58,7 +76,11 @@ export function RegisterPage() {
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'))
else if (e.code === 'registration_closed') {
// Bu arada başka biri ilk haneyi kurmuş olabilir → bilgiyi tazele, formu davete çevir
await qc.invalidateQueries({ queryKey: queryKeys.registration })
setError('root', { message: e.message })
} 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 })
}
@@ -74,7 +96,10 @@ export function RegisterPage() {
footer={
<>
{s.hasAccount}{' '}
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
<Link
to={loginLink}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{s.toLogin}
</Link>
</>
@@ -105,39 +130,60 @@ export function RegisterPage() {
{/* 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>
)}
/>
{!newAllowed && (
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
{s.closedNote}
</p>
)}
{newAllowed && (
<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
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}>
+23 -10
View File
@@ -1,9 +1,18 @@
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]))
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', () => {
@@ -13,9 +22,9 @@ describe('loginSchema', () => {
})
})
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',
)
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 })
@@ -34,11 +43,15 @@ describe('registerSchema', () => {
})
})
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(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe(
'Davet kodunu yaz',
)
expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true)
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)
+7 -2
View File
@@ -7,7 +7,11 @@ 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 }))
const email = z
.string()
.trim()
.min(1, { error: m.emailRequired })
.pipe(z.email({ error: m.emailInvalid }))
export const loginSchema = z.object({
email,
@@ -32,7 +36,8 @@ export const registerSchema = z
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 })
else if (code.length !== 8)
ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
})
export type RegisterForm = z.infer<typeof registerSchema>
+4 -3
View File
@@ -32,7 +32,9 @@ export function DesignPreview() {
<p className="text-sm">
{health.isError && <span className="text-expense">● {health.error.message}</span>}
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
{health.isPending && (
<span className="text-muted-foreground">● Backend kontrol ediliyor…</span>
)}
{health.data && (
<span className="text-income">
● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
@@ -40,7 +42,6 @@ export function DesignPreview() {
)}
</p>
<section className="space-y-3">
<h2 className="text-xl font-semibold">Ana gruplar</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -65,7 +66,7 @@ export function DesignPreview() {
{[
['Gelir', formatMoney(4_500_000, 'TRY', { signed: true }), 'text-income'],
['Gider', formatMoney(-125_000), 'text-expense'],
['Bütçenin %85\'i kullanıldı', formatMoney(85_000), 'text-warning'],
["Bütçenin %85'i kullanıldı", formatMoney(85_000), 'text-warning'],
['Euro', formatMoney(30_000, 'EUR'), ''],
['Dolar', formatMoney(15_000, 'USD'), ''],
['Gram altın', formatQuantity(12_500_000, 'gr'), ''],