3e15d9f937
Hesap güvenliği için authenticator app (Google Authenticator, 1Password,
Authy etc.) based TOTP. SMS yok — sadece app-based per user request.
Enroll flow (/settings/security)
- startMfaEnrollAction → account.createMFAAuthenticator('totp'),
returns otpauth URI + plain secret as backup.
- MfaPanel client island: starts the flow, shows the QR (rendered via
api.qrserver.com for zero deps) plus the secret as text. Picks the
6-digit code → verifyMfaEnrollAction calls
updateMFAAuthenticator(totp, otp) + updateMFA(true) +
createMFARecoveryCodes(). The recovery codes are surfaced once on
success with a 'save these now' warning.
- disableMfaAction + regenerateRecoveryCodesAction give the same
panel a disable + 'yeni yedek kodlar' option once MFA is active.
- settings-nav now has 'Güvenlik' between 'Görünüm' and 'Hesap
Aktivitesi'.
Sign-in flow
- signInAction:
1. createEmailPasswordSession (sets cookie as before)
2. users.get(userId).mfa? If yes:
a. otp empty → return { mfaRequired: true, error }
b. otp present → createMfaChallenge({factor: totp}) +
updateMfaChallenge(challengeId, otp). Failure tears the
partial session down and bounces back with mfaRequired.
- AuthState gained an mfaRequired field. The login form watches it
and reveals an autofocused 6-digit OTP input on the next render.
User types the code, submits the form again, the same action
finishes the challenge and redirects.
Existing accounts without MFA are unaffected — they never hit the
challenge branch.
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useActionState, useState } from "react";
|
||
import { FlaskConical, Loader2, Stethoscope } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Logo } from "@/components/logo";
|
||
import { cn } from "@/lib/utils";
|
||
import { signInAction } from "@/lib/appwrite/auth-actions";
|
||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||
|
||
type Kind = "clinic" | "lab";
|
||
|
||
export function LoginForm1({
|
||
className,
|
||
inviteCode,
|
||
...props
|
||
}: React.ComponentProps<"div"> & { inviteCode?: string }) {
|
||
const [kind, setKind] = useState<Kind>("clinic");
|
||
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
|
||
|
||
return (
|
||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||
<Card className="overflow-hidden p-0">
|
||
<CardContent className="grid p-0 md:grid-cols-2">
|
||
<form action={formAction} className="p-6 md:p-10">
|
||
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||
<input type="hidden" name="kind" value={kind} />
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex justify-center">
|
||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||
<Logo size={22} />
|
||
</div>
|
||
<span className="text-xl font-semibold">DLS</span>
|
||
</Link>
|
||
</div>
|
||
|
||
{inviteCode && (
|
||
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
|
||
Davete katılmak için giriş yapın.
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex flex-col items-center text-center">
|
||
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
||
Hesabınıza giriş yapın
|
||
</p>
|
||
</div>
|
||
|
||
{!inviteCode && (
|
||
<KindPill value={kind} onChange={setKind} disabled={isPending} />
|
||
)}
|
||
|
||
<div className="grid gap-3">
|
||
<Label htmlFor="email">Email</Label>
|
||
<Input
|
||
id="email"
|
||
name="email"
|
||
type="email"
|
||
placeholder="ornek@firma.com"
|
||
autoComplete="email"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<div className="flex items-center">
|
||
<Label htmlFor="password">Şifre</Label>
|
||
<Link
|
||
href="/forgot-password"
|
||
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
|
||
>
|
||
Şifremi unuttum
|
||
</Link>
|
||
</div>
|
||
<Input
|
||
id="password"
|
||
name="password"
|
||
type="password"
|
||
autoComplete="current-password"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{state.mfaRequired && (
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="otp">Authenticator kodu</Label>
|
||
<Input
|
||
id="otp"
|
||
name="otp"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
maxLength={6}
|
||
autoComplete="one-time-code"
|
||
autoFocus
|
||
placeholder="000000"
|
||
className="font-mono text-lg tracking-widest"
|
||
required
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{state.error && (
|
||
<p className="text-destructive text-sm text-center" role="alert">
|
||
{state.error}
|
||
</p>
|
||
)}
|
||
|
||
<Button type="submit" className="w-full" disabled={isPending}>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Giriş yapılıyor...
|
||
</>
|
||
) : (
|
||
"Giriş yap"
|
||
)}
|
||
</Button>
|
||
|
||
<div className="text-center text-sm text-muted-foreground">
|
||
Hesabınız yok mu?{" "}
|
||
<Link
|
||
href="/sign-up"
|
||
className="text-foreground font-medium underline-offset-4 hover:underline"
|
||
>
|
||
Hesap oluştur
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
<BrandPanel />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<p className="text-muted-foreground text-center text-xs text-balance">
|
||
Giriş yaparak{" "}
|
||
<Link href="#" className="underline-offset-4 hover:underline">
|
||
Kullanım Şartları
|
||
</Link>{" "}
|
||
ve{" "}
|
||
<Link href="#" className="underline-offset-4 hover:underline">
|
||
Gizlilik Politikası
|
||
</Link>
|
||
'nı kabul etmiş olursunuz.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KindPill({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
value: Kind;
|
||
onChange: (next: Kind) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
return (
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Hesap türü"
|
||
className="bg-muted text-muted-foreground inline-flex w-full items-center gap-1 rounded-full border p-1"
|
||
>
|
||
<PillButton
|
||
active={value === "clinic"}
|
||
onClick={() => onChange("clinic")}
|
||
disabled={disabled}
|
||
>
|
||
<Stethoscope className="size-4" />
|
||
Klinik
|
||
</PillButton>
|
||
<PillButton
|
||
active={value === "lab"}
|
||
onClick={() => onChange("lab")}
|
||
disabled={disabled}
|
||
>
|
||
<FlaskConical className="size-4" />
|
||
Laboratuvar
|
||
</PillButton>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PillButton({
|
||
active,
|
||
onClick,
|
||
disabled,
|
||
children,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={active}
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={cn(
|
||
"flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
|
||
active
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "hover:text-foreground",
|
||
disabled && "cursor-not-allowed opacity-60",
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function BrandPanel() {
|
||
return (
|
||
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
|
||
<div
|
||
className="absolute inset-0 opacity-30"
|
||
style={{
|
||
backgroundImage:
|
||
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
|
||
}}
|
||
aria-hidden
|
||
/>
|
||
<div
|
||
className="absolute -top-24 -right-24 size-72 rounded-full bg-white/10 blur-3xl"
|
||
aria-hidden
|
||
/>
|
||
<div
|
||
className="absolute -bottom-32 -left-20 size-80 rounded-full bg-black/10 blur-3xl"
|
||
aria-hidden
|
||
/>
|
||
|
||
<div className="relative z-10 flex items-center gap-2">
|
||
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
|
||
<Logo size={22} />
|
||
</div>
|
||
<span className="text-lg font-medium">DLS</span>
|
||
</div>
|
||
|
||
<div className="relative z-10 flex flex-col gap-3">
|
||
<h2 className="text-3xl font-semibold leading-tight">
|
||
Klinik ve laboratuvar tek panelde.
|
||
</h2>
|
||
<p className="text-primary-foreground/80 text-sm">
|
||
İş yayınla, taranan dosyaları paylaş, protez aşamalarını adım adım takip et — finansal akışla birlikte.
|
||
</p>
|
||
<div className="text-primary-foreground/70 mt-4 text-xs">Kovak Yazılım tarafından</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|