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 && (
+
+ Authenticator kodu
+
+
+ )}
+
{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.
+
+
+
+
+
+ Yedek kodları yenile
+
+
+ {busy ? : }
+ Devre dışı bırak
+
+
+
+ );
+ }
+
+ 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}
+ ))}
+
+
+
setStage({ kind: "idle" })}>
+ Tamamladım
+
+
+ );
+ }
+
+ if (stage.kind === "verify") {
+ const otpauthQrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(stage.uri)}`;
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Pasif
+
+ Hesabınız yalnızca şifre ile korunuyor.
+
+
+
+ {(busy || stage.kind === "loading") ? : }
+ İki adımlı doğrulamayı aç
+
+
+ );
+}
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.") };
+ }
+}