diff --git a/src/app/(auth)/sign-in/components/login-form-1.tsx b/src/app/(auth)/sign-in/components/login-form-1.tsx index cf94878..4b39ba0 100644 --- a/src/app/(auth)/sign-in/components/login-form-1.tsx +++ b/src/app/(auth)/sign-in/components/login-form-1.tsx @@ -88,6 +88,24 @@ export function LoginForm1({ /> + {state.mfaRequired && ( +
+ + +
+ )} + {state.error && (

{state.error} diff --git a/src/app/(dashboard)/settings/components/settings-nav.tsx b/src/app/(dashboard)/settings/components/settings-nav.tsx index 8f4088c..a8f0941 100644 --- a/src/app/(dashboard)/settings/components/settings-nav.tsx +++ b/src/app/(dashboard)/settings/components/settings-nav.tsx @@ -11,6 +11,7 @@ const ITEMS: { href: string; label: string }[] = [ { href: "/settings/members", label: "Üyeler" }, { href: "/settings/notifications", label: "Bildirimler" }, { href: "/settings/appearance", label: "Görünüm" }, + { href: "/settings/security", label: "Güvenlik" }, { href: "/settings/activity", label: "Hesap Aktivitesi" }, ]; diff --git a/src/app/(dashboard)/settings/security/components/mfa-panel.tsx b/src/app/(dashboard)/settings/security/components/mfa-panel.tsx new file mode 100644 index 0000000..640f08c --- /dev/null +++ b/src/app/(dashboard)/settings/security/components/mfa-panel.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useActionState, useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Check, KeyRound, Loader2, ShieldCheck, ShieldOff } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + disableMfaAction, + initialMfaActionState, + regenerateRecoveryCodesAction, + startMfaEnrollAction, + verifyMfaEnrollAction, +} from "@/lib/appwrite/mfa-actions"; + +type EnrollStage = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "verify"; uri: string; secret: string } + | { kind: "done"; recoveryCodes: string[] }; + +export function MfaPanel({ initiallyEnabled }: { initiallyEnabled: boolean }) { + const router = useRouter(); + const [enabled, setEnabled] = useState(initiallyEnabled); + const [stage, setStage] = useState({ kind: "idle" }); + const [verifyState, verifyAction, verifying] = useActionState( + verifyMfaEnrollAction, + initialMfaActionState, + ); + const [busy, startTransition] = useTransition(); + + useEffect(() => { + if (verifyState.ok && verifyState.recoveryCodes) { + setEnabled(true); + setStage({ kind: "done", recoveryCodes: verifyState.recoveryCodes }); + toast.success("2FA etkinleştirildi."); + router.refresh(); + } else if (verifyState.error) { + toast.error(verifyState.error); + } + }, [verifyState, router]); + + function beginEnroll() { + setStage({ kind: "loading" }); + startTransition(async () => { + const res = await startMfaEnrollAction(); + if (res.ok && res.uri && res.secret) { + setStage({ kind: "verify", uri: res.uri, secret: res.secret }); + } else { + toast.error(res.error ?? "Başlatılamadı."); + setStage({ kind: "idle" }); + } + }); + } + + function onDisable() { + if ( + !window.confirm( + "2FA devre dışı bırakılsın mı? Hesabınız sadece şifre ile korunacak.", + ) + ) + return; + startTransition(async () => { + const res = await disableMfaAction(); + if (res.ok) { + setEnabled(false); + setStage({ kind: "idle" }); + toast.success("2FA devre dışı bırakıldı."); + router.refresh(); + } else { + toast.error(res.error ?? "Devre dışı bırakılamadı."); + } + }); + } + + function onRegenerateCodes() { + startTransition(async () => { + const res = await regenerateRecoveryCodesAction(); + if (res.ok && res.recoveryCodes) { + setStage({ kind: "done", recoveryCodes: res.recoveryCodes }); + toast.success("Yeni yedek kodlar oluşturuldu — eskileri geçersiz."); + } else { + toast.error(res.error ?? "Üretilemedi."); + } + }); + } + + if (enabled && stage.kind !== "done") { + return ( +

+
+ + + Aktif + + + Authenticator uygulaması ile giriş yapıyorsunuz. + +
+
+ + +
+
+ ); + } + + if (stage.kind === "done") { + return ( +
+
+

+ + Yedek kodlarınız +

+

+ Telefonunuza erişiminizi kaybederseniz bu kodlardan biriyle giriş + yapabilirsiniz. Her kod tek seferlik. Şimdi güvenli bir yere kaydedin — + bu sayfadan çıktığınızda tekrar gösterilmez. +

+
+            {stage.recoveryCodes.map((c) => (
+              {c}
+            ))}
+          
+
+ +
+ ); + } + + if (stage.kind === "verify") { + const otpauthQrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(stage.uri)}`; + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR kodu +
+

Authenticator uygulamanızı açın, QR kodu tarayın.

+

+ Tarayamıyorsanız bu kodu manuel girin: +

+ {stage.secret} +
+
+
+ + + {verifyState.error && ( +

{verifyState.error}

+ )} +
+
+ + +
+
+ ); + } + + return ( +
+
+ Pasif + + Hesabınız yalnızca şifre ile korunuyor. + +
+ +
+ ); +} diff --git a/src/app/(dashboard)/settings/security/page.tsx b/src/app/(dashboard)/settings/security/page.tsx new file mode 100644 index 0000000..09208c8 --- /dev/null +++ b/src/app/(dashboard)/settings/security/page.tsx @@ -0,0 +1,56 @@ +import { redirect } from "next/navigation"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { createSessionClient } from "@/lib/appwrite/server"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { MfaPanel } from "./components/mfa-panel"; + +export const metadata = { + title: "DLS — Güvenlik", +}; + +export default async function SecurityPage() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + // Look up the user's current MFA status straight from the session + // client so the panel knows whether to offer enroll or disable. + let mfaEnabled = false; + try { + const { account } = await createSessionClient(); + const user = await account.get(); + mfaEnabled = Boolean(user.mfa); + } catch { + // ignore — panel will treat as not enabled + } + + return ( +
+
+

{ctx.settings?.companyName ?? "Çalışma alanı"}

+

Güvenlik

+

+ Hesap erişiminizi koruyan ayarlar. İki adımlı doğrulamayı açtığınızda + giriş yaparken authenticator uygulamanızdaki 6 haneli kod istenir. +

+
+ + + + İki Adımlı Doğrulama + + Authenticator uygulaması (Google Authenticator, 1Password, Authy, vs.) + ile TOTP. SMS desteklenmiyor. + + + + + + +
+ ); +} diff --git a/src/lib/appwrite/auth-actions.ts b/src/lib/appwrite/auth-actions.ts index 78b4abf..84342ad 100644 --- a/src/lib/appwrite/auth-actions.ts +++ b/src/lib/appwrite/auth-actions.ts @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { AppwriteException, ID, Query } from "node-appwrite"; +import { AppwriteException, AuthenticationFactor, ID, Query } from "node-appwrite"; import { APPWRITE_SESSION_COOKIE, createAdminClient, createSessionClient } from "./server"; import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema"; @@ -85,6 +85,7 @@ async function resolveTenantOnLogin( export async function signInAction(_prev: AuthState, formData: FormData): Promise { const email = String(formData.get("email") ?? "").trim(); const password = String(formData.get("password") ?? ""); + const otp = String(formData.get("otp") ?? "").trim(); const inviteCode = String(formData.get("inviteCode") ?? "").trim(); const rawKind = String(formData.get("kind") ?? "").trim(); const kind: TenantKind | null = @@ -107,6 +108,51 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis return { ok: false, error: appwriteError(e) }; } + // MFA: if the user has TOTP enabled, the session above is half-confirmed. + // Either pass the OTP they typed in this submission or ask for it. + try { + const { users } = createAdminClient(); + const user = await users.get({ userId: sessionUserId }); + if (user.mfa) { + if (!otp) { + return { + ok: false, + mfaRequired: true, + error: "Hesabınız 2FA korumalı. Authenticator uygulamasındaki 6 haneli kodu girin.", + }; + } + try { + const { account: sessionAccount } = await createSessionClient(); + const challenge = await sessionAccount.createMfaChallenge({ + factor: AuthenticationFactor.Totp, + }); + await sessionAccount.updateMfaChallenge({ + challengeId: challenge.$id, + otp, + }); + } catch (e) { + // Wrong code or expired challenge — kill the partial session and ask + // them to start over with the OTP visible. + try { + if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId }); + } catch { + /* best-effort */ + } + (await cookies()).delete(APPWRITE_SESSION_COOKIE); + return { + ok: false, + mfaRequired: true, + error: "Kod doğrulanamadı, yeniden deneyin.", + }; + } + } + } catch (e) { + console.error("[signInAction] MFA check", e); + // Fail-open on MFA check errors only when the user has no MFA configured; + // for safety, surface a generic error here. + return { ok: false, error: "Oturum doğrulanamadı." }; + } + // Invite flow short-circuits the kind check — invite code drives team membership if (inviteCode) { redirect(`/d/${inviteCode}`); diff --git a/src/lib/appwrite/auth-types.ts b/src/lib/appwrite/auth-types.ts index e8b40a2..e1c4ed5 100644 --- a/src/lib/appwrite/auth-types.ts +++ b/src/lib/appwrite/auth-types.ts @@ -1,6 +1,8 @@ export type AuthState = { ok: boolean; error?: string; + /** Set when the account has MFA enabled and the OTP field was empty. */ + mfaRequired?: boolean; }; export const initialAuthState: AuthState = { ok: false }; diff --git a/src/lib/appwrite/mfa-actions.ts b/src/lib/appwrite/mfa-actions.ts new file mode 100644 index 0000000..906337f --- /dev/null +++ b/src/lib/appwrite/mfa-actions.ts @@ -0,0 +1,104 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { AppwriteException, AuthenticatorType } from "node-appwrite"; + +import { createSessionClient } from "./server"; + +export type MfaEnrollState = { + ok: boolean; + error?: string; + /** otpauth:// URI for QR; only set on enroll start. */ + uri?: string; + /** Plain TOTP secret as a fallback if the QR can't be scanned. */ + secret?: string; +}; + +export const initialMfaEnrollState: MfaEnrollState = { ok: false }; + +export type MfaActionState = { + ok: boolean; + error?: string; + /** Recovery codes returned right after enable; show once, never stored again. */ + recoveryCodes?: string[]; +}; + +export const initialMfaActionState: MfaActionState = { ok: false }; + +function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string { + if (e instanceof AppwriteException) return e.message || fallback; + return process.env.NODE_ENV !== "production" && e instanceof Error + ? `${fallback} (${e.message})` + : fallback; +} + +/** + * Step 1 of TOTP enroll: produce a fresh secret and otpauth URI for the + * user's authenticator app. Calling this when an authenticator already + * exists yields the same secret back. + */ +export async function startMfaEnrollAction(): Promise { + try { + const { account } = await createSessionClient(); + const res = await account.createMFAAuthenticator(AuthenticatorType.Totp); + return { ok: true, uri: res.uri, secret: res.secret }; + } catch (e) { + return { ok: false, error: appwriteError(e, "MFA başlatılamadı.") }; + } +} + +/** + * Step 2 of TOTP enroll: user scanned the QR, opened their authenticator, + * typed the 6-digit code. We verify, then flip account.mfa = true so + * future sign-ins require the second factor. Returns recovery codes — + * shown once. + */ +export async function verifyMfaEnrollAction( + _prev: MfaActionState, + formData: FormData, +): Promise { + const otp = String(formData.get("otp") ?? "").trim(); + if (!otp || otp.length < 6) { + return { ok: false, error: "6 haneli kodu girin." }; + } + try { + const { account } = await createSessionClient(); + await account.updateMFAAuthenticator(AuthenticatorType.Totp, otp); + await account.updateMFA(true); + const codes = await account.createMFARecoveryCodes(); + return { ok: true, recoveryCodes: codes.recoveryCodes }; + } catch (e) { + return { ok: false, error: appwriteError(e, "Doğrulanamadı.") }; + } +} + +/** + * Disable MFA: turn the account flag off and remove the TOTP authenticator + * so the user can re-enroll later with a fresh secret. Requires a current + * authenticated session. + */ +export async function disableMfaAction(): Promise { + try { + const { account } = await createSessionClient(); + await account.updateMFA(false); + try { + await account.deleteMFAAuthenticator(AuthenticatorType.Totp); + } catch { + // Already removed — ignore. + } + revalidatePath("/settings/security"); + return { ok: true }; + } catch (e) { + return { ok: false, error: appwriteError(e, "Devre dışı bırakılamadı.") }; + } +} + +export async function regenerateRecoveryCodesAction(): Promise { + try { + const { account } = await createSessionClient(); + const codes = await account.updateMFARecoveryCodes(); + return { ok: true, recoveryCodes: codes.recoveryCodes }; + } catch (e) { + return { ok: false, error: appwriteError(e, "Yedek kodlar üretilemedi.") }; + } +}