feat(güvenlik): giriş ve kayıtta Cloudflare Turnstile, IP başına 3 hatalı denemede 15 dk kilit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile (bot doğrulaması). Betik yalnızca giriş/kayıt sayfalarında, bir kez
|
||||
* yüklenir. Jeton tek kullanımlıktır: her gönderimden sonra `reset()` ile yenisi alınır.
|
||||
*/
|
||||
type TurnstileApi = {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => string
|
||||
reset: (id: string) => void
|
||||
remove: (id: string) => void
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
const SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
let loading: Promise<TurnstileApi> | null = null
|
||||
|
||||
function loadTurnstile(): Promise<TurnstileApi> {
|
||||
if (window.turnstile) return Promise.resolve(window.turnstile)
|
||||
loading ??= new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = SRC
|
||||
s.async = true
|
||||
s.onload = () => (window.turnstile ? resolve(window.turnstile) : reject(new Error('turnstile')))
|
||||
s.onerror = () => {
|
||||
loading = null
|
||||
reject(new Error('turnstile'))
|
||||
}
|
||||
document.head.append(s)
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
export type TurnstileHandle = { reset: () => void }
|
||||
|
||||
export const Turnstile = forwardRef<
|
||||
TurnstileHandle,
|
||||
{
|
||||
siteKey: string
|
||||
/** Geçerli jeton ya da `null` (süresi doldu / hata) */
|
||||
onToken: (token: string | null) => void
|
||||
/** Betik yüklenemedi (ağ / reklam engelleyici) */
|
||||
onLoadError?: () => void
|
||||
}
|
||||
>(function Turnstile({ siteKey, onToken, onLoadError }, ref) {
|
||||
const el = useRef<HTMLDivElement>(null)
|
||||
const widget = useRef<string | null>(null)
|
||||
const { theme } = useTheme()
|
||||
// Geri çağrılar her çizimde değişebilir; kutu yeniden oluşturulmasın
|
||||
const cb = useRef({ onToken, onLoadError })
|
||||
useEffect(() => {
|
||||
cb.current = { onToken, onLoadError }
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
cb.current.onToken(null)
|
||||
if (widget.current && window.turnstile) window.turnstile.reset(widget.current)
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadTurnstile()
|
||||
.then((ts) => {
|
||||
if (cancelled || !el.current) return
|
||||
widget.current = ts.render(el.current, {
|
||||
sitekey: siteKey,
|
||||
theme: theme === 'system' ? 'auto' : theme,
|
||||
language: 'tr',
|
||||
size: 'flexible',
|
||||
callback: (t: string) => cb.current.onToken(t),
|
||||
'expired-callback': () => cb.current.onToken(null),
|
||||
'error-callback': () => cb.current.onToken(null),
|
||||
})
|
||||
})
|
||||
.catch(() => cb.current.onLoadError?.())
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (widget.current && window.turnstile) window.turnstile.remove(widget.current)
|
||||
widget.current = null
|
||||
}
|
||||
}, [siteKey, theme])
|
||||
|
||||
return <div ref={el} className="min-h-[65px]" />
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileHandle } from '@/components/turnstile'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useAuthGuard } from '@/lib/auth'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.auth.guard
|
||||
|
||||
/**
|
||||
* Giriş / kayıt güvenliği (ortak): bot doğrulama jetonu + IP kilidi.
|
||||
* Sunucu: üst üste 3 hatalı deneme → bu bağlantıya giriş ve kayıt 15 dk kapalı.
|
||||
*/
|
||||
export function useAuthSecurity() {
|
||||
const guard = useAuthGuard()
|
||||
const widget = useRef<TurnstileHandle>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [scriptFailed, setScriptFailed] = useState(false)
|
||||
const siteKey = guard.data?.captcha_site_key ?? null
|
||||
// Bitiş anı: yanıtın alındığı an + sunucunun bildirdiği kalan süre
|
||||
const lockedSecs = guard.data?.locked_seconds ?? 0
|
||||
const until = lockedSecs > 0 ? guard.dataUpdatedAt + lockedSecs * 1000 : 0
|
||||
const locked = useCountdown(until, () => void guard.refetch())
|
||||
|
||||
return {
|
||||
loading: guard.isPending,
|
||||
locked,
|
||||
/** Gönder düğmesi: doğrulama açıksa jeton gelmeden olmaz */
|
||||
ready: !guard.isPending && locked === 0 && (!siteKey || !!token),
|
||||
token: token ?? undefined,
|
||||
/** Her başarısız gönderimden sonra: jeton tek kullanımlık; kilit geldiyse sayaç başlar */
|
||||
afterError(e: unknown) {
|
||||
widget.current?.reset()
|
||||
if (e instanceof ApiError && (e.code === 'locked' || e.code === 'invalid_credentials'))
|
||||
void guard.refetch()
|
||||
},
|
||||
field: siteKey ? (
|
||||
<div className="space-y-1.5">
|
||||
<Turnstile
|
||||
ref={widget}
|
||||
siteKey={siteKey}
|
||||
onToken={setToken}
|
||||
onLoadError={() => setScriptFailed(true)}
|
||||
/>
|
||||
{scriptFailed && <p className="text-sm text-expense">{s.scriptFailed}</p>}
|
||||
</div>
|
||||
) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** `until` anına (ms) kadar kalan saniye, saniye saniye; bitince `onDone` */
|
||||
function useCountdown(until: number, onDone: () => void): number {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const done = useRef(onDone)
|
||||
useEffect(() => {
|
||||
done.current = onDone
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!until) return
|
||||
const id = setInterval(() => {
|
||||
const n = Date.now()
|
||||
setNow(n)
|
||||
if (n >= until) {
|
||||
clearInterval(id)
|
||||
done.current()
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [until])
|
||||
// `now` bileşen açılırken alındı; yanıt daha sonra geldiyse süre 15 dk'yı aşmasın
|
||||
return until ? Math.max(0, Math.ceil((until - Math.max(now, until - 15 * 60_000)) / 1000)) : 0
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Lock } from 'lucide-react'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.auth.guard
|
||||
|
||||
/** Kilitliyken formun yerine: neden + geri sayım */
|
||||
export function LockNotice({ seconds }: { seconds: number }) {
|
||||
const mm = Math.floor(seconds / 60)
|
||||
const ss = String(seconds % 60).padStart(2, '0')
|
||||
return (
|
||||
<div role="alert" className="flex gap-3 rounded-xl border border-expense/30 bg-expense/5 p-4">
|
||||
<Lock className="mt-0.5 size-5 shrink-0 text-expense" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{s.lockedTitle}</p>
|
||||
<p className="text-sm text-muted-foreground">{s.lockedText}</p>
|
||||
<p className="money text-lg font-semibold" aria-live="off">
|
||||
{mm}:{ss}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { ApiError } from '@/lib/api'
|
||||
import { useLogin } from '@/lib/auth'
|
||||
import { t } from '@/lib/strings'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import { useAuthSecurity } from './guard'
|
||||
import { LockNotice } from './lock-notice'
|
||||
import { loginSchema, type LoginForm } from './schemas'
|
||||
|
||||
const s = t.auth.login
|
||||
@@ -31,12 +33,15 @@ export function LoginPage() {
|
||||
defaultValues: { email: '', password: '', remember_me: true },
|
||||
})
|
||||
|
||||
const security = useAuthSecurity()
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
try {
|
||||
const me = await login.mutateAsync(values)
|
||||
const me = await login.mutateAsync({ ...values, captcha_token: security.token })
|
||||
toast.success(s.welcome(me.user.name))
|
||||
// Yönlendirmeyi RequireGuest yapar (?donus= varsa oraya)
|
||||
} catch (e) {
|
||||
security.afterError(e)
|
||||
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
@@ -63,52 +68,62 @@ export function LoginPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
{security.locked > 0 ? (
|
||||
<LockNotice seconds={security.locked} />
|
||||
) : (
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<FormField label={s.password} error={errors.password}>
|
||||
{(f) => (
|
||||
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{security.field}
|
||||
<FormError message={errors.root?.message} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !security.ready}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { queryKeys } from '@/lib/query'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import { useAuthSecurity } from './guard'
|
||||
import { LockNotice } from './lock-notice'
|
||||
import type { RegistrationInfo } from '@/types/RegistrationInfo'
|
||||
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
|
||||
|
||||
@@ -61,9 +63,12 @@ export function RegisterPage() {
|
||||
const mode = useWatch({ control, name: 'mode' })
|
||||
const name = useWatch({ control, name: 'name' }).trim()
|
||||
|
||||
const security = useAuthSecurity()
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
const me = await registerAccount.mutateAsync({
|
||||
captcha_token: security.token,
|
||||
name: v.name,
|
||||
email: v.email,
|
||||
password: v.password,
|
||||
@@ -73,6 +78,7 @@ export function RegisterPage() {
|
||||
})
|
||||
toast.success(v.mode === 'join' ? s.joined(me.household.name) : s.welcome(me.household.name))
|
||||
} catch (e) {
|
||||
security.afterError(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 })
|
||||
@@ -105,110 +111,120 @@ export function RegisterPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{security.locked > 0 ? (
|
||||
<LockNotice seconds={security.locked} />
|
||||
) : (
|
||||
<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.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>
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Yeni hane mi, davetle katılma mı? */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
||||
{!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>
|
||||
) : (
|
||||
<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>
|
||||
{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} />
|
||||
{security.field}
|
||||
<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>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !security.ready}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||
{isSubmitting ? s.submitting : s.submit}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - `MeResponse`→ giriş yapılmış
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import type { AuthGuardInfo } from '@/types/AuthGuardInfo'
|
||||
import type { LoginRequest } from '@/types/LoginRequest'
|
||||
import type { MeResponse } from '@/types/MeResponse'
|
||||
import type { RegisterRequest } from '@/types/RegisterRequest'
|
||||
@@ -69,3 +70,12 @@ export function useLogout() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Giriş/kayıt sayfası: bot doğrulama site anahtarı ve bu bağlantının kilidi (her açılışta taze) */
|
||||
export function useAuthGuard() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.authGuard,
|
||||
queryFn: () => api.get<AuthGuardInfo>('/auth/guard'),
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ApiError } from './api'
|
||||
export const queryKeys = {
|
||||
me: ['me'] as const,
|
||||
registration: ['registration'] as const,
|
||||
/** Giriş/kayıt: bot doğrulama anahtarı + bu bağlantının kilidi */
|
||||
authGuard: ['auth-guard'] as const,
|
||||
health: ['health'] as const,
|
||||
household: ['household'] as const,
|
||||
invites: ['household', 'invites'] as const,
|
||||
|
||||
@@ -52,6 +52,14 @@ export const t = {
|
||||
},
|
||||
|
||||
auth: {
|
||||
guard: {
|
||||
lockedTitle: 'Çok fazla hatalı deneme',
|
||||
lockedText:
|
||||
'Güvenliğin için bu bağlantıdan giriş ve kayıt geçici olarak kapatıldı. Süre dolunca tekrar deneyebilirsin.',
|
||||
verifying: 'Robot olmadığın doğrulanıyor…',
|
||||
scriptFailed:
|
||||
'Doğrulama kutusu yüklenemedi. İnternet bağlantını ya da reklam engelleyiciyi kontrol edip sayfayı yenile.',
|
||||
},
|
||||
login: {
|
||||
title: 'Tekrar hoş geldin',
|
||||
description: 'Defterine devam etmek için giriş yap.',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Giriş/kayıt sayfası: bot doğrulaması açık mı, bu IP kilitli mi?
|
||||
*/
|
||||
export type AuthGuardInfo = {
|
||||
/**
|
||||
* Turnstile site anahtarı (herkese açık); `null` → doğrulama kapalı
|
||||
*/
|
||||
captcha_site_key: string | null,
|
||||
/**
|
||||
* Kilit varsa kalan süre (sn); yoksa 0
|
||||
*/
|
||||
locked_seconds: number, };
|
||||
@@ -1,3 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type LoginRequest = { email: string, password: string, remember_me: boolean, };
|
||||
export type LoginRequest = { email: string, password: string, remember_me: boolean,
|
||||
/**
|
||||
* Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||
*/
|
||||
captcha_token?: string, };
|
||||
|
||||
@@ -4,4 +4,8 @@ export type RegisterRequest = { name: string, email: string, password: string, h
|
||||
/**
|
||||
* Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
|
||||
*/
|
||||
invite_code?: string, };
|
||||
invite_code?: string,
|
||||
/**
|
||||
* Cloudflare Turnstile jetonu (bot doğrulaması açıksa zorunlu)
|
||||
*/
|
||||
captcha_token?: string, };
|
||||
|
||||
Reference in New Issue
Block a user