feat(auth): complete sqlite auth and client invitations
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -12,3 +12,5 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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">
|
||||
|
||||
+4
-1
@@ -7,7 +7,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATA_DIR: /app/data
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}
|
||||
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
|
||||
volumes:
|
||||
- neta-data:/app/data
|
||||
healthcheck:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: Phase 1 Runtime, SQLite and Deploy Skeleton
|
||||
description: Faz 1 kapsamında eklenen Next.js standalone, SQLite, Drizzle migration, health, Docker ve backup/restore kanıtları.
|
||||
status: active
|
||||
last_updated: 2026-07-10
|
||||
status: complete
|
||||
last_updated: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 1 Runtime, SQLite and Deploy Skeleton
|
||||
@@ -33,6 +33,8 @@ Bu dosya Faz 1 için uygulanan runtime iskeletini ve local doğrulama sonuçlar
|
||||
- Relative path değerleri `process.cwd()` üzerinden absolute hale getirilir.
|
||||
- Uygulama `data`, `uploads`, `backups` ve `tmp` dizinlerini kontrollü oluşturur.
|
||||
- Secret değerler config validation çıktısına yazılmaz; mevcut config modülü boot sırasında değer dump etmez.
|
||||
- Production build sırasında route modüllerinin SQLite başlatması için her worker/process ayrı bir geçici data dizini kullanır; production runtime varsayılanı `/app/data` olarak kalır.
|
||||
- `APP_URL`, `NEXT_PUBLIC_SITE_URL`, `BETTER_AUTH_SECRET` ve `TRUSTED_ORIGINS` Compose ortam sözleşmesinde açıktır.
|
||||
|
||||
## SQLite davranışı
|
||||
|
||||
@@ -67,7 +69,7 @@ Health response DB path, data path, schema path veya secret döndürmez.
|
||||
|
||||
## Doğrulama sonuçları
|
||||
|
||||
2026-07-10 local sonuçları:
|
||||
2026-07-16 güncel local sonuçları:
|
||||
|
||||
| Komut | Sonuç | Not |
|
||||
| --- | --- | --- |
|
||||
@@ -77,8 +79,10 @@ Health response DB path, data path, schema path veya secret döndürmez.
|
||||
| `npm.cmd run phase1:smoke` | Başarılı | Temp data dir, migration, persistence, backup ve restore geçti. |
|
||||
| `npm.cmd run typecheck` | Başarılı | TypeScript temiz. |
|
||||
| `npm.cmd run build` | Başarılı | Next.js production build geçti, standalone output etkin. |
|
||||
| `npm.cmd run lint` | Başarısız | Faz 0 baseline ile aynı 34 error, 25 warning; yeni Faz 1 dosyaları lint çıktısında görünmedi. |
|
||||
| `docker compose build` | Başarısız | Docker Desktop/Linux engine çalışmıyor: daemon pipe bulunamadı. Docker smoke henüz doğrulanmadı. |
|
||||
| `node scripts/phase1-auth-smoke.mjs` | Başarılı | Localhost üzerinde setup, login/logout, invitation ve negatif role/token senaryoları geçti. |
|
||||
| `docker compose config` | Başarılı | Production secret ve URL env sözleşmesi çözüldü. |
|
||||
| `npm.cmd run lint` | Başarısız | Eski feature baseline'ında 31 error, 18 warning; değişen Faz 1 dosyalarında targeted lint temiz. |
|
||||
| `docker compose build` | Çalıştırılamadı | Docker Desktop/Linux engine çalışmıyor: daemon socket bulunamadı. Docker runtime smoke henüz doğrulanmadı. |
|
||||
|
||||
## Backup/restore POC
|
||||
|
||||
@@ -122,4 +126,3 @@ Compose kararları:
|
||||
Docker doğrulaması açık istisna:
|
||||
|
||||
- Local Docker daemon çalışmadığı için `docker compose build`, `docker compose up`, native SQLite Linux runtime ve container restart persistence testleri yapılamadı.
|
||||
|
||||
|
||||
@@ -1,39 +1,78 @@
|
||||
# Phase 2 Auth Implementation Notes
|
||||
---
|
||||
title: Phase 1 Auth and Client Invitation Implementation
|
||||
description: Better Auth, SQLite setup lock, session guards, client invitation lifecycle and auth audit implementation notes.
|
||||
status: complete
|
||||
last_updated: 2026-07-16
|
||||
---
|
||||
|
||||
## Kapsam
|
||||
# Phase 1 Auth and Client Invitation Implementation
|
||||
|
||||
Faz 2'de Supabase Auth yerine Better Auth + Drizzle SQLite temelli ilk auth katmanı eklendi. Bu faz, veri ekranlarının tamamını Supabase'ten taşımıyor; koruma noktalarını ve yeni session contract'ını hazır hale getiriyor.
|
||||
Bu dosya tarihsel adı korunarak Faz 1'de tamamlanan Better Auth + SQLite auth temelini kaydeder. Domain ekranlarının Supabase veri sorgularından taşınması Faz 2 ve sonraki domain fazlarının kapsamındadır; auth ve davet yaşam döngüsü artık Supabase Auth kullanmaz.
|
||||
|
||||
## Eklenen runtime parçaları
|
||||
## Tamamlanan runtime parçaları
|
||||
|
||||
- `server/auth/auth.ts`: Better Auth server-only config.
|
||||
- `app/api/auth/[...all]/route.ts`: Better Auth GET/POST Route Handler.
|
||||
- `server/db/schema/auth.ts`: Better Auth auth tabloları, Neta profile tablosu, setup lock, portal invitation ve audit log tabloları.
|
||||
- `server/auth/session.ts`: request içi memoize edilen `getSessionContext`, `requireSession`, `requireFreelancer`, `requireClientUser`.
|
||||
- `server/auth/setup.ts`: ilk freelancer setup durumu, atomic setup guard ve audit yazımı.
|
||||
- `server/auth/authorization.ts`: role ve owner assertion helper'ları.
|
||||
- `server/auth/auth.ts`: Better Auth config, first-owner user hook'ları ve session oluşturma audit/guard hook'ları.
|
||||
- `server/auth/setup.ts`: transaction korumalı ilk owner kilidi, stale repair, başarısız setup kilidi temizliği ve auth audit yazımı.
|
||||
- `server/auth/session.ts`: web ve Route Handler için session context, disabled profile ve role/client binding kontrolleri.
|
||||
- `server/auth/invitations.ts`: davet üretme, hash-only token, replacement/revoke, expiry, transaction içinde kabul ve client access enable/disable servisi.
|
||||
- `app/api/portal-invitations/*`: freelancer-only create/revoke ve public invitation accept adapter'ları.
|
||||
- `app/api/portal-clients/[clientId]`: client portal erişimi enable/disable adapter'ı.
|
||||
- `app/invite/[token]`: davet durumu ve müşterinin kendi şifresini belirlediği kabul ekranı.
|
||||
- `server/db/migrations/0002_mighty_korg.sql`: `app_profiles.client_id` identity binding ve unique index migration'ı.
|
||||
|
||||
## Güvenlik kararları
|
||||
## İlk owner ve public registration
|
||||
|
||||
- `BETTER_AUTH_SECRET` production runtime'da zorunludur. Build sırasında placeholder kullanılır; runtime'da env yoksa uygulama hata verir.
|
||||
- `TRUSTED_ORIGINS` wildcard kabul etmez.
|
||||
- Auth cookie'leri production'da `Secure`, tüm ortamlarda `HttpOnly`, `SameSite=Lax`, `Path=/` ayarlarıyla üretilir.
|
||||
- Public sign-up endpoint'i `databaseHooks.user.create.before` ile ilk freelancer setup guard'ına bağlıdır. İlk freelancer oluştuktan sonra doğrudan `/api/auth/sign-up/email` çağrısı da kullanıcı oluşturamaz.
|
||||
- Login hatası genel mesaj döndürür; email varlığı sızdırılmaz.
|
||||
- Public `/api/auth/sign-up/email`, ilk owner'dan sonra database hook seviyesinde kapanır; yalnızca `/register` UI kontrolüne dayanmaz.
|
||||
- Eşzamanlı ilk kayıt istekleri `app_setup_state` kilidiyle serialize edilir.
|
||||
- İlk başarılı kullanıcı `app_profiles.role = freelancer` olarak bağlanır.
|
||||
- Better Auth user insert ile session insert arasındaki hook sırası için stale repair çalışır; profile tamamlanmadan session verilmez.
|
||||
- Setup başarısızsa aynı e-postaya ait pending kilit temizlenir ve `setup_failed` audit olayı yazılır.
|
||||
|
||||
## Bilinen sınırlar
|
||||
## Client invitation sözleşmesi
|
||||
|
||||
- Portal client kullanıcı üretimi bu fazda sadece token modeli seviyesindedir; gerçek client invitation tüketimi sonraki veri/API fazında tamamlanacak.
|
||||
- Eski dashboard ve portal feature sayfalarının veri sorguları hâlâ Supabase kullanıyor. Layout koruması Better Auth'a taşındı, veri okuma/yazma Faz 4-6 kapsamındadır.
|
||||
- Reverse proxy/TLS altında cookie testi Docker daemon çalışmadığı için bu turda kapatılmadı.
|
||||
- Yalnızca aktif `freelancer` rolü davet oluşturabilir veya iptal edebilir.
|
||||
- Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır.
|
||||
- Varsayılan TTL 72 saat, servis üst sınırı 168 saattir.
|
||||
- Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir.
|
||||
- Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `client_id` identity bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır.
|
||||
- Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz.
|
||||
- Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler.
|
||||
- Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz.
|
||||
|
||||
## Doğrulama
|
||||
`app_profiles.client_id`, Faz 1 auth sınırında opaque domain identity bağıdır. Yerel `clients` tablosu ve foreign key Faz 2 domain migration'ında eklenecektir; mevcut Supabase client sayfaları bu nedenle henüz domain açısından hibrittir.
|
||||
|
||||
- `npm run db:generate`
|
||||
- `npm run db:migrate`
|
||||
- `npm run phase2:smoke`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm run lint`
|
||||
## Audit kapsamı
|
||||
|
||||
`npm run lint` mevcut proje baseline'ındaki eski hatalar nedeniyle başarısız kalabilir; Faz 2 dosyalarında yeni lint bulgusu bırakılmamalıdır.
|
||||
Kapsanan olaylar:
|
||||
|
||||
- setup start/completion/failure ve kapalı registration denemesi;
|
||||
- login success/failure ve logout;
|
||||
- invitation create/revoke/expire/accept/accept failure;
|
||||
- client access disable/enable.
|
||||
|
||||
Raw invitation token, parola, session token veya auth secret audit metadata'sına yazılmaz.
|
||||
|
||||
## Production güvenlik ve env
|
||||
|
||||
- Production runtime'da en az 32 karakter `BETTER_AUTH_SECRET` zorunludur.
|
||||
- `APP_URL`, `NEXT_PUBLIC_SITE_URL`, opsiyonel `TRUSTED_ORIGINS` ve secret Compose sözleşmesine eklenmiştir.
|
||||
- Wildcard trusted origin reddedilir.
|
||||
- Cookie'ler HTTPS deployment'ta `Secure`; tüm ortamlarda `HttpOnly`, `SameSite=Lax`, `Path=/` kullanır. Local Docker'ın `http://localhost` kurulumu kontrollü istisnadır; production'da localhost dışındaki HTTP `APP_URL` boot sırasında reddedilir.
|
||||
- Build worker'ları SQLite module initialization sırasında birbirini kilitlemesin diye production build her process için ayrı geçici data dizini kullanır. Runtime yolu değişmez: `/app/data`.
|
||||
|
||||
## Doğrulama — 2026-07-16
|
||||
|
||||
| Kontrol | Sonuç |
|
||||
| --- | --- |
|
||||
| `npm run typecheck` | Başarılı |
|
||||
| Değişen Faz 1 dosyalarında targeted ESLint | 0 error, 0 warning |
|
||||
| `node scripts/phase1-smoke.mjs` | Başarılı |
|
||||
| `node scripts/phase2-auth-smoke.mjs` | Başarılı |
|
||||
| `node scripts/phase1-auth-smoke.mjs` | Başarılı, ardışık iki çalışma |
|
||||
| `npm run build` | Başarılı, standalone route çıktısı üretildi |
|
||||
| `docker compose config` | Başarılı, secret ve URL env'leri çözüldü |
|
||||
| Docker image/runtime smoke | Çalıştırılamadı; yerel Docker daemon aktif değil |
|
||||
|
||||
Uçtan uca auth smoke şu senaryoları kapsar: concurrent first setup, kayıt kapanışı, owner login/logout, token'ın hash saklanması, replacement revoke, davet kabulü, replay reddi, expired/revoked token reddi, client→freelancer role ihlali, disable ile session revoke, disabled direct login reddi ve enable sonrası login.
|
||||
|
||||
Repo geneli lint, Faz 0'da kaydedilmiş eski feature dosyalarındaki baseline hatalar nedeniyle kalite kapısı olarak açık kalır. Faz 1'de değiştirilen dosyalarda yeni lint bulgusu yoktur.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"db:backup": "node scripts/backup.mjs",
|
||||
"db:restore": "node scripts/restore.mjs",
|
||||
"phase1:smoke": "node scripts/phase1-smoke.mjs",
|
||||
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
|
||||
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
|
||||
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
|
||||
},
|
||||
|
||||
Generated
+12083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,349 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const port = await getAvailablePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_ENV: "development",
|
||||
DATA_DIR: dataDir,
|
||||
DATABASE_PATH: databasePath,
|
||||
APP_URL: baseUrl,
|
||||
NEXT_PUBLIC_SITE_URL: baseUrl,
|
||||
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
|
||||
TRUSTED_ORIGINS: baseUrl,
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
},
|
||||
);
|
||||
|
||||
let serverOutput = "";
|
||||
server.stdout.on("data", (chunk) => {
|
||||
serverOutput = `${serverOutput}${chunk}`.slice(-12000);
|
||||
});
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverOutput = `${serverOutput}${chunk}`.slice(-12000);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
|
||||
const setupAttempts = await Promise.all([
|
||||
authPost("/api/auth/sign-up/email", {
|
||||
name: "Owner One",
|
||||
email: "owner-one@example.com",
|
||||
password: "OwnerOne-Password-123",
|
||||
}),
|
||||
authPost("/api/auth/sign-up/email", {
|
||||
name: "Owner Two",
|
||||
email: "owner-two@example.com",
|
||||
password: "OwnerTwo-Password-123",
|
||||
}),
|
||||
]);
|
||||
const successfulSetups = setupAttempts.filter((attempt) => attempt.response.ok);
|
||||
assert.equal(successfulSetups.length, 1, "Concurrent setup must create exactly one owner");
|
||||
|
||||
const setupPayload = successfulSetups[0].payload;
|
||||
const ownerEmail = setupPayload.user.email;
|
||||
let ownerCookie = cookieHeader(successfulSetups[0].response);
|
||||
|
||||
const db = new Database(databasePath);
|
||||
try {
|
||||
assert.equal(
|
||||
db.prepare("select count(*) as value from app_profiles where role = 'freelancer'").get().value,
|
||||
1,
|
||||
"Exactly one freelancer profile must exist",
|
||||
);
|
||||
|
||||
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
|
||||
name: "Public Attacker",
|
||||
email: "attacker@example.com",
|
||||
password: "Attacker-Password-123",
|
||||
});
|
||||
assert.equal(rejectedRegistration.response.ok, false, "Public registration must close after setup");
|
||||
|
||||
if (!ownerCookie) {
|
||||
const signedIn = await authPost("/api/auth/sign-in/email", {
|
||||
email: ownerEmail,
|
||||
password: ownerEmail.startsWith("owner-one")
|
||||
? "OwnerOne-Password-123"
|
||||
: "OwnerTwo-Password-123",
|
||||
});
|
||||
assert.equal(signedIn.response.ok, true, "Owner must be able to sign in");
|
||||
ownerCookie = cookieHeader(signedIn.response);
|
||||
}
|
||||
assert.ok(ownerCookie, "Owner session cookie must be issued");
|
||||
|
||||
const anonymousInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
body: { clientId: "anonymous-client", email: "anonymous@example.com" },
|
||||
});
|
||||
assert.equal(anonymousInvite.response.status, 401, "Anonymous invitation creation must fail");
|
||||
|
||||
const invalidInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: ownerCookie,
|
||||
body: { clientId: "", email: "not-an-email" },
|
||||
});
|
||||
assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail");
|
||||
|
||||
const firstInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: ownerCookie,
|
||||
body: { clientId: "client-alpha", email: "client@example.com" },
|
||||
});
|
||||
assert.equal(firstInvite.response.status, 201);
|
||||
const rawFirstToken = tokenFromUrl(firstInvite.payload.invitation.invitationUrl);
|
||||
const storedFirst = db
|
||||
.prepare("select token_hash as tokenHash, status from portal_invitations where id = ?")
|
||||
.get(firstInvite.payload.invitation.id);
|
||||
assert.notEqual(storedFirst.tokenHash, rawFirstToken, "Raw invitation token must not be stored");
|
||||
assert.equal(storedFirst.tokenHash, sha256(rawFirstToken));
|
||||
|
||||
const secondInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: ownerCookie,
|
||||
body: { clientId: "client-alpha", email: "client@example.com" },
|
||||
});
|
||||
assert.equal(secondInvite.response.status, 201);
|
||||
assert.equal(
|
||||
db.prepare("select status from portal_invitations where id = ?").get(firstInvite.payload.invitation.id).status,
|
||||
"revoked",
|
||||
"A replacement invitation must revoke the prior active invitation",
|
||||
);
|
||||
|
||||
const revokedByReplacement = await acceptInvite(rawFirstToken);
|
||||
assert.equal(revokedByReplacement.response.status, 409);
|
||||
|
||||
const rawClientToken = tokenFromUrl(secondInvite.payload.invitation.invitationUrl);
|
||||
const accepted = await acceptInvite(rawClientToken);
|
||||
assert.equal(accepted.response.status, 201, JSON.stringify(accepted.payload));
|
||||
const clientAuthUserId = db
|
||||
.prepare("select auth_user_id as authUserId from app_profiles where email = ?")
|
||||
.get("client@example.com").authUserId;
|
||||
assert.deepEqual(
|
||||
db
|
||||
.prepare("select role, client_id as clientId, disabled from app_profiles where email = ?")
|
||||
.get("client@example.com"),
|
||||
{ role: "client", clientId: "client-alpha", disabled: 0 },
|
||||
);
|
||||
assert.notEqual(
|
||||
db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password,
|
||||
"Client-Password-123",
|
||||
"Client password must be hashed",
|
||||
);
|
||||
|
||||
const replayed = await acceptInvite(rawClientToken);
|
||||
assert.equal(replayed.response.status, 409, "Accepted invitation must be single-use");
|
||||
|
||||
const clientSignIn = await authPost("/api/auth/sign-in/email", {
|
||||
email: "client@example.com",
|
||||
password: "Client-Password-123",
|
||||
});
|
||||
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
|
||||
const clientCookie = cookieHeader(clientSignIn.response);
|
||||
|
||||
const roleViolation = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: clientCookie,
|
||||
body: { clientId: "forbidden-client", email: "forbidden@example.com" },
|
||||
});
|
||||
assert.equal(roleViolation.response.status, 403, "Client must not create invitations");
|
||||
|
||||
const disableClient = await jsonRequest("/api/portal-clients/client-alpha", {
|
||||
method: "PATCH",
|
||||
cookie: ownerCookie,
|
||||
body: { enabled: false },
|
||||
});
|
||||
assert.equal(disableClient.response.ok, true);
|
||||
const revokedSession = await fetch(`${baseUrl}/api/auth/get-session`, {
|
||||
headers: { cookie: clientCookie },
|
||||
});
|
||||
assert.equal((await revokedSession.json()), null, "Disabling a client must revoke active sessions");
|
||||
|
||||
const disabledSignIn = await authPost("/api/auth/sign-in/email", {
|
||||
email: "client@example.com",
|
||||
password: "Client-Password-123",
|
||||
});
|
||||
assert.equal(disabledSignIn.response.ok, false, "Disabled client must not create a session directly");
|
||||
|
||||
const enableClient = await jsonRequest("/api/portal-clients/client-alpha", {
|
||||
method: "PATCH",
|
||||
cookie: ownerCookie,
|
||||
body: { enabled: true },
|
||||
});
|
||||
assert.equal(enableClient.response.ok, true);
|
||||
const enabledSignIn = await authPost("/api/auth/sign-in/email", {
|
||||
email: "client@example.com",
|
||||
password: "Client-Password-123",
|
||||
});
|
||||
assert.equal(enabledSignIn.response.ok, true, "Re-enabled client must be able to sign in");
|
||||
|
||||
const expiringInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: ownerCookie,
|
||||
body: { clientId: "client-expired", email: "expired@example.com" },
|
||||
});
|
||||
const rawExpiredToken = tokenFromUrl(expiringInvite.payload.invitation.invitationUrl);
|
||||
db.prepare("update portal_invitations set expires_at = ? where id = ?").run(
|
||||
Date.now() - 1000,
|
||||
expiringInvite.payload.invitation.id,
|
||||
);
|
||||
const expired = await acceptInvite(rawExpiredToken);
|
||||
assert.equal(expired.response.status, 409);
|
||||
assert.equal(expired.payload.code, "INVITATION_EXPIRED");
|
||||
assert.equal(
|
||||
db.prepare("select status from portal_invitations where id = ?").get(expiringInvite.payload.invitation.id).status,
|
||||
"expired",
|
||||
);
|
||||
|
||||
const manualRevokeInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: ownerCookie,
|
||||
body: { clientId: "client-revoked", email: "revoked@example.com" },
|
||||
});
|
||||
const rawRevokedToken = tokenFromUrl(manualRevokeInvite.payload.invitation.invitationUrl);
|
||||
const revoked = await jsonRequest(
|
||||
`/api/portal-invitations/${manualRevokeInvite.payload.invitation.id}`,
|
||||
{ method: "DELETE", cookie: ownerCookie },
|
||||
);
|
||||
assert.equal(revoked.response.ok, true);
|
||||
assert.equal((await acceptInvite(rawRevokedToken)).response.status, 409);
|
||||
|
||||
const auditTypes = new Set(
|
||||
db.prepare("select distinct type from auth_audit_events").all().map((row) => row.type),
|
||||
);
|
||||
for (const requiredType of [
|
||||
"setup_started",
|
||||
"setup_completed",
|
||||
"registration_rejected",
|
||||
"login_succeeded",
|
||||
"login_failed",
|
||||
"invitation_created",
|
||||
"invitation_accepted",
|
||||
"invitation_revoked",
|
||||
"invitation_expired",
|
||||
"client_access_disabled",
|
||||
"client_access_enabled",
|
||||
]) {
|
||||
assert.ok(auditTypes.has(requiredType), `Missing audit event: ${requiredType}`);
|
||||
}
|
||||
|
||||
const signOut = await authPost("/api/auth/sign-out", {}, ownerCookie);
|
||||
assert.equal(signOut.response.ok, true);
|
||||
const ownerSessionAfterLogout = await fetch(`${baseUrl}/api/auth/get-session`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
assert.equal(await ownerSessionAfterLogout.json(), null, "Logout must revoke owner session");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log("Phase 1 auth and invitation smoke passed.");
|
||||
} catch (error) {
|
||||
console.error(serverOutput);
|
||||
throw error;
|
||||
} finally {
|
||||
if (server.pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-server.pid, "SIGTERM");
|
||||
} catch {}
|
||||
} else {
|
||||
server.kill("SIGTERM");
|
||||
}
|
||||
await Promise.race([
|
||||
new Promise((resolve) => server.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function acceptInvite(token) {
|
||||
return jsonRequest("/api/portal-invitations/accept", {
|
||||
method: "POST",
|
||||
body: { token, displayName: "Portal Client", password: "Client-Password-123" },
|
||||
});
|
||||
}
|
||||
|
||||
async function authPost(pathname, body, cookie) {
|
||||
return jsonRequest(pathname, { method: "POST", body, cookie });
|
||||
}
|
||||
|
||||
async function jsonRequest(pathname, { method, body, cookie } = {}) {
|
||||
const headers = { origin: baseUrl };
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
if (cookie) headers.cookie = cookie;
|
||||
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
method: method ?? "GET",
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function cookieHeader(response) {
|
||||
const values = response.headers.getSetCookie?.() ?? [];
|
||||
const fallback = response.headers.get("set-cookie");
|
||||
return (values.length > 0 ? values : fallback ? [fallback] : [])
|
||||
.map((value) => value.split(";", 1)[0])
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function tokenFromUrl(value) {
|
||||
return new URL(value).pathname.split("/").at(-1);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (server.exitCode !== null) {
|
||||
throw new Error(`Next.js server exited early (${server.exitCode}).\n${serverOutput}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health/live`);
|
||||
if (response.ok) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Next.js server.\n${serverOutput}`);
|
||||
}
|
||||
|
||||
function getAvailablePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = net.createServer();
|
||||
listener.once("error", reject);
|
||||
listener.listen(0, "127.0.0.1", () => {
|
||||
const address = listener.address();
|
||||
listener.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const requiredTables = [
|
||||
|
||||
const requiredIndexes = [
|
||||
"app_profiles_auth_user_id_unique",
|
||||
"app_profiles_client_id_unique",
|
||||
"portal_invitations_token_hash_unique",
|
||||
"session_user_id_idx",
|
||||
"account_user_id_idx",
|
||||
@@ -50,9 +51,11 @@ async function main() {
|
||||
const profileColumns = sqlite.prepare("pragma table_info(app_profiles)").all();
|
||||
const roleColumn = profileColumns.find((column) => column.name === "role");
|
||||
const disabledColumn = profileColumns.find((column) => column.name === "disabled");
|
||||
const clientIdColumn = profileColumns.find((column) => column.name === "client_id");
|
||||
|
||||
assert.equal(roleColumn?.notnull, 1, "app_profiles.role must be required");
|
||||
assert.equal(disabledColumn?.notnull, 1, "app_profiles.disabled must be required");
|
||||
assert.ok(clientIdColumn, "app_profiles.client_id must bind invited client accounts");
|
||||
|
||||
console.log("Phase 2 auth smoke passed");
|
||||
} finally {
|
||||
|
||||
+4
-2
@@ -7,6 +7,7 @@ import { getServerConfig } from "@/server/config";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import * as schema from "@/server/db/schema";
|
||||
import {
|
||||
authorizeSessionCreation,
|
||||
completeFirstFreelancerSetup,
|
||||
recordAuthAuditEvent,
|
||||
reserveFirstFreelancerSetup,
|
||||
@@ -46,12 +47,12 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
useSecureCookies: config.nodeEnv === "production",
|
||||
useSecureCookies: config.secureCookies,
|
||||
cookiePrefix: "neta",
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.nodeEnv === "production",
|
||||
secure: config.secureCookies,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
@@ -69,6 +70,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => authorizeSessionCreation(session.userId),
|
||||
after: async (session) => {
|
||||
await recordAuthAuditEvent({
|
||||
type: "login_succeeded",
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { SessionContext } from "@/server/auth/session";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import {
|
||||
account,
|
||||
appProfiles,
|
||||
authAuditEvents,
|
||||
portalInvitations,
|
||||
session,
|
||||
user,
|
||||
} from "@/server/db/schema";
|
||||
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
|
||||
|
||||
const DEFAULT_INVITATION_TTL_HOURS = 72;
|
||||
|
||||
const createInvitationSchema = z.object({
|
||||
clientId: z.string().trim().min(1).max(128),
|
||||
email: z.email().transform(normalizeAuthEmail),
|
||||
expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS),
|
||||
});
|
||||
|
||||
const acceptInvitationSchema = z.object({
|
||||
token: z.string().trim().min(32).max(256),
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export type PortalInvitationErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "INVALID_INPUT"
|
||||
| "INVITATION_NOT_FOUND"
|
||||
| "INVITATION_NOT_PENDING"
|
||||
| "INVITATION_EXPIRED"
|
||||
| "CLIENT_ALREADY_LINKED"
|
||||
| "EMAIL_ALREADY_REGISTERED";
|
||||
|
||||
export class PortalInvitationError extends Error {
|
||||
constructor(
|
||||
public readonly code: PortalInvitationErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PortalInvitationError";
|
||||
}
|
||||
}
|
||||
|
||||
export type PortalInvitationPreview = {
|
||||
email: string;
|
||||
expiresAt: Date;
|
||||
status: "pending" | "accepted" | "revoked" | "expired";
|
||||
};
|
||||
|
||||
export async function createPortalInvitation(
|
||||
actor: SessionContext,
|
||||
input: z.input<typeof createInvitationSchema>,
|
||||
): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> {
|
||||
assertFreelancerActor(actor);
|
||||
|
||||
const parsed = parseOrThrow(createInvitationSchema, input);
|
||||
const rawToken = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashInvitationToken(rawToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + parsed.expiresInHours * 60 * 60 * 1000);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
const invitationId = db.transaction((tx) => {
|
||||
const [linkedProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.clientId, parsed.clientId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (linkedProfile) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||
);
|
||||
}
|
||||
|
||||
const replacedInvitations = tx
|
||||
.select({ id: portalInvitations.id, email: portalInvitations.email })
|
||||
.from(portalInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.clientId, parsed.clientId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "revoked" })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.clientId, parsed.clientId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
for (const replaced of replacedInvitations) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_revoked",
|
||||
authUserId: actor.user.id,
|
||||
email: replaced.email,
|
||||
metadata: { invitationId: replaced.id, reason: "replaced", clientId: parsed.clientId },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const inserted = tx
|
||||
.insert(portalInvitations)
|
||||
.values({
|
||||
tokenHash,
|
||||
clientId: parsed.clientId,
|
||||
email: parsed.email,
|
||||
status: "pending",
|
||||
expiresAt,
|
||||
createdByUserId: actor.user.id,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning({ id: portalInvitations.id })
|
||||
.get();
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_created",
|
||||
authUserId: actor.user.id,
|
||||
email: parsed.email,
|
||||
metadata: {
|
||||
invitationId: inserted.id,
|
||||
clientId: parsed.clientId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return inserted.id;
|
||||
});
|
||||
|
||||
return {
|
||||
id: invitationId,
|
||||
invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPortalInvitationPreview(rawToken: string): PortalInvitationPreview | null {
|
||||
if (!isPlausibleToken(rawToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { db } = getSqliteConnection();
|
||||
const [invitation] = db
|
||||
.select({
|
||||
id: portalInvitations.id,
|
||||
email: portalInvitations.email,
|
||||
status: portalInvitations.status,
|
||||
expiresAt: portalInvitations.expiresAt,
|
||||
})
|
||||
.from(portalInvitations)
|
||||
.where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken)))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
|
||||
db.transaction((tx) => {
|
||||
const result = tx
|
||||
.update(portalInvitations)
|
||||
.set({ status: "expired" })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitation.id),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_expired",
|
||||
email: invitation.email,
|
||||
metadata: { invitationId: invitation.id },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { ...invitation, status: "expired" };
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}
|
||||
|
||||
export async function acceptPortalInvitation(input: {
|
||||
token: string;
|
||||
displayName?: string;
|
||||
password: string;
|
||||
}): Promise<{ authUserId: string; clientId: string; email: string }> {
|
||||
const preview = getPortalInvitationPreview(input.token);
|
||||
|
||||
if (!preview) {
|
||||
await recordInvitationFailure(null, "INVITATION_NOT_FOUND");
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
|
||||
}
|
||||
|
||||
if (preview.status === "expired") {
|
||||
await recordInvitationFailure(preview.email, "INVITATION_EXPIRED");
|
||||
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
||||
}
|
||||
|
||||
if (preview.status !== "pending") {
|
||||
await recordInvitationFailure(preview.email, "INVITATION_NOT_PENDING");
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseOrThrow(acceptInvitationSchema, {
|
||||
...input,
|
||||
displayName: input.displayName || getDefaultDisplayName(preview.email),
|
||||
});
|
||||
const passwordHash = await hashPassword(parsed.password);
|
||||
const tokenHash = hashInvitationToken(parsed.token);
|
||||
const authUserId = randomUUID();
|
||||
const accountId = randomUUID();
|
||||
const now = new Date();
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
try {
|
||||
return db.transaction((tx) => {
|
||||
const [invitation] = tx
|
||||
.select()
|
||||
.from(portalInvitations)
|
||||
.where(eq(portalInvitations.tokenHash, tokenHash))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
|
||||
}
|
||||
|
||||
if (invitation.status !== "pending") {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
|
||||
);
|
||||
}
|
||||
|
||||
if (invitation.expiresAt.getTime() <= now.getTime()) {
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "expired" })
|
||||
.where(eq(portalInvitations.id, invitation.id))
|
||||
.run();
|
||||
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
||||
}
|
||||
|
||||
const [existingUser] = tx
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, invitation.email))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (existingUser) {
|
||||
throw new PortalInvitationError(
|
||||
"EMAIL_ALREADY_REGISTERED",
|
||||
"Bu e-posta adresiyle kayıtlı bir hesap zaten var.",
|
||||
);
|
||||
}
|
||||
|
||||
const [linkedProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.clientId, invitation.clientId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (linkedProfile) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.insert(user)
|
||||
.values({
|
||||
id: authUserId,
|
||||
name: parsed.displayName,
|
||||
email: invitation.email,
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(account)
|
||||
.values({
|
||||
id: accountId,
|
||||
accountId: authUserId,
|
||||
providerId: "credential",
|
||||
userId: authUserId,
|
||||
password: passwordHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(appProfiles)
|
||||
.values({
|
||||
authUserId,
|
||||
email: invitation.email,
|
||||
displayName: parsed.displayName,
|
||||
role: "client",
|
||||
clientId: invitation.clientId,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const accepted = tx
|
||||
.update(portalInvitations)
|
||||
.set({ status: "accepted", acceptedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitation.id),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (accepted.changes !== 1) {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Davet başka bir istek tarafından kullanıldı.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_accepted",
|
||||
authUserId,
|
||||
email: invitation.email,
|
||||
metadata: { invitationId: invitation.id, clientId: invitation.clientId },
|
||||
})
|
||||
.run();
|
||||
|
||||
return { authUserId, clientId: invitation.clientId, email: invitation.email };
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof PortalInvitationError ? error.code : "transaction_failed";
|
||||
await recordInvitationFailure(preview.email, code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function revokePortalInvitation(actor: SessionContext, invitationId: number): void {
|
||||
assertFreelancerActor(actor);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [invitation] = tx
|
||||
.select({ id: portalInvitations.id, email: portalInvitations.email })
|
||||
.from(portalInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitationId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Aktif davet bulunamadı.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "revoked" })
|
||||
.where(eq(portalInvitations.id, invitation.id))
|
||||
.run();
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_revoked",
|
||||
authUserId: actor.user.id,
|
||||
email: invitation.email,
|
||||
metadata: { invitationId },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export function setClientPortalAccess(
|
||||
actor: SessionContext,
|
||||
clientId: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
assertFreelancerActor(actor);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [profile] = tx
|
||||
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
|
||||
.from(appProfiles)
|
||||
.where(and(eq(appProfiles.clientId, clientId), eq(appProfiles.role, "client")))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!profile) {
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Müşteri portal hesabı bulunamadı.");
|
||||
}
|
||||
|
||||
tx.update(appProfiles)
|
||||
.set({ disabled: !enabled, updatedAt: new Date() })
|
||||
.where(eq(appProfiles.authUserId, profile.authUserId))
|
||||
.run();
|
||||
|
||||
if (!enabled) {
|
||||
tx.delete(session).where(eq(session.userId, profile.authUserId)).run();
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: enabled ? "client_access_enabled" : "client_access_disabled",
|
||||
authUserId: actor.user.id,
|
||||
email: profile.email,
|
||||
metadata: { clientId, targetAuthUserId: profile.authUserId },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export function hashInvitationToken(rawToken: string): string {
|
||||
return createHash("sha256").update(rawToken, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function assertFreelancerActor(actor: SessionContext): void {
|
||||
if (actor.profile.disabled || actor.profile.role !== "freelancer") {
|
||||
throw new PortalInvitationError("FORBIDDEN", "Bu işlem için yetkiniz yok.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlausibleToken(value: string): boolean {
|
||||
return typeof value === "string" && value.length >= 32 && value.length <= 256;
|
||||
}
|
||||
|
||||
function parseOrThrow<TSchema extends z.ZodType>(
|
||||
schema: TSchema,
|
||||
input: unknown,
|
||||
): z.output<TSchema> {
|
||||
const result = schema.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw new PortalInvitationError("INVALID_INPUT", "Girilen bilgiler geçersiz.");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function recordInvitationFailure(email: string | null, reason: string): Promise<void> {
|
||||
const { db } = getSqliteConnection();
|
||||
db.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_accept_failed",
|
||||
email,
|
||||
metadata: { reason },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
+17
-4
@@ -20,13 +20,20 @@ export type SessionContext = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
clientId: string | null;
|
||||
disabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
|
||||
return getSessionContextFromHeaders(await headers());
|
||||
});
|
||||
|
||||
export async function getSessionContextFromHeaders(
|
||||
requestHeaders: Headers,
|
||||
): Promise<SessionContext | null> {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
headers: requestHeaders,
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
@@ -38,7 +45,12 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
|
||||
|
||||
const profile = getProfileByAuthUserId(session.user.id);
|
||||
|
||||
if (!profile || profile.disabled || profile.authUserId !== session.user.id) {
|
||||
if (
|
||||
!profile ||
|
||||
profile.disabled ||
|
||||
profile.authUserId !== session.user.id ||
|
||||
(profile.role === "client" && !profile.clientId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +59,7 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
|
||||
user: session.user,
|
||||
profile,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<SessionContext> {
|
||||
const context = await getSessionContext();
|
||||
@@ -72,7 +84,7 @@ export async function requireFreelancer(): Promise<SessionContext> {
|
||||
export async function requireClientUser(): Promise<SessionContext> {
|
||||
const context = await requireSession();
|
||||
|
||||
if (context.profile.role !== "client") {
|
||||
if (context.profile.role !== "client" || !context.profile.clientId) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -88,6 +100,7 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
|
||||
email: appProfiles.email,
|
||||
displayName: appProfiles.displayName,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
|
||||
+132
-7
@@ -122,6 +122,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
.all();
|
||||
|
||||
if (freelancerCount > 0) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "registration_rejected",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason: "setup_completed" },
|
||||
})
|
||||
.run();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -135,6 +142,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
const now = new Date();
|
||||
|
||||
if (setupState?.status === "completed") {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "registration_rejected",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason: "setup_completed" },
|
||||
})
|
||||
.run();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -178,21 +192,52 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
});
|
||||
}
|
||||
|
||||
export function failFirstFreelancerSetup(email: string, reason: string): void {
|
||||
const normalizedEmail = normalizeAuthEmail(email);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [setupState] = tx
|
||||
.select()
|
||||
.from(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (setupState?.status === "pending" && setupState.lockedBy === normalizedEmail) {
|
||||
tx.delete(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.run();
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "setup_failed",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeFirstFreelancerSetup(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
}): Promise<void> {
|
||||
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||
const now = new Date();
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
completeFirstFreelancerSetupInTransaction(tx, {
|
||||
id: user.id,
|
||||
email: normalizedEmail,
|
||||
name: user.name ?? null,
|
||||
});
|
||||
completeFirstFreelancerSetupInTransaction(
|
||||
tx,
|
||||
{
|
||||
id: user.id,
|
||||
email: normalizedEmail,
|
||||
name: user.name ?? null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,9 +250,29 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
email: string;
|
||||
name?: string | null;
|
||||
},
|
||||
repaired = true,
|
||||
): void {
|
||||
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||
const now = new Date();
|
||||
const [existingProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, user.id))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (existingProfile) {
|
||||
tx.update(appSetupState)
|
||||
.set({
|
||||
status: "completed",
|
||||
lockedBy: normalizedEmail,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
tx.insert(appProfiles)
|
||||
.values({
|
||||
@@ -215,6 +280,7 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
email: normalizedEmail,
|
||||
displayName: user.name || getDefaultDisplayName(normalizedEmail),
|
||||
role: "freelancer",
|
||||
clientId: null,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -247,7 +313,7 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
type: "setup_completed",
|
||||
authUserId: user.id,
|
||||
email: normalizedEmail,
|
||||
metadata: { role: "freelancer", repaired: true },
|
||||
metadata: { role: "freelancer", repaired },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -269,3 +335,62 @@ export async function recordAuthAuditEvent(input: {
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function authorizeSessionCreation(authUserId: string): boolean {
|
||||
const { db } = getSqliteConnection();
|
||||
let [profile] = db
|
||||
.select({
|
||||
email: appProfiles.email,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!profile) {
|
||||
const [authUser] = db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (authUser && repairFirstFreelancerSetupForEmail(authUser.email)) {
|
||||
[profile] = db
|
||||
.select({
|
||||
email: appProfiles.email,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
}
|
||||
}
|
||||
|
||||
if (profile && !profile.disabled && (profile.role !== "client" || profile.clientId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
db.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "login_failed",
|
||||
authUserId: profile ? authUserId : null,
|
||||
email: profile?.email ?? null,
|
||||
metadata: {
|
||||
reason: profile?.disabled
|
||||
? "disabled_profile"
|
||||
: profile?.role === "client" && !profile.clientId
|
||||
? "unlinked_client_profile"
|
||||
: "missing_profile",
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
+14
-2
@@ -4,10 +4,22 @@ export type UserRole = (typeof userRoles)[number];
|
||||
|
||||
export type SetupStatus = "pending" | "completed";
|
||||
|
||||
export const portalInvitationStatuses = ["pending", "accepted", "revoked", "expired"] as const;
|
||||
|
||||
export type PortalInvitationStatus = (typeof portalInvitationStatuses)[number];
|
||||
|
||||
export type AuthAuditEventType =
|
||||
| "setup_started"
|
||||
| "setup_completed"
|
||||
| "setup_failed"
|
||||
| "registration_rejected"
|
||||
| "login_succeeded"
|
||||
| "login_failed"
|
||||
| "logout_succeeded";
|
||||
|
||||
| "logout_succeeded"
|
||||
| "invitation_created"
|
||||
| "invitation_revoked"
|
||||
| "invitation_expired"
|
||||
| "invitation_accepted"
|
||||
| "invitation_accept_failed"
|
||||
| "client_access_disabled"
|
||||
| "client_access_enabled";
|
||||
|
||||
+26
-3
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -24,6 +25,7 @@ export type ServerConfig = {
|
||||
tmpDir: string;
|
||||
appUrl: string;
|
||||
trustedOrigins: string[];
|
||||
secureCookies: boolean;
|
||||
betterAuthSecret?: string;
|
||||
};
|
||||
|
||||
@@ -35,12 +37,16 @@ export function getServerConfig(): ServerConfig {
|
||||
}
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
const isProductionBuild = process.env.NEXT_PHASE === "phase-production-build";
|
||||
const defaultDataDir = isProductionBuild
|
||||
? path.join(os.tmpdir(), `neta-production-build-${process.pid}`)
|
||||
: parsed.NODE_ENV === "production"
|
||||
? "/app/data"
|
||||
: path.join(process.cwd(), ".data");
|
||||
const dataDir = path.resolve(
|
||||
parsed.DATA_DIR && parsed.DATA_DIR.length > 0
|
||||
? parsed.DATA_DIR
|
||||
: parsed.NODE_ENV === "production"
|
||||
? "/app/data"
|
||||
: path.join(process.cwd(), ".data"),
|
||||
: defaultDataDir,
|
||||
);
|
||||
|
||||
const databasePath = path.resolve(
|
||||
@@ -55,6 +61,7 @@ export function getServerConfig(): ServerConfig {
|
||||
parsed.NEXT_PUBLIC_SITE_URL ||
|
||||
"http://localhost:3000",
|
||||
);
|
||||
const secureCookies = validateAppUrlSecurity(appUrl, parsed.NODE_ENV);
|
||||
const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl);
|
||||
const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV);
|
||||
|
||||
@@ -67,6 +74,7 @@ export function getServerConfig(): ServerConfig {
|
||||
tmpDir: path.join(dataDir, "tmp"),
|
||||
appUrl,
|
||||
trustedOrigins,
|
||||
secureCookies,
|
||||
betterAuthSecret,
|
||||
};
|
||||
|
||||
@@ -98,6 +106,21 @@ function normalizeTrustedOrigins(value: string | undefined, appUrl: string): str
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function validateAppUrlSecurity(
|
||||
appUrl: string,
|
||||
nodeEnv: ServerConfig["nodeEnv"],
|
||||
): boolean {
|
||||
const url = new URL(appUrl);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
||||
|
||||
if (nodeEnv === "production" && !isHttps && !isLoopback) {
|
||||
throw new Error("Production APP_URL HTTPS kullanmalidir; HTTP yalnizca localhost icin desteklenir.");
|
||||
}
|
||||
|
||||
return isHttps;
|
||||
}
|
||||
|
||||
function normalizeAuthSecret(
|
||||
value: string | undefined,
|
||||
nodeEnv: ServerConfig["nodeEnv"],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `app_profiles` ADD `client_id` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `app_profiles_client_id_unique` ON `app_profiles` (`client_id`);
|
||||
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9dcf0ef9-1e97-48d0-87ac-a5da54e16bc5",
|
||||
"prevId": "cb8d4285-951f-4bbf-b848-953bdf769836",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"app_profiles": {
|
||||
"name": "app_profiles",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"auth_user_id": {
|
||||
"name": "auth_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"app_profiles_auth_user_id_unique": {
|
||||
"name": "app_profiles_auth_user_id_unique",
|
||||
"columns": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"app_profiles_client_id_unique": {
|
||||
"name": "app_profiles_client_id_unique",
|
||||
"columns": [
|
||||
"client_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"app_profiles_role_idx": {
|
||||
"name": "app_profiles_role_idx",
|
||||
"columns": [
|
||||
"role"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"app_profiles_auth_user_id_user_id_fk": {
|
||||
"name": "app_profiles_auth_user_id_user_id_fk",
|
||||
"tableFrom": "app_profiles",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"app_setup_state": {
|
||||
"name": "app_setup_state",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locked_by": {
|
||||
"name": "locked_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locked_at": {
|
||||
"name": "locked_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"auth_audit_events": {
|
||||
"name": "auth_audit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_user_id": {
|
||||
"name": "auth_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"auth_audit_events_type_idx": {
|
||||
"name": "auth_audit_events_type_idx",
|
||||
"columns": [
|
||||
"type"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"auth_audit_events_auth_user_id_idx": {
|
||||
"name": "auth_audit_events_auth_user_id_idx",
|
||||
"columns": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"auth_audit_events_auth_user_id_user_id_fk": {
|
||||
"name": "auth_audit_events_auth_user_id_user_id_fk",
|
||||
"tableFrom": "auth_audit_events",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"portal_invitations": {
|
||||
"name": "portal_invitations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by_user_id": {
|
||||
"name": "created_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"portal_invitations_token_hash_unique": {
|
||||
"name": "portal_invitations_token_hash_unique",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"portal_invitations_client_id_idx": {
|
||||
"name": "portal_invitations_client_id_idx",
|
||||
"columns": [
|
||||
"client_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"portal_invitations_email_idx": {
|
||||
"name": "portal_invitations_email_idx",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"portal_invitations_created_by_user_id_user_id_fk": {
|
||||
"name": "portal_invitations_created_by_user_id_user_id_fk",
|
||||
"tableFrom": "portal_invitations",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"created_by_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": [
|
||||
"token"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"verification": {
|
||||
"name": "verification",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": [
|
||||
"identifier"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"runtime_checks": {
|
||||
"name": "runtime_checks",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"runtime_events": {
|
||||
"name": "runtime_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1783709956320,
|
||||
"tag": "0001_silky_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1784205329112,
|
||||
"tag": "0002_mighty_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type { AuthAuditEventType, SetupStatus, UserRole } from "@/server/auth/types";
|
||||
import type {
|
||||
AuthAuditEventType,
|
||||
PortalInvitationStatus,
|
||||
SetupStatus,
|
||||
UserRole,
|
||||
} from "@/server/auth/types";
|
||||
|
||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
@@ -86,6 +91,7 @@ export const appProfiles = sqliteTable(
|
||||
email: text("email").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
role: text("role").$type<UserRole>().notNull(),
|
||||
clientId: text("client_id"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
@@ -95,6 +101,7 @@ export const appProfiles = sqliteTable(
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("app_profiles_auth_user_id_unique").on(table.authUserId),
|
||||
uniqueIndex("app_profiles_client_id_unique").on(table.clientId),
|
||||
index("app_profiles_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
@@ -118,7 +125,7 @@ export const portalInvitations = sqliteTable(
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
clientId: text("client_id").notNull(),
|
||||
email: text("email").notNull(),
|
||||
status: text("status", { enum: ["pending", "accepted", "revoked", "expired"] })
|
||||
status: text("status").$type<PortalInvitationStatus>()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
@@ -148,4 +155,3 @@ export const authAuditEvents = sqliteTable(
|
||||
index("auth_audit_events_auth_user_id_idx").on(table.authUserId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user