From 92bc99ba12b3b93bc59eeaf41962697bbabfa8f0 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Thu, 15 Jul 2027 10:15:00 +0300 Subject: [PATCH] feat(auth): complete sqlite auth and client invitations --- .env.example | 2 +- .gitignore | 2 + .../clients/[id]/client-detail-client.tsx | 50 +- app/api/create-client-user/route.ts | 124 +- app/api/portal-clients/[clientId]/route.ts | 39 + app/api/portal-invitations/[id]/route.ts | 36 + app/api/portal-invitations/accept/route.ts | 29 + app/api/portal-invitations/route.ts | 40 + app/invite/[token]/actions.ts | 27 + app/invite/[token]/page.tsx | 90 + app/login/actions.ts | 2 + app/login/page.tsx | 5 + docker-compose.yml | 5 +- docs/self-hosted-redesign/phase-1-runtime.md | 15 +- docs/self-hosted-redesign/phase-2-auth.md | 95 +- package.json | 1 + pnpm-lock.yaml | 12083 ++++++++++++++++ pnpm-workspace.yaml | 5 + scripts/phase1-auth-smoke.mjs | 349 + scripts/phase2-auth-smoke.mjs | 3 + server/auth/auth.ts | 6 +- server/auth/invitations.ts | 487 + server/auth/session.ts | 21 +- server/auth/setup.ts | 139 +- server/auth/types.ts | 16 +- server/config.ts | 29 +- server/db/migrations/0002_mighty_korg.sql | 2 + server/db/migrations/meta/0002_snapshot.json | 804 + server/db/migrations/meta/_journal.json | 7 + server/db/schema/auth.ts | 12 +- 30 files changed, 14355 insertions(+), 170 deletions(-) create mode 100644 app/api/portal-clients/[clientId]/route.ts create mode 100644 app/api/portal-invitations/[id]/route.ts create mode 100644 app/api/portal-invitations/accept/route.ts create mode 100644 app/api/portal-invitations/route.ts create mode 100644 app/invite/[token]/actions.ts create mode 100644 app/invite/[token]/page.tsx create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/phase1-auth-smoke.mjs create mode 100644 server/auth/invitations.ts create mode 100644 server/db/migrations/0002_mighty_korg.sql create mode 100644 server/db/migrations/meta/0002_snapshot.json diff --git a/.env.example b/.env.example index d2ad3ab..cdc3157 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Public URL where users open Neta. +# Public URL where users open Neta. Production'da localhost dışında HTTPS kullanın. NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Canonical server-side app URL used by auth callbacks and trusted origin checks. diff --git a/.gitignore b/.gitignore index ae28c77..0814a5a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +.pnpm-store/ +*.tsbuildinfo diff --git a/app/(dashboard)/clients/[id]/client-detail-client.tsx b/app/(dashboard)/clients/[id]/client-detail-client.tsx index f5dba37..938f787 100644 --- a/app/(dashboard)/clients/[id]/client-detail-client.tsx +++ b/app/(dashboard)/clients/[id]/client-detail-client.tsx @@ -5,8 +5,7 @@ import { format } from "date-fns"; import { tr } from "date-fns/locale"; import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules"; -import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText, UserPlus, Loader2 } from "lucide-react"; -import Link from "next/link"; +import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react"; import { toast } from "poyraz-ui/molecules"; import { addClientActivity } from "./actions"; @@ -66,30 +65,28 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai const [isCreatingUser, setIsCreatingUser] = useState(false); const [createUserOpen, setCreateUserOpen] = useState(false); + const [invitationUrl, setInvitationUrl] = useState(null); async function handleCreateUser(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); const email = formData.get("email") as string; - const password = formData.get("password") as string; setIsCreatingUser(true); try { const res = await fetch("/api/create-client-user", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, client_id: client.id }) + body: JSON.stringify({ email, client_id: client.id }) }); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || "Kullanıcı oluşturulamadı."); } - toast.success("Müşteri portal hesabı başarıyla oluşturuldu."); - setCreateUserOpen(false); - // Optional: Refresh page to reflect the new client_auth_id - window.location.reload(); - } catch (err: any) { - toast.error(err.message); + setInvitationUrl(data.invitation.invitationUrl); + toast.success("Güvenli portal daveti oluşturuldu."); + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı."); } finally { setIsCreatingUser(false); } @@ -123,9 +120,9 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
- Müşteri Portalı Hesabı Oluştur + Müşteri Portalına Davet Et - Müşteriniz bu e-posta ve şifre ile sisteme giriş yaparak projelerini takip edebilir. + Müşterin bağlantıyı açıp kendi şifresini belirler. Davet 72 saat geçerlidir ve yalnızca bir kez kullanılabilir.
@@ -133,16 +130,33 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
-
- - -
+ {invitationUrl ? ( +
+ +
+ + +
+

Bağlantı yalnızca bu ekranda düz metin olarak gösterilir.

+
+ ) : null} -
diff --git a/app/api/create-client-user/route.ts b/app/api/create-client-user/route.ts index bc8204d..b08845f 100644 --- a/app/api/create-client-user/route.ts +++ b/app/api/create-client-user/route.ts @@ -1,103 +1,37 @@ -import { createInternalAuthUser } from "@/lib/auth/internal-users"; -import { createClient } from "@/lib/supabase/server"; import { NextResponse } from "next/server"; +import { + createPortalInvitation, + PortalInvitationError, +} from "@/server/auth/invitations"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; +/** + * Legacy adapter for the current client detail screen. + * The old endpoint created a Supabase Auth user with a freelancer-chosen password. + * It now issues a one-time Better Auth invitation and never accepts a password. + */ export async function POST(request: Request) { + const actor = await getSessionContextFromHeaders(new Headers(request.headers)); + + if (!actor) { + return NextResponse.json({ error: "Müşteri daveti için giriş yapmalısınız." }, { status: 401 }); + } + try { - const { email, password, client_id } = await request.json(); + const { email, client_id: clientId } = await request.json(); + const invitation = await createPortalInvitation(actor, { email, clientId }); - if (!email || !password || !client_id) { - return NextResponse.json( - { error: "E-posta, şifre ve müşteri ID gereklidir." }, - { status: 400 }, - ); - } - - const supabase = await createClient(); - const { - data: { user }, - error: userError, - } = await supabase.auth.getUser(); - - if (userError || !user) { - return NextResponse.json( - { error: "Müşteri hesabı oluşturmak için giriş yapmalısınız." }, - { status: 401 }, - ); - } - - const { data: client, error: clientError } = await supabase - .from("clients") - .select("id, client_auth_id") - .eq("id", client_id) - .eq("user_id", user.id) - .single(); - - if (clientError || !client) { - return NextResponse.json( - { error: "Müşteri kaydı bulunamadı." }, - { status: 404 }, - ); - } - - if (client.client_auth_id) { - return NextResponse.json( - { error: "Bu müşteri için portal hesabı zaten oluşturulmuş." }, - { status: 409 }, - ); - } - - const { - admin, - user: createdUser, - userId, - } = await createInternalAuthUser({ - email, - password, - role: "client", - reason: "client_portal", - }); - - const { error: profileError } = await admin - .from("profiles") - .update({ role: "client" }) - .eq("id", userId); - - if (profileError) { - return NextResponse.json( - { - error: `Kullanıcı oluşturuldu fakat profil rolü güncellenemedi: ${profileError.message}`, - }, - { status: 500 }, - ); - } - - const { error: updateClientError } = await admin - .from("clients") - .update({ client_auth_id: userId }) - .eq("id", client_id) - .eq("user_id", user.id); - - if (updateClientError) { - return NextResponse.json( - { - error: `Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi: ${updateClientError.message}`, - }, - { status: 500 }, - ); - } - - return NextResponse.json({ success: true, user: createdUser }); + return NextResponse.json({ success: true, invitation }, { status: 201 }); } catch (error) { - console.error("Create client user error:", error); - return NextResponse.json( - { - error: - error instanceof Error - ? error.message - : "Sunucu tarafında beklenmeyen bir hata oluştu.", - }, - { status: 500 }, - ); + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 }); + } + if (error instanceof PortalInvitationError) { + const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Legacy client invitation adapter failed", error); + return NextResponse.json({ error: "Müşteri daveti oluşturulamadı." }, { status: 500 }); } } diff --git a/app/api/portal-clients/[clientId]/route.ts b/app/api/portal-clients/[clientId]/route.ts new file mode 100644 index 0000000..14b1d8f --- /dev/null +++ b/app/api/portal-clients/[clientId]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { + PortalInvitationError, + setClientPortalAccess, +} from "@/server/auth/invitations"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ clientId: string }> }, +) { + const actor = await getSessionContextFromHeaders(new Headers(request.headers)); + + if (!actor) { + return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 }); + } + + try { + const { enabled } = await request.json(); + + if (typeof enabled !== "boolean") { + return NextResponse.json({ error: "enabled boolean olmalıdır." }, { status: 400 }); + } + + setClientPortalAccess(actor, (await params).clientId, enabled); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 }); + } + if (error instanceof PortalInvitationError) { + const status = error.code === "FORBIDDEN" ? 403 : 404; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Client portal access update failed", error); + return NextResponse.json({ error: "Portal erişimi güncellenemedi." }, { status: 500 }); + } +} diff --git a/app/api/portal-invitations/[id]/route.ts b/app/api/portal-invitations/[id]/route.ts new file mode 100644 index 0000000..a6065f2 --- /dev/null +++ b/app/api/portal-invitations/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { + PortalInvitationError, + revokePortalInvitation, +} from "@/server/auth/invitations"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const actor = await getSessionContextFromHeaders(new Headers(request.headers)); + + if (!actor) { + return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 }); + } + + const invitationId = Number((await params).id); + + if (!Number.isInteger(invitationId) || invitationId < 1) { + return NextResponse.json({ error: "Geçersiz davet kimliği." }, { status: 400 }); + } + + try { + revokePortalInvitation(actor, invitationId); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PortalInvitationError) { + const status = error.code === "FORBIDDEN" ? 403 : 409; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Portal invitation revoke failed", error); + return NextResponse.json({ error: "Davet iptal edilemedi." }, { status: 500 }); + } +} diff --git a/app/api/portal-invitations/accept/route.ts b/app/api/portal-invitations/accept/route.ts new file mode 100644 index 0000000..0a24b33 --- /dev/null +++ b/app/api/portal-invitations/accept/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { + acceptPortalInvitation, + PortalInvitationError, +} from "@/server/auth/invitations"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + await acceptPortalInvitation({ + token: body.token, + displayName: body.displayName, + password: body.password, + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 }); + } + if (error instanceof PortalInvitationError) { + const status = error.code === "INVALID_INPUT" ? 400 : 409; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Portal invitation accept failed", error); + return NextResponse.json({ error: "Portal hesabı oluşturulamadı." }, { status: 500 }); + } +} diff --git a/app/api/portal-invitations/route.ts b/app/api/portal-invitations/route.ts new file mode 100644 index 0000000..47658a6 --- /dev/null +++ b/app/api/portal-invitations/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { + createPortalInvitation, + PortalInvitationError, +} from "@/server/auth/invitations"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; + +export async function POST(request: Request) { + const actor = await getSessionContextFromHeaders(new Headers(request.headers)); + + if (!actor) { + return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 }); + } + + try { + const body = await request.json(); + const invitation = await createPortalInvitation(actor, { + clientId: body.clientId, + email: body.email, + expiresInHours: body.expiresInHours, + }); + + return NextResponse.json({ invitation }, { status: 201 }); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 }); + } + return invitationErrorResponse(error); + } +} + +function invitationErrorResponse(error: unknown) { + if (error instanceof PortalInvitationError) { + const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409; + return NextResponse.json({ error: error.message, code: error.code }, { status }); + } + + console.error("Portal invitation create failed", error); + return NextResponse.json({ error: "Davet oluşturulamadı." }, { status: 500 }); +} diff --git a/app/invite/[token]/actions.ts b/app/invite/[token]/actions.ts new file mode 100644 index 0000000..1ced07b --- /dev/null +++ b/app/invite/[token]/actions.ts @@ -0,0 +1,27 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { + acceptPortalInvitation, + PortalInvitationError, +} from "@/server/auth/invitations"; + +export async function acceptInvitation(formData: FormData) { + const token = String(formData.get("token") ?? ""); + const displayName = String(formData.get("displayName") ?? ""); + const password = String(formData.get("password") ?? ""); + + try { + await acceptPortalInvitation({ token, displayName, password }); + } catch (error) { + const message = + error instanceof PortalInvitationError + ? error.message + : "Portal hesabı oluşturulamadı."; + redirect(`/invite/${encodeURIComponent(token)}?error=true&message=${encodeURIComponent(message)}`); + } + + redirect( + `/login?message=${encodeURIComponent("Portal hesabın oluşturuldu. Şimdi giriş yapabilirsin.")}`, + ); +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 0000000..41e90d0 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,90 @@ +import { LockKeyhole, Mail, UserRound } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { acceptInvitation } from "./actions"; +import { AuthPageShell } from "@/components/auth/auth-page-shell"; +import { SubmitButton } from "@/components/auth/submit-button"; +import { ErrorToaster } from "@/components/error-toaster"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/field"; +import { getPortalInvitationPreview } from "@/server/auth/invitations"; + +export const dynamic = "force-dynamic"; + +export default async function InvitationPage({ + params, + searchParams, +}: { + params: Promise<{ token: string }>; + searchParams: Promise<{ error?: string; message?: string }>; +}) { + const { token } = await params; + const invitation = getPortalInvitationPreview(token); + + if (!invitation) { + notFound(); + } + + const query = await searchParams; + const isUsable = invitation.status === "pending"; + const unavailableMessage = + invitation.status === "expired" + ? "Bu davetin süresi dolmuş. Freelancer'dan yeni bir bağlantı istemelisin." + : invitation.status === "accepted" + ? "Bu davet daha önce kullanılmış. Hesabınla giriş yapabilirsin." + : invitation.status === "revoked" + ? "Bu davet iptal edilmiş. Freelancer'dan yeni bir bağlantı istemelisin." + : null; + + return ( + <> + {query.error && query.message ? : null} + + +
+
+ + +
+
+ + +
+
+ + +

En az 8 karakter kullan.

+
+
+ + Portal hesabını oluştur + + + ) : ( +

{unavailableMessage}

+ ) + } + secondaryAction={null} + footer={ + + Giriş sayfasına dön + + } + /> + + ); +} diff --git a/app/login/actions.ts b/app/login/actions.ts index 84acb34..78adebe 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -6,6 +6,7 @@ import { auth } from '@/server/auth/auth' import { callAuthAction } from '@/server/auth/action-handler' import { getProfileByAuthUserId } from '@/server/auth/session' import { + failFirstFreelancerSetup, getFirstFreelancerSetupState, recordAuthAuditEvent, repairFirstFreelancerSetupForEmail, @@ -85,6 +86,7 @@ export async function signup(formData: FormData) { rememberMe: true, }) } catch (error) { + failFirstFreelancerSetup(credentials.email, 'better_auth_signup_failed') const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.' redirect(`/register?error=true&message=${encodeURIComponent(message)}`) } diff --git a/app/login/page.tsx b/app/login/page.tsx index 856c86c..e2243f4 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -24,6 +24,11 @@ export default async function LoginPage({ description="Neta çalışma alanına erişmek için hesabına giriş yap." form={
+ {!error && message ? ( +

+ {String(message)} +

+ ) : null}