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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user