Files
lab/src/lib/appwrite/auth-actions.ts
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

267 lines
8.9 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 server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
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";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import type { AuthState } from "./auth-types";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
switch (e.type) {
case "user_invalid_credentials":
return "Email veya şifre hatalı.";
case "user_blocked":
return "Hesabınız engellenmiş.";
case "user_already_exists":
case "user_email_already_exists":
return "Bu email ile zaten bir hesap var.";
case "user_password_mismatch":
return "Şifreler eşleşmiyor.";
case "general_rate_limit_exceeded":
return "Çok fazla deneme. Birkaç dakika sonra tekrar deneyin.";
default:
return e.message || "Beklenmeyen bir hata oluştu.";
}
}
if (process.env.NODE_ENV !== "production" && e instanceof Error) {
return `Bağlantı hatası: ${e.message}`;
}
return "Bağlantı hatası. Tekrar deneyin.";
}
async function setSessionCookie(secret: string, expire: string) {
(await cookies()).set(APPWRITE_SESSION_COOKIE, secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
expires: new Date(expire),
});
}
async function setActiveTenantCookie(tenantId: string) {
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
});
}
type LoginResolution =
| { state: "no_tenants" }
| { state: "mismatch"; existingKinds: TenantKind[] }
| { state: "matched"; tenantId: string };
async function resolveTenantOnLogin(
userId: string,
kind: TenantKind,
): Promise<LoginResolution> {
const { users, tablesDB } = createAdminClient();
const memberships = await users.listMemberships({ userId });
const teamIds = memberships.memberships.map((m) => m.teamId);
if (teamIds.length === 0) return { state: "no_tenants" };
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", teamIds), Query.limit(50)],
});
const rows = result.rows as unknown as TenantSettings[];
if (rows.length === 0) return { state: "no_tenants" };
const match = rows.find((r) => r.kind === kind);
if (match) return { state: "matched", tenantId: match.tenantId };
const existingKinds = Array.from(new Set(rows.map((r) => r.kind)));
return { state: "mismatch", existingKinds };
}
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
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 =
rawKind === "lab" || rawKind === "clinic" ? rawKind : null;
if (!email || !password) {
return { ok: false, error: "Email ve şifre zorunlu." };
}
let sessionUserId: string | null = null;
let sessionId: string | null = null;
try {
const { account } = createAdminClient();
const session = await account.createEmailPasswordSession(email, password);
sessionUserId = session.userId;
sessionId = session.$id;
await setSessionCookie(session.secret, session.expire);
} catch (e) {
console.error("[signInAction] createEmailPasswordSession", e);
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}`);
}
if (kind && sessionUserId) {
let resolution: LoginResolution;
try {
resolution = await resolveTenantOnLogin(sessionUserId, kind);
} catch (e) {
console.error("[signInAction] resolveTenantOnLogin", e);
// Tenant resolution failed: keep the session, send user to onboarding
// so they can recover instead of getting locked out.
redirect("/onboarding");
}
if (resolution.state === "no_tenants") {
// Authenticated user with no workspace yet — carry the kind into onboarding
redirect(`/onboarding?kind=${kind}`);
}
if (resolution.state === "mismatch") {
try {
const { users } = createAdminClient();
if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId });
} catch {
/* best-effort */
}
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
const requested = kind === "lab" ? "laboratuvar" : "klinik";
const existingLabel = resolution.existingKinds
.map((k) => (k === "lab" ? "laboratuvar" : "klinik"))
.join(" / ");
return {
ok: false,
error: `Bu hesap ${existingLabel} olarak kayıtlı. ${requested} sekmesinden değil, ${existingLabel} sekmesinden giriş yapın.`,
};
}
// matched
await setActiveTenantCookie(resolution.tenantId);
try {
const { users } = createAdminClient();
const user = await users.get({ userId: sessionUserId });
await users.updatePrefs({
userId: sessionUserId,
prefs: {
...(user.prefs ?? {}),
activeTenant: resolution.tenantId,
},
});
} catch {
/* best-effort */
}
}
redirect("/dashboard");
}
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
if (!name || !email || !password) {
return { ok: false, error: "Tüm alanlar zorunlu." };
}
if (password.length < 8) {
return { ok: false, error: "Şifre en az 8 karakter olmalı." };
}
try {
const { account } = createAdminClient();
await account.create(ID.unique(), email, password, name);
const session = await account.createEmailPasswordSession(email, password);
await setSessionCookie(session.secret, session.expire);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
redirect(inviteCode ? `/d/${inviteCode}` : "/onboarding");
}
export async function forgotPasswordAction(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim();
if (!email) return { ok: false, error: "Email zorunlu." };
try {
const { account } = createAdminClient();
const recoveryUrl = `${process.env.APP_URL ?? "http://localhost:3000"}/reset-password`;
await account.createRecovery(email, recoveryUrl);
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function signOutAction() {
try {
const { account } = await createSessionClient();
await account.deleteSession("current");
} catch {
// ignore — cookie will be cleared anyway
}
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
redirect("/sign-in");
}