"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 { 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 { 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 { 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 { 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"); }