From 7fb8288f79732248872c3f8eac5f3c920b9c6c78 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 19:39:29 +0300 Subject: [PATCH] auth: route tenant-less sign-in to onboarding instead of erroring out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= (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. --- .../components/create-workspace-form.tsx | 9 ++- src/app/onboarding/page.tsx | 30 ++++++- src/lib/appwrite/auth-actions.ts | 81 +++++++++++++------ 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/app/onboarding/components/create-workspace-form.tsx b/src/app/onboarding/components/create-workspace-form.tsx index 525c2f1..ff00506 100644 --- a/src/app/onboarding/components/create-workspace-form.tsx +++ b/src/app/onboarding/components/create-workspace-form.tsx @@ -17,10 +17,15 @@ type Kind = "lab" | "clinic"; interface Props { userName?: string; crossAppTeams?: Array<{ $id: string; name: string }>; + initialKind?: Kind | null; } -export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) { - const [kind, setKind] = useState(null); +export function CreateWorkspaceForm({ + userName, + crossAppTeams = [], + initialKind = null, +}: Props) { + const [kind, setKind] = useState(initialKind); const [state, formAction, isPending] = useActionState( createWorkspaceAction, initialWorkspaceState, diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 32ddf21..2f03153 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { Query } from "node-appwrite"; -import { getCurrentUser } from "@/lib/appwrite/server"; +import { createAdminClient, getCurrentUser } from "@/lib/appwrite/server"; +import { DATABASE_ID, TABLES, type TenantKind } from "@/lib/appwrite/schema"; import { getUserTeams, getCrossAppTeams } from "@/lib/appwrite/tenant"; import { CreateWorkspaceForm } from "./components/create-workspace-form"; @@ -10,21 +12,43 @@ export const metadata: Metadata = { description: "DLS için ilk çalışma alanınızı kurun.", }; -export default async function OnboardingPage() { +export default async function OnboardingPage({ + searchParams, +}: { + searchParams: Promise<{ kind?: string }>; +}) { const user = await getCurrentUser(); if (!user) redirect("/sign-in"); + // Already has a DLS tenant? Send to dashboard. Cross-app teams without a + // tenant_settings row in `lab` still belong on onboarding. const teams = await getUserTeams(); - if (teams && teams.total > 0) redirect("/dashboard"); + if (teams && teams.total > 0) { + const { tablesDB } = createAdminClient(); + const existing = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [ + Query.equal("tenantId", teams.teams.map((t) => t.$id)), + Query.limit(1), + ], + }); + if (existing.total > 0) redirect("/dashboard"); + } const crossAppTeams = await getCrossAppTeams(); + const { kind: rawKind } = await searchParams; + const initialKind: TenantKind | null = + rawKind === "lab" || rawKind === "clinic" ? rawKind : null; + return (
diff --git a/src/lib/appwrite/auth-actions.ts b/src/lib/appwrite/auth-actions.ts index 3fb5631..78b4abf 100644 --- a/src/lib/appwrite/auth-actions.ts +++ b/src/lib/appwrite/auth-actions.ts @@ -53,23 +53,33 @@ async function setActiveTenantCookie(tenantId: string) { }); } -async function pickTenantIdByKind(userId: string, kind: TenantKind): Promise { +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 memberships = await users.listMemberships({ userId }); const teamIds = memberships.memberships.map((m) => m.teamId); - if (teamIds.length === 0) return null; + 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.equal("kind", kind), - Query.limit(1), - ], + queries: [Query.equal("tenantId", teamIds), Query.limit(50)], }); - const row = result.rows[0] as unknown as TenantSettings | undefined; - return row?.tenantId ?? null; + 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 { @@ -98,37 +108,62 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis } // Invite flow short-circuits the kind check — invite code drives team membership - if (!inviteCode && kind && sessionUserId) { - const matchedTenantId = await pickTenantIdByKind(sessionUserId, kind); - if (!matchedTenantId) { - // Roll back session: user has no tenant of the requested kind + 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(sessionUserId, sessionId); + if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId }); } catch { /* best-effort */ } (await cookies()).delete(APPWRITE_SESSION_COOKIE); - const label = kind === "lab" ? "laboratuvar" : "klinik"; + const requested = kind === "lab" ? "laboratuvar" : "klinik"; + const existingLabel = resolution.existingKinds + .map((k) => (k === "lab" ? "laboratuvar" : "klinik")) + .join(" / "); return { ok: false, - error: `Bu hesap için ${label} kaydı bulunamadı. Diğer hesap türünü seçin veya yeni çalışma alanı oluşturun.`, + error: `Bu hesap ${existingLabel} olarak kayıtlı. ${requested} sekmesinden değil, ${existingLabel} sekmesinden giriş yapın.`, }; } - await setActiveTenantCookie(matchedTenantId); + + // matched + await setActiveTenantCookie(resolution.tenantId); try { const { users } = createAdminClient(); - const user = await users.get(sessionUserId); - await users.updatePrefs(sessionUserId, { - ...(user.prefs ?? {}), - activeTenant: matchedTenantId, + const user = await users.get({ userId: sessionUserId }); + await users.updatePrefs({ + userId: sessionUserId, + prefs: { + ...(user.prefs ?? {}), + activeTenant: resolution.tenantId, + }, }); } catch { /* best-effort */ } } - redirect(inviteCode ? `/d/${inviteCode}` : "/dashboard"); + redirect("/dashboard"); } export async function signUpAction(_prev: AuthState, formData: FormData): Promise {