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.
This commit is contained in:
@@ -17,10 +17,15 @@ type Kind = "lab" | "clinic";
|
|||||||
interface Props {
|
interface Props {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
crossAppTeams?: Array<{ $id: string; name: string }>;
|
crossAppTeams?: Array<{ $id: string; name: string }>;
|
||||||
|
initialKind?: Kind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) {
|
export function CreateWorkspaceForm({
|
||||||
const [kind, setKind] = useState<Kind | null>(null);
|
userName,
|
||||||
|
crossAppTeams = [],
|
||||||
|
initialKind = null,
|
||||||
|
}: Props) {
|
||||||
|
const [kind, setKind] = useState<Kind | null>(initialKind);
|
||||||
const [state, formAction, isPending] = useActionState(
|
const [state, formAction, isPending] = useActionState(
|
||||||
createWorkspaceAction,
|
createWorkspaceAction,
|
||||||
initialWorkspaceState,
|
initialWorkspaceState,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
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 { getUserTeams, getCrossAppTeams } from "@/lib/appwrite/tenant";
|
||||||
import { CreateWorkspaceForm } from "./components/create-workspace-form";
|
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.",
|
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();
|
const user = await getCurrentUser();
|
||||||
if (!user) redirect("/sign-in");
|
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();
|
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 crossAppTeams = await getCrossAppTeams();
|
||||||
|
|
||||||
|
const { kind: rawKind } = await searchParams;
|
||||||
|
const initialKind: TenantKind | null =
|
||||||
|
rawKind === "lab" || rawKind === "clinic" ? rawKind : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<CreateWorkspaceForm
|
<CreateWorkspaceForm
|
||||||
userName={user.name?.split(" ")[0]}
|
userName={user.name?.split(" ")[0]}
|
||||||
crossAppTeams={crossAppTeams}
|
crossAppTeams={crossAppTeams}
|
||||||
|
initialKind={initialKind}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,23 +53,33 @@ async function setActiveTenantCookie(tenantId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickTenantIdByKind(userId: string, kind: TenantKind): Promise<string | null> {
|
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 { users, tablesDB } = createAdminClient();
|
||||||
const memberships = await users.listMemberships(userId);
|
const memberships = await users.listMemberships({ userId });
|
||||||
const teamIds = memberships.memberships.map((m) => m.teamId);
|
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({
|
const result = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.tenantSettings,
|
tableId: TABLES.tenantSettings,
|
||||||
queries: [
|
queries: [Query.equal("tenantId", teamIds), Query.limit(50)],
|
||||||
Query.equal("tenantId", teamIds),
|
|
||||||
Query.equal("kind", kind),
|
|
||||||
Query.limit(1),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const row = result.rows[0] as unknown as TenantSettings | undefined;
|
const rows = result.rows as unknown as TenantSettings[];
|
||||||
return row?.tenantId ?? null;
|
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> {
|
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
||||||
@@ -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
|
// Invite flow short-circuits the kind check — invite code drives team membership
|
||||||
if (!inviteCode && kind && sessionUserId) {
|
if (inviteCode) {
|
||||||
const matchedTenantId = await pickTenantIdByKind(sessionUserId, kind);
|
redirect(`/d/${inviteCode}`);
|
||||||
if (!matchedTenantId) {
|
}
|
||||||
// Roll back session: user has no tenant of the requested kind
|
|
||||||
|
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 {
|
try {
|
||||||
const { users } = createAdminClient();
|
const { users } = createAdminClient();
|
||||||
if (sessionId) await users.deleteSession(sessionUserId, sessionId);
|
if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId });
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
|
(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 {
|
return {
|
||||||
ok: false,
|
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 {
|
try {
|
||||||
const { users } = createAdminClient();
|
const { users } = createAdminClient();
|
||||||
const user = await users.get(sessionUserId);
|
const user = await users.get({ userId: sessionUserId });
|
||||||
await users.updatePrefs(sessionUserId, {
|
await users.updatePrefs({
|
||||||
|
userId: sessionUserId,
|
||||||
|
prefs: {
|
||||||
...(user.prefs ?? {}),
|
...(user.prefs ?? {}),
|
||||||
activeTenant: matchedTenantId,
|
activeTenant: resolution.tenantId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(inviteCode ? `/d/${inviteCode}` : "/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
|
||||||
|
|||||||
Reference in New Issue
Block a user