Files
lab/src/lib/appwrite/auth-actions.ts
T
kovakmedya 7fb8288f79 auth: route tenant-less sign-in to onboarding instead of erroring out
Previously, signing in with a kind toggle when the account had no
tenant_settings row in the lab database returned 'Bu hesap için kayıt
bulunamadı'. For a freshly-imported isletmem user that has never opened
DLS, the right behaviour is onboarding — not a dead end.

resolveTenantOnLogin now distinguishes three states:
  - no_tenants  → redirect /onboarding?kind=<pill>  (session stays)
  - mismatch    → real error naming the existing kind, session rolled back
  - matched     → existing tenant set as active, /dashboard

Onboarding page accepts ?kind= and the workspace form pre-selects it, so
the user keeps their login pill choice without re-picking. Also fixed the
'teams.total > 0' redirect — it now requires a tenant_settings row before
sending users off to /dashboard, otherwise users with cross-app teams but
no DLS workspace would bounce.
2026-05-21 19:39:29 +03:00

221 lines
7.3 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, 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 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) };
}
// 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");
}