feat(auth): complete sqlite auth and client invitations

This commit is contained in:
poyrazavsever
2026-07-16 16:21:31 +03:00
parent cce7265fb1
commit 92bc99ba12
30 changed files with 14355 additions and 170 deletions
@@ -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<string | null>(null);
async function handleCreateUser(e: React.FormEvent<HTMLFormElement>) {
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
<DialogContent>
<form onSubmit={handleCreateUser}>
<DialogHeader>
<DialogTitle>Müşteri Portalı Hesabı Oluştur</DialogTitle>
<DialogTitle>Müşteri Portalına Davet Et</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -133,16 +130,33 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
<Label htmlFor="email">E-posta Adresi</Label>
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Geçici Şifre</Label>
<Input id="password" name="password" type="text" required minLength={6} placeholder="Min 6 karakter" />
</div>
{invitationUrl ? (
<div className="space-y-2">
<Label htmlFor="invitation-url">Davet bağlantısı</Label>
<div className="flex gap-2">
<Input id="invitation-url" value={invitationUrl} readOnly />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Davet bağlantısını kopyala"
onClick={async () => {
await navigator.clipboard.writeText(invitationUrl);
toast.success("Davet bağlantısı kopyalandı.");
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Bağlantı yalnızca bu ekranda düz metin olarak gösterilir.</p>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setCreateUserOpen(false)}>İptal</Button>
<Button type="submit" disabled={isCreatingUser}>
<Button type="submit" disabled={isCreatingUser || Boolean(invitationUrl)}>
{isCreatingUser && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Hesabı Oluştur
Davet Oluştur
</Button>
</DialogFooter>
</form>
+29 -95
View File
@@ -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 });
}
}
@@ -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 });
}
}
+36
View File
@@ -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 });
}
}
@@ -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 });
}
}
+40
View File
@@ -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 });
}
+27
View File
@@ -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.")}`,
);
}
+90
View File
@@ -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 ? <ErrorToaster message={query.message} /> : null}
<AuthPageShell
title="Müşteri portalına katıl"
description="Davet edilen hesabın için adını ve şifreni belirle."
form={
isUsable ? (
<form className="space-y-6">
<input type="hidden" name="token" value={token} />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
E-posta
</Label>
<Input id="email" type="email" value={invitation.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="displayName" className="flex items-center gap-2">
<UserRound className="h-4 w-4 text-muted-foreground" />
Ad soyad
</Label>
<Input id="displayName" name="displayName" required maxLength={120} />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
Şifre
</Label>
<Input id="password" name="password" type="password" required minLength={8} maxLength={128} />
<p className="text-xs text-muted-foreground">En az 8 karakter kullan.</p>
</div>
</div>
<SubmitButton formAction={acceptInvitation} className="h-11 w-full" pendingText="Hesap oluşturuluyor...">
Portal hesabını oluştur
</SubmitButton>
</form>
) : (
<p className="text-sm text-muted-foreground">{unavailableMessage}</p>
)
}
secondaryAction={null}
footer={
<Link href="/login" className="text-sm font-medium text-primary hover:text-primary-hover">
Giriş sayfasına dön
</Link>
}
/>
</>
);
}
+2
View File
@@ -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)}`)
}
+5
View File
@@ -24,6 +24,11 @@ export default async function LoginPage({
description="Neta çalışma alanına erişmek için hesabına giriş yap."
form={
<form className="space-y-6">
{!error && message ? (
<p className="rounded-md border border-success/30 bg-success/5 p-3 text-sm text-foreground">
{String(message)}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">