Files
lab/src/app/(auth)/sign-in/components/login-form-1.tsx
T
kovakmedya 3e15d9f937 feat(security): two-factor authentication (TOTP)
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.
2026-05-22 16:25:26 +03:00

262 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
&apos;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>
);
}