3e15d9f937
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.
267 lines
8.9 KiB
TypeScript
267 lines
8.9 KiB
TypeScript
"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");
|
||
}
|