feat(auth): complete sqlite auth and client invitations
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.")}`,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user