From 30cbb8f1bed6d5788263f78e7a1a135005062028 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 03:22:48 +0300 Subject: [PATCH] feat(onboarding): create-workspace flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /onboarding page (server component): redirects to /sign-in if not authed, to /dashboard if user already has a team. Otherwise renders form. - createWorkspaceAction: * teams.create (user becomes owner via session SDK) * tablesDB.createRow on tenant_settings (admin SDK) with team-scoped permissions: Permission.read(Role.team(id)), update(owner|admin), delete(owner) * account.updatePrefs({ activeTenant }) — persisted source of truth * isletmem-tenant cookie for fast access * redirect to /dashboard - setActiveTenantAction stub for future workspace switcher - lib/appwrite/tenant.ts: getUserTeams, getActiveTenantId helpers (server-only) - tenant-types.ts holds WorkspaceState + ACTIVE_TENANT_COOKIE (no 'use server') --- .../components/create-workspace-form.tsx | 112 ++++++++++++++++++ src/app/onboarding/page.tsx | 29 +++++ src/lib/appwrite/tenant-actions.ts | 101 ++++++++++++++++ src/lib/appwrite/tenant-types.ts | 8 ++ src/lib/appwrite/tenant.ts | 31 +++++ 5 files changed, 281 insertions(+) create mode 100644 src/app/onboarding/components/create-workspace-form.tsx create mode 100644 src/app/onboarding/page.tsx create mode 100644 src/lib/appwrite/tenant-actions.ts create mode 100644 src/lib/appwrite/tenant-types.ts create mode 100644 src/lib/appwrite/tenant.ts diff --git a/src/app/onboarding/components/create-workspace-form.tsx b/src/app/onboarding/components/create-workspace-form.tsx new file mode 100644 index 0000000..dafb9e2 --- /dev/null +++ b/src/app/onboarding/components/create-workspace-form.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useActionState } from "react"; +import { Building2, Loader2, ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Logo } from "@/components/logo"; +import { createWorkspaceAction } from "@/lib/appwrite/tenant-actions"; +import { initialWorkspaceState } from "@/lib/appwrite/tenant-types"; + +export function CreateWorkspaceForm({ userName }: { userName?: string }) { + const [state, formAction, isPending] = useActionState( + createWorkspaceAction, + initialWorkspaceState, + ); + + return ( +
+
+
+ +
+ İşletmem +
+ + + +
+ +
+ + {userName ? `Hoş geldiniz, ${userName}` : "Çalışma alanı oluştur"} + + + Şirketinizin bilgilerini girin — birkaç saniyede çalışma alanınız hazır. + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {state.error && ( +

+ {state.error} +

+ )} + + + +

+ + Verileriniz yalnızca sizin ekibinize özel — multi-tenant izolasyon +

+
+
+
+ +

+ Bu bilgileri daha sonra Profil ayarlarından düzenleyebilirsiniz. +

+
+ ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 0000000..5cbcead --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/appwrite/server"; +import { getUserTeams } from "@/lib/appwrite/tenant"; +import { CreateWorkspaceForm } from "./components/create-workspace-form"; + +export const metadata: Metadata = { + title: "İşletmem — Çalışma alanı oluştur", + description: "İşletmem için ilk çalışma alanınızı kurun.", +}; + +export default async function OnboardingPage() { + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + + const teams = await getUserTeams(); + if (teams && teams.total > 0) { + redirect("/dashboard"); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/lib/appwrite/tenant-actions.ts b/src/lib/appwrite/tenant-actions.ts new file mode 100644 index 0000000..d44311e --- /dev/null +++ b/src/lib/appwrite/tenant-actions.ts @@ -0,0 +1,101 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { AppwriteException, ID, Permission, Role } from "node-appwrite"; + +import { createAdminClient, createSessionClient } from "./server"; +import { DATABASE_ID, TABLES } from "./schema"; +import { ACTIVE_TENANT_COOKIE, type WorkspaceState } from "./tenant-types"; + +function appwriteError(e: unknown): string { + if (e instanceof AppwriteException) { + if (e.type === "team_already_exists") return "Bu isimde bir çalışma alanı zaten var."; + if (e.type === "general_unauthorized_scope") return "Yetki hatası. Tekrar giriş yapın."; + return e.message || "Beklenmeyen bir hata oluştu."; + } + return "Bağlantı hatası. Tekrar deneyin."; +} + +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, + }); +} + +export async function createWorkspaceAction( + _prev: WorkspaceState, + formData: FormData, +): Promise { + const companyName = String(formData.get("companyName") ?? "").trim(); + const companyTaxId = String(formData.get("companyTaxId") ?? "").trim() || undefined; + const companyPhone = String(formData.get("companyPhone") ?? "").trim() || undefined; + + if (!companyName) { + return { ok: false, error: "Şirket adı zorunlu." }; + } + + let teamId: string; + + try { + const session = await createSessionClient(); + const user = await session.account.get(); + + const team = await session.teams.create(ID.unique(), companyName, ["owner"]); + teamId = team.$id; + + const admin = createAdminClient(); + await admin.tablesDB.createRow( + DATABASE_ID, + TABLES.tenantSettings, + ID.unique(), + { + tenantId: teamId, + companyName, + companyTaxId, + companyPhone, + createdBy: user.$id, + }, + [ + Permission.read(Role.team(teamId)), + Permission.update(Role.team(teamId, "owner")), + Permission.update(Role.team(teamId, "admin")), + Permission.delete(Role.team(teamId, "owner")), + ], + ); + + await session.account.updatePrefs({ + ...(user.prefs ?? {}), + activeTenant: teamId, + }); + + await setActiveTenantCookie(teamId); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + redirect("/dashboard"); +} + +export async function setActiveTenantAction(tenantId: string) { + try { + const { account } = await createSessionClient(); + const user = await account.get(); + + const teams = await (await createSessionClient()).teams.list(); + const owns = teams.teams.some((t) => t.$id === tenantId); + if (!owns) { + return { ok: false, error: "Bu çalışma alanına erişiminiz yok." }; + } + + await account.updatePrefs({ ...(user.prefs ?? {}), activeTenant: tenantId }); + await setActiveTenantCookie(tenantId); + return { ok: true }; + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } +} diff --git a/src/lib/appwrite/tenant-types.ts b/src/lib/appwrite/tenant-types.ts new file mode 100644 index 0000000..77ed39d --- /dev/null +++ b/src/lib/appwrite/tenant-types.ts @@ -0,0 +1,8 @@ +export type WorkspaceState = { + ok: boolean; + error?: string; +}; + +export const initialWorkspaceState: WorkspaceState = { ok: false }; + +export const ACTIVE_TENANT_COOKIE = "isletmem-tenant"; diff --git a/src/lib/appwrite/tenant.ts b/src/lib/appwrite/tenant.ts new file mode 100644 index 0000000..0dbd90e --- /dev/null +++ b/src/lib/appwrite/tenant.ts @@ -0,0 +1,31 @@ +import "server-only"; + +import { cookies } from "next/headers"; + +import { createSessionClient } from "./server"; +import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; + +export async function getUserTeams() { + try { + const { teams } = await createSessionClient(); + return await teams.list(); + } catch { + return null; + } +} + +export async function getActiveTenantId(): Promise { + const cookie = (await cookies()).get(ACTIVE_TENANT_COOKIE)?.value; + if (cookie) return cookie; + + try { + const { account } = await createSessionClient(); + const user = await account.get(); + const fromPrefs = (user.prefs as Record | undefined)?.activeTenant; + if (typeof fromPrefs === "string" && fromPrefs.length > 0) return fromPrefs; + } catch { + return null; + } + + return null; +}