feat: Implement first admin setup guard to lock registration after initial account creation

- Updated the registration flow to check if the first admin account has been created, preventing further public registrations.
- Introduced `is_first_admin_setup_available` function to determine registration availability.
- Modified the `/register` and `/login` pages to redirect based on the setup state.
- Enhanced the user creation process to handle internal admin accounts correctly.
- Added migration script to enforce the new registration rules in the database.
- Refactored chat API to improve message handling and context building.
- Updated dashboard and settings components for better state management.
- Improved error handling and user feedback across various components.
This commit is contained in:
Poyraz Avsever
2026-06-08 16:22:26 +03:00
parent cf8492db9e
commit 950522d467
15 changed files with 570 additions and 445 deletions
+182 -240
View File
@@ -1,339 +1,281 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { Brain, Send, MessageSquare, Plus, Trash2, Loader2, Wrench } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { Button } from "poyraz-ui/atoms";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
import { Button } from "poyraz-ui/atoms";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
interface ChatSession {
type ChatSession = {
id: string;
title: string;
created_at: string;
}
};
export default function AIChatPage() {
const [supabase] = useState(() => createClient());
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const { messages, append, setMessages, isLoading, stop } = useChat({
api: "/api/chat",
body: {
sessionId: activeSessionId
const { messages, sendMessage, setMessages, status, stop } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
onError: (error) => {
console.error(error);
toast.error(error.message || "Yapay zeka ile iletişim kurulurken bir hata oluştu.");
},
onError: (err) => {
console.error(err);
toast.error(err.message || "Yapay zeka ile iletişim kurulurken bir hata oluştu.");
}
});
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
};
// Auto-scroll to bottom
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const isLoading = status === "submitted" || status === "streaming";
useEffect(() => {
scrollToBottom();
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 1. Fetch Sessions on mount
useEffect(() => {
const fetchSessions = async () => {
const { data: { user } } = await supabase.auth.getUser();
async function fetchSessions() {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase
.from("chat_sessions")
.select("*")
.select("id, title, created_at")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
.order("created_at", { ascending: false });
if (data) {
setSessions(data);
if (data.length > 0) {
setActiveSessionId(data[0].id);
}
setActiveSessionId(data[0]?.id || null);
}
};
fetchSessions();
}, [supabase]);
// 2. Fetch Messages when session changes
useEffect(() => {
if (!activeSessionId) {
setMessages([]);
return;
}
const fetchMessages = async () => {
void fetchSessions();
}, [supabase]);
useEffect(() => {
async function fetchMessages() {
if (!activeSessionId) {
setMessages([]);
return;
}
const { data } = await supabase
.from("chat_messages")
.select("*")
.select("id, role, content")
.eq("session_id", activeSessionId)
.order("created_at", { ascending: true });
if (data) {
// Map Supabase messages to AI SDK format
const formattedMessages = data.map((msg: any) => ({
id: msg.id,
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
};
fetchMessages();
}, [activeSessionId, supabase, setMessages]);
const formattedMessages: UIMessage[] = (data || []).map((message) => ({
id: message.id,
role: message.role as UIMessage["role"],
parts: [{ type: "text", text: message.content || "" }],
}));
const handleNewChat = async () => {
setMessages(formattedMessages);
}
void fetchMessages();
}, [activeSessionId, setMessages, supabase]);
async function handleNewChat() {
setActiveSessionId(null);
setMessages([]);
};
}
const handleDeleteSession = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
async function handleDeleteSession(id: string, event: React.MouseEvent) {
event.stopPropagation();
await supabase.from("chat_sessions").delete().eq("id", id);
const updatedSessions = sessions.filter(s => s.id !== id);
setSessions(updatedSessions);
if (activeSessionId === id) {
setActiveSessionId(updatedSessions.length > 0 ? updatedSessions[0].id : null);
if (updatedSessions.length === 0) setMessages([]);
}
};
const customHandleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!(input || "").trim() || isLoading) return;
const nextSessions = sessions.filter((session) => session.id !== id);
setSessions(nextSessions);
if (activeSessionId === id) {
setActiveSessionId(nextSessions[0]?.id || null);
if (nextSessions.length === 0) setMessages([]);
}
}
async function handleSubmit(event: { preventDefault: () => void }) {
event.preventDefault();
const currentInput = input.trim();
if (!currentInput || isLoading) return;
let sessionId = activeSessionId;
const currentInput = input || "";
setInput("");
if (!sessionId) {
const { data: { user } } = await supabase.auth.getUser();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data: newSess } = await supabase
const { data: newSession } = await supabase
.from("chat_sessions")
.insert({ user_id: user.id, title: currentInput.slice(0, 30) + "..." })
.select()
.insert({
user_id: user.id,
title: currentInput.length > 32 ? `${currentInput.slice(0, 32)}...` : currentInput,
})
.select("id, title, created_at")
.single();
if (newSess) {
sessionId = newSess.id;
setActiveSessionId(sessionId);
setSessions([newSess, ...sessions]);
} else {
return;
}
if (!newSession) return;
sessionId = newSession.id;
setActiveSessionId(sessionId);
setSessions((currentSessions) => [newSession, ...currentSessions]);
}
// Append using AI SDK with explicit body to ensure sessionId is passed immediately
append({ role: 'user', content: currentInput }, { body: { sessionId } });
};
await sendMessage({ text: currentInput }, { body: { sessionId } });
}
return (
<div className="flex h-[calc(100vh-6rem)] w-full overflow-hidden border border-border rounded-lg bg-background">
{/* Sidebar - Sessions List */}
<div className="w-80 border-r border-border bg-muted/20 flex flex-col hidden md:flex">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<div className="flex h-[calc(100dvh-6rem)] w-full overflow-hidden rounded-sm border border-border bg-background">
<aside className="hidden w-80 flex-col border-r border-border bg-muted/20 md:flex">
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="flex items-center gap-2 font-semibold text-foreground">
<MessageSquare className="h-4 w-4" />
Geçmiş Sohbetler
Sohbetler
</h2>
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleNewChat}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
<div className="tiny-scrollbar flex-1 space-y-2 overflow-y-auto p-3">
{sessions.length === 0 ? (
<div className="text-center text-sm text-muted-foreground mt-10">Henüz sohbet yok.</div>
<div className="mt-10 text-center text-sm text-muted-foreground">
Henüz sohbet yok.
</div>
) : (
sessions.map(session => (
<div
sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => setActiveSessionId(session.id)}
className={`group flex items-center justify-between p-3 rounded-md cursor-pointer transition-colors ${activeSessionId === session.id ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50 text-foreground'}`}
className={`group flex w-full items-center justify-between rounded-sm p-3 text-left transition-colors ${
activeSessionId === session.id
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted/50"
}`}
>
<div className="truncate text-sm font-medium w-full pr-2">
{session.title || "İsimsiz Sohbet"}
</div>
<button
onClick={(e) => handleDeleteSession(session.id, e)}
className={`opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all`}
<span className="truncate pr-2 text-sm font-medium">
{session.title || "İsimsiz sohbet"}
</span>
<span
role="button"
tabIndex={0}
onClick={(event) => void handleDeleteSession(session.id, event)}
className="rounded-sm p-1 opacity-0 transition-all hover:bg-rose-50 hover:text-rose-600 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</span>
</button>
))
)}
</div>
</div>
</aside>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col bg-background relative">
{/* Chat Header */}
<div className="h-14 border-b border-border flex items-center justify-between px-6 bg-background/50 backdrop-blur-sm z-10">
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b border-border px-6">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<div className="flex h-8 w-8 items-center justify-center rounded-sm bg-primary/10 text-primary">
<Brain className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">Ajan Asistan</h2>
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Sisteme görev ve veri ekleyebilir
</p>
<h1 className="text-sm font-semibold text-foreground">AI Asistan</h1>
<p className="text-xs text-muted-foreground">Kayıtlı verilerin hakkında soru sor.</p>
</div>
</div>
<Button variant="ghost" size="icon" className="md:hidden" onClick={handleNewChat}>
<Button variant="outline" size="icon" className="h-8 w-8 md:hidden" onClick={handleNewChat}>
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 scroll-smooth">
<div className="tiny-scrollbar flex-1 space-y-5 overflow-y-auto p-6">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-70 max-w-md mx-auto">
<div className="h-16 w-16 bg-primary/10 rounded-2xl flex items-center justify-center mb-6 text-primary">
<Brain className="h-8 w-8" />
</div>
<h3 className="text-xl font-bold text-foreground mb-2">Ajan Asistan'a Hoşgeldiniz</h3>
<p className="text-sm text-muted-foreground mb-4">Sadece sorularınızı cevaplamakla kalmaz, komutlarınızla projeler, görevler ve finans kayıtları da oluşturabilir.</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full mt-4">
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Bana 'Sunum hazırlığı' adında yeni bir görev ekler misin?")}>
Görev Ekle
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Bugün 350 TL Yemek harcaması yaptım, kaydeder misin?")}>
Harcama Ekle
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Aktif projelerimin durumunu özetler misin?")}>
Projelerimi Sorgula
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Son 30 günlük finansal özetimi ver.")}>
Gelir/Gider Özeti
</Button>
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-sm bg-primary/10 text-primary">
<Brain className="h-7 w-7" />
</div>
<h2 className="text-xl font-semibold text-foreground">Verilerine danış</h2>
<p className="mt-2 text-sm text-muted-foreground">
Görevler, projeler, müşteriler, finans ve günlük kayıtların hakkında soru sorabilirsin.
</p>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`flex gap-3 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`shrink-0 h-8 w-8 rounded-full flex items-center justify-center ${msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted border border-border text-foreground'}`}>
{msg.role === 'user' ? <span className="text-xs font-bold">ME</span> : <Brain className="h-4 w-4" />}
</div>
<div className="flex flex-col gap-2">
{msg.content && (
<div className={`px-4 py-3 rounded-2xl text-sm ${msg.role === 'user' ? 'bg-primary text-primary-foreground rounded-tr-none' : 'bg-muted/50 border border-border text-foreground rounded-tl-none whitespace-pre-wrap'}`}>
{msg.content}
</div>
)}
messages.map((message) => {
const text = getMessageText(message);
{/* Render Tool Invocations for AI Messages */}
{msg.toolInvocations && msg.toolInvocations.map((tool) => (
<div key={tool.toolCallId} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 border border-border px-3 py-1.5 rounded-md self-start">
{tool.state === 'result' ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
<span><span className="font-medium text-foreground">{tool.toolName}</span> aracı başarıyla çalıştırıldı.</span>
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span><span className="font-medium text-foreground">{tool.toolName}</span> aracı çalıştırılıyor...</span>
</>
)}
</div>
))}
return (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-sm px-4 py-3 text-sm ${
message.role === "user"
? "bg-primary text-primary-foreground"
: "border border-border bg-muted/40 text-foreground"
}`}
>
{text}
</div>
</div>
</div>
))
)}
{isLoading && messages[messages.length - 1]?.role === 'user' && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[85%] flex-row">
<div className="shrink-0 h-8 w-8 rounded-full bg-muted border border-border text-foreground flex items-center justify-center">
<Brain className="h-4 w-4" />
</div>
<div className="px-4 py-3 rounded-2xl text-sm bg-muted/50 border border-border text-foreground rounded-tl-none flex items-center gap-2">
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
</div>
</div>
</div>
);
})
)}
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
Yanıt hazırlanıyor...
</div>
) : null}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 border-t border-border bg-background">
<form onSubmit={customHandleSubmit} className="relative flex items-end gap-2 max-w-4xl mx-auto">
<div className="relative flex-1 bg-muted/30 border border-border rounded-xl flex items-center focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all">
<textarea
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
customHandleSubmit(e);
}
}}
placeholder="Verilerinize dayalı bir soru sorun veya görev/finans kaydı eklemesini isteyin..."
className="w-full bg-transparent border-none focus:outline-none focus:ring-0 resize-none py-4 pl-4 pr-12 text-sm text-foreground placeholder:text-muted-foreground min-h-[56px] max-h-[200px]"
rows={1}
disabled={isLoading}
/>
<div className="absolute right-2 bottom-2 flex gap-1">
{isLoading ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-10 w-10 rounded-lg bg-background text-foreground"
onClick={() => stop()}
>
<div className="w-3 h-3 bg-current" />
</Button>
) : (
<Button
type="submit"
size="icon"
className={`h-10 w-10 rounded-lg ${(input || "").trim() ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-muted-foreground'}`}
disabled={!(input || "").trim()}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
</form>
<div className="text-center mt-3 flex items-center justify-center gap-4 text-[10px] text-muted-foreground">
<span>Yapay Zeka Hatalar Yapabilir.</span>
<div className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
<span>Veri okuma/yazma yetkisi aktiftir.</span>
</div>
<form onSubmit={handleSubmit} className="border-t border-border p-4">
<div className="mx-auto flex max-w-4xl items-end gap-2 rounded-sm border border-border bg-background p-2 focus-within:border-primary">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSubmit(event);
}
}}
placeholder="Örn. Bu hafta geciken işlerim neler?"
className="min-h-10 max-h-40 flex-1 resize-none bg-transparent px-2 py-2 text-sm outline-none placeholder:text-muted-foreground"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<Button type="button" variant="outline" size="icon" onClick={() => void stop()}>
<span className="h-3 w-3 bg-current" />
</Button>
) : (
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</form>
</section>
</div>
);
}
function getMessageText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}
+1 -1
View File
@@ -123,7 +123,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<Plus className="h-4 w-4" />
Hızlı Ekle
</Button>
<QuickActionsSheet open={isQuickActionOpen} onOpenChange={setIsQuickActionOpen} />
<QuickActionsSheet isOpen={isQuickActionOpen} onClose={() => setIsQuickActionOpen(false)} />
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { User, Shield, Brain, Save, Key, AlertTriangle } from "lucide-react";
import { AlertTriangle, Blocks, Brain, Key, Save, Shield, User } from "lucide-react";
import { updatePassword, updateProfile } from "./actions";
import { createClient } from "@/lib/supabase/client";
import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
+119 -139
View File
@@ -1,156 +1,57 @@
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { z } from 'zod';
import { createClient } from '@/lib/supabase/server';
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { createClient } from "@/lib/supabase/server";
export const maxDuration = 30; // Allow longer execution for tool calls
export const maxDuration = 30;
export async function POST(req: Request) {
export async function POST(request: Request) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return new Response('Yetkisiz erişim', { status: 401 });
return new Response("Yetkisiz erişim", { status: 401 });
}
const { messages, sessionId, provider: clientProvider, apiKey: clientApiKey } = await req.json();
const body = await request.json();
const messages = (body.messages || []) as UIMessage[];
const sessionId = body.sessionId as string | undefined;
const latestMessage = messages[messages.length - 1];
const latestText = latestMessage ? getMessageText(latestMessage) : "";
if (sessionId && latestMessage?.role === "user" && latestText) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
role: "user",
content: latestText,
});
}
// Read App Settings for Provider/API Key
const { data: appSettings } = await supabase
.from("app_settings")
.select("*")
.select("ai_provider, ai_model, api_key")
.eq("user_id", user.id)
.single();
const provider = clientProvider || appSettings?.ai_provider || "openai";
const apiKey = clientApiKey || appSettings?.api_key || "";
let model;
if (provider === 'gemini') {
const google = createGoogleGenerativeAI({ apiKey });
model = google('gemini-1.5-pro-latest');
} else if (provider === 'groq') {
const groq = createOpenAI({ apiKey, baseURL: 'https://api.groq.com/openai/v1' });
model = groq('llama-3.1-8b-instant');
} else {
const openai = createOpenAI({ apiKey });
model = openai('gpt-4o');
}
// Identify the latest user message to save to Supabase and use for RAG
const latestMessage = messages[messages.length - 1];
let ragContext = "";
if (latestMessage && latestMessage.role === 'user' && latestMessage.content) {
if (sessionId) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
role: "user",
content: latestMessage.content,
});
}
// Perform RAG search
try {
const { searchSimilarDocuments } = await import('@/lib/ai/embeddings');
const similarDocs = await searchSimilarDocuments(user.id, latestMessage.content, provider, apiKey, 3);
if (similarDocs && similarDocs.length > 0) {
ragContext = "Aşağıda kullanıcının veri tabanından sistemin otomatik bulduğu geçmiş notlar ve veriler (RAG Context) bulunmaktadır. Gerektiğinde soruları yanıtlarken bunlardan faydalan:\n\n" + similarDocs.map((doc: any) => `- ${doc.content}`).join("\n");
}
} catch (err) {
console.error("RAG araması başarısız:", err);
}
}
const systemPrompt = `Sen kullanıcının kişisel Freelancer İş Asistanı ve Danışmanısın. Cognis Freelancer OS içinde yaşıyorsun.
Kullanıcının iş süreçlerini, projelerini ve finansal durumunu organize etmesine yardımcı oluyorsun.
Gerektiğinde araçları (tools) kullanarak sistemden güncel verileri çek ve doğrudan veri ekle.
${ragContext}
Aşağıdaki yeteneklere sahipsin:
- Finansal verileri listeleyebilir ve yeni finans kaydı (gelir/gider) girebilirsin.
- Görevleri okuyabilir ve yeni görevler ekleyebilirsin.
- Projeleri sorgulayabilir ve projelerin detaylarını/tasarım sistemini çekebilirsin.
Kullanıcıya her zaman proaktif, kısa ve profesyonel yanıtlar ver. Türkçe dilinde cevapla.`;
const provider = body.provider || appSettings?.ai_provider || "openai";
const apiKey = body.apiKey || appSettings?.api_key || "";
const modelName = appSettings?.ai_model || getDefaultModel(provider);
const model = getModel(provider, apiKey, modelName);
const context = await buildUserContext(user.id);
const result = streamText({
model,
system: systemPrompt,
messages,
tools: {
getFinancialSummary: tool({
description: 'Son X gündeki gelir ve gider işlemlerinin listesini getirir.',
parameters: z.object({ days: z.number().default(30) }),
execute: async ({ days }) => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - days);
const { data } = await supabase.from('finance_transactions')
.select('type, amount, category, transaction_date')
.gte('transaction_date', pastDate.toISOString());
return data || [];
}
}),
listTasks: tool({
description: 'Kullanıcının mevcut görevlerini belirli bir duruma göre listeler.',
parameters: z.object({ status: z.enum(['todo', 'in_progress', 'completed', 'all']).default('all') }),
execute: async ({ status }) => {
let query = supabase.from('tasks').select('id, title, status, due_at');
if (status !== 'all') query = query.eq('status', status);
const { data } = await query.order('created_at', { ascending: false }).limit(20);
return data || [];
}
}),
createTask: tool({
description: 'Sisteme yeni bir görev ekler.',
parameters: z.object({
title: z.string().describe('Görev başlığı'),
description: z.string().optional().describe('Görevin detayı')
}),
execute: async ({ title, description }) => {
const { data, error } = await supabase.from('tasks')
.insert({ user_id: user.id, title, description, status: 'todo' })
.select().single();
if (error) return { success: false, error: error.message };
return { success: true, task: data };
}
}),
searchProjects: tool({
description: 'Projeleri isimlerine veya durumlarına göre listeler',
parameters: z.object({ status: z.enum(['active', 'planning', 'completed', 'paused', 'all']).default('active') }),
execute: async ({ status }) => {
let query = supabase.from('projects').select('id, name, status, progress, budget');
if (status !== 'all') query = query.eq('status', status);
const { data } = await query.limit(10);
return data || [];
}
}),
addFinanceTransaction: tool({
description: 'Sisteme yeni bir finansal kayıt (gelir veya gider) ekler.',
parameters: z.object({
type: z.enum(['income', 'expense']).describe('income (gelir) veya expense (gider)'),
amount: z.number().describe('Tutar'),
category: z.string().describe('Kategori örn. Yazılım, Yemek, Vergi vb.')
}),
execute: async ({ type, amount, category }) => {
const { data, error } = await supabase.from('finance_transactions')
.insert({
user_id: user.id,
type,
amount,
category,
transaction_date: new Date().toISOString()
})
.select().single();
if (error) return { success: false, error: error.message };
return { success: true, transaction: data };
}
})
},
system: `Sen Cognis içindeki kişisel Freelancer OS asistanısın.
Kullanıcının kayıtlı verileri hakkında kısa, net ve Türkçe cevap ver.
Veri yoksa bunu açıkça söyle. Klinik, finansal veya hukuki kesin hüküm verme.
Kullanıcının güncel veri özeti:
${context}`,
messages: await convertToModelMessages(messages),
onFinish: async ({ text }) => {
// Save assistant response to DB
if (sessionId && text) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
@@ -158,12 +59,91 @@ Kullanıcıya her zaman proaktif, kısa ve profesyonel yanıtlar ver. Türkçe d
content: text,
});
}
}
},
});
return result.toDataStreamResponse();
} catch (error: any) {
return result.toUIMessageStreamResponse();
} catch (error) {
console.error("Chat API error:", error);
return new Response(error.message || "Internal Server Error", { status: 500 });
return new Response(error instanceof Error ? error.message : "Internal Server Error", {
status: 500,
});
}
}
function getDefaultModel(provider: string) {
if (provider === "gemini") return "gemini-1.5-pro-latest";
if (provider === "groq") return "llama-3.1-8b-instant";
return "gpt-4o";
}
function getModel(provider: string, apiKey: string, modelName: string) {
if (provider === "gemini") {
return createGoogleGenerativeAI({ apiKey })(modelName);
}
if (provider === "groq") {
return createOpenAI({ apiKey, baseURL: "https://api.groq.com/openai/v1" })(modelName);
}
return createOpenAI({ apiKey })(modelName);
}
async function buildUserContext(userId: string) {
const supabase = await createClient();
const since = new Date();
since.setDate(since.getDate() - 30);
const sinceDate = since.toISOString().slice(0, 10);
const [{ data: tasks }, { data: projects }, { data: finance }, { data: logs }] =
await Promise.all([
supabase
.from("tasks")
.select("title, status, priority, due_at")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(20),
supabase
.from("projects")
.select("name, status, progress, due_date")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(12),
supabase
.from("finance_transactions")
.select("type, amount, currency, category, payment_status, transaction_date")
.eq("user_id", userId)
.gte("transaction_date", sinceDate)
.order("transaction_date", { ascending: false })
.limit(20),
supabase
.from("daily_logs")
.select("log_date, mood_score, energy_score, work_satisfaction_score, note")
.eq("user_id", userId)
.gte("log_date", sinceDate)
.order("log_date", { ascending: false })
.limit(14),
]);
return [
formatContextList("Görevler", tasks),
formatContextList("Projeler", projects),
formatContextList("Son 30 gün finans", finance),
formatContextList("Son günlük kayıtlar", logs),
].join("\n\n");
}
function formatContextList(title: string, rows: unknown[] | null) {
if (!rows || rows.length === 0) return `${title}: kayıt yok.`;
return `${title}:\n${rows
.map((row) => `- ${JSON.stringify(row)}`)
.join("\n")}`;
}
function getMessageText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}
+96 -45
View File
@@ -1,6 +1,12 @@
import { createClient } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
type SupabaseAdminUserResponse = {
id?: string;
email?: string;
message?: string;
error_description?: string;
};
export async function POST(request: Request) {
try {
const { email, password, client_id } = await request.json();
@@ -8,64 +14,109 @@ export async function POST(request: Request) {
if (!email || !password || !client_id) {
return NextResponse.json(
{ error: "Email, şifre ve müşteri ID gereklidir." },
{ status: 400 }
{ status: 400 },
);
}
// Initialize Supabase Admin client with service role key
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
// 1. Create the user in auth.users
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
email_confirm: true,
if (!supabaseUrl || !serviceRoleKey) {
return NextResponse.json(
{ error: "Supabase service role ayarı eksik." },
{ status: 500 },
);
}
const userResponse = await fetch(`${supabaseUrl}/auth/v1/admin/users`, {
method: "POST",
headers: getServiceHeaders(serviceRoleKey),
body: JSON.stringify({
email,
password,
email_confirm: true,
app_metadata: {
internal_created: true,
role: "client",
},
}),
});
const userPayload = (await userResponse.json()) as SupabaseAdminUserResponse;
if (!userResponse.ok || !userPayload.id) {
return NextResponse.json(
{
error:
userPayload.message ||
userPayload.error_description ||
"Kullanıcı oluşturulamadı.",
},
{ status: 400 },
);
}
const userId = userPayload.id;
await patchRestRow({
supabaseUrl,
serviceRoleKey,
table: "profiles",
filter: `id=eq.${encodeURIComponent(userId)}`,
payload: { role: "client" },
});
if (authError || !authData.user) {
return NextResponse.json(
{ error: authError?.message || "Kullanıcı oluşturulamadı." },
{ status: 400 }
);
}
const clientResponse = await patchRestRow({
supabaseUrl,
serviceRoleKey,
table: "clients",
filter: `id=eq.${encodeURIComponent(client_id)}`,
payload: { client_auth_id: userId },
});
const userId = authData.user.id;
// 2. Wait for trigger to create the profile (it might take a fraction of a second, but usually synchronous in Postgres)
// We update the profile to set the role to 'client'
const { error: profileError } = await supabaseAdmin
.from("profiles")
.update({ role: "client" })
.eq("id", userId);
if (profileError) {
console.error("Profile update error:", profileError);
// Optional: Handle partial failure
}
// 3. Link the user to the client record
const { error: clientError } = await supabaseAdmin
.from("clients")
.update({ client_auth_id: userId })
.eq("id", client_id);
if (clientError) {
console.error("Client link error:", clientError);
if (!clientResponse.ok) {
return NextResponse.json(
{ error: "Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi." },
{ status: 500 }
{ status: 500 },
);
}
return NextResponse.json({ success: true, user: authData.user });
} catch (err: any) {
console.error("Create client user error:", err);
return NextResponse.json({ success: true, user: userPayload });
} catch (error) {
console.error("Create client user error:", error);
return NextResponse.json(
{ error: "Sunucu tarafında beklenmeyen bir hata oluştu." },
{ status: 500 }
{ status: 500 },
);
}
}
function getServiceHeaders(serviceRoleKey: string) {
return {
apikey: serviceRoleKey,
authorization: `Bearer ${serviceRoleKey}`,
"content-type": "application/json",
};
}
function patchRestRow({
supabaseUrl,
serviceRoleKey,
table,
filter,
payload,
}: {
supabaseUrl: string;
serviceRoleKey: string;
table: string;
filter: string;
payload: Record<string, unknown>;
}) {
return fetch(`${supabaseUrl}/rest/v1/${table}?${filter}`, {
method: "PATCH",
headers: {
...getServiceHeaders(serviceRoleKey),
prefer: "return=minimal",
},
body: JSON.stringify(payload),
});
}
+16 -1
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getFirstAdminSetupState } from '@/lib/auth/first-admin-setup'
export async function login(formData: FormData) {
const supabase = await createClient()
@@ -23,6 +24,20 @@ export async function login(formData: FormData) {
}
export async function signup(formData: FormData) {
const setupState = await getFirstAdminSetupState()
if (setupState.errorMessage) {
redirect(`/register?error=true&message=${encodeURIComponent(setupState.errorMessage)}`)
}
if (!setupState.available) {
redirect(
`/login?error=true&message=${encodeURIComponent(
'Kayıt kapalı. Bu self-host kurulumunda ilk admin hesabı zaten oluşturulmuş.',
)}`,
)
}
const supabase = await createClient()
const data = {
@@ -33,7 +48,7 @@ export async function signup(formData: FormData) {
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`)
redirect(`/register?error=true&message=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
+19 -11
View File
@@ -1,7 +1,8 @@
import { login } from "@/app/login/actions";
import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { ErrorToaster } from "@/components/error-toaster";
import { LockKeyhole, LogIn, Mail, Search } from "lucide-react";
import { getFirstAdminSetupState } from "@/lib/auth/first-admin-setup";
import { LockKeyhole, LogIn, Mail } from "lucide-react";
import Link from "next/link";
import { Button, Input, Label } from "poyraz-ui/atoms";
@@ -10,7 +11,10 @@ export default async function LoginPage({
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const resolvedParams = await searchParams;
const [resolvedParams, setupState] = await Promise.all([
searchParams,
getFirstAdminSetupState(),
]);
const error = resolvedParams?.error;
const message = resolvedParams?.message;
@@ -69,15 +73,19 @@ export default async function LoginPage({
}
secondaryAction={null}
footer={
<div className="text-center text-sm">
Hesabın yok mu?{" "}
<Link
href="/register"
className="font-medium text-primary transition-colors hover:text-primary-hover"
>
Kayıt ol
</Link>
</div>
setupState.available ? (
<div className="text-center text-sm">
İlk kurulumu yapmadın mı?{" "}
<Link
href="/register"
className="font-medium text-primary transition-colors hover:text-primary-hover"
>
Admin hesabını oluştur
</Link>
</div>
) : (
<span></span>
)
}
/>
</>
+9 -1
View File
@@ -5,6 +5,14 @@ import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
type RevisionRow = {
id: string;
description: string;
status: string;
project_id: string;
created_at: string;
};
export default async function PortalRevisionsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -32,7 +40,7 @@ export default async function PortalRevisionsPage() {
const projectIds = projectsData?.map(p => p.id) || [];
let revisions = [];
let revisions: RevisionRow[] = [];
if (projectIds.length > 0) {
const { data: revisionsData } = await supabase
.from("project_revisions")
+11 -1
View File
@@ -5,6 +5,16 @@ import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
type PortalTaskRow = {
id: string;
title: string;
status: string;
project_id: string;
created_at: string;
date: string | null;
priority: string | null;
};
export default async function PortalTasksPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -32,7 +42,7 @@ export default async function PortalTasksPage() {
const projectIds = projectsData?.map(p => p.id) || [];
let tasks = [];
let tasks: PortalTaskRow[] = [];
if (projectIds.length > 0) {
const { data: tasksData } = await supabase
.from("tasks")
+20 -4
View File
@@ -1,8 +1,10 @@
import { signup } from "@/app/login/actions";
import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { ErrorToaster } from "@/components/error-toaster";
import { LockKeyhole, Mail, Search, UserPlus } from "lucide-react";
import { getFirstAdminSetupState } from "@/lib/auth/first-admin-setup";
import { LockKeyhole, Mail, UserPlus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button, Input, Label } from "poyraz-ui/atoms";
export default async function RegisterPage({
@@ -10,6 +12,20 @@ export default async function RegisterPage({
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const setupState = await getFirstAdminSetupState();
if (setupState.errorMessage) {
redirect(`/login?error=true&message=${encodeURIComponent(setupState.errorMessage)}`);
}
if (!setupState.available) {
redirect(
`/login?error=true&message=${encodeURIComponent(
"Kayıt kapalı. Bu self-host kurulumunda ilk admin hesabı zaten oluşturulmuş.",
)}`,
);
}
const resolvedParams = await searchParams;
const error = resolvedParams?.error;
const message = resolvedParams?.message;
@@ -18,8 +34,8 @@ export default async function RegisterPage({
<>
{error && message && <ErrorToaster message={String(message)} />}
<AuthPageShell
title="Kayıt ol"
description="Freelancer operasyon panelini kullanmak için hesabını oluştur."
title="İlk admin hesabını oluştur"
description="Bu self-host kurulumu için Cognis çalışma alanının ilk yönetici hesabını oluştur."
form={
<form className="space-y-6">
<div className="space-y-4">
@@ -55,7 +71,7 @@ export default async function RegisterPage({
<Button formAction={signup} className="h-11 w-full gap-2">
<UserPlus className="h-4 w-4" />
Hesap oluştur
Admin hesabını oluştur
</Button>
</form>
}
@@ -0,0 +1,34 @@
# 0009 - Lock Registration After First Admin
SQL file:
`supabase/migrations/0009_lock_registration_after_first_admin.sql`
## Purpose
Adds the first-time setup guard for self-hosted installations.
The `/register` page is only available while the system has no profile record. After the first account creates a profile, public registration is closed.
## Changes
- Adds `public.is_first_admin_setup_available()`.
- Grants the function to `anon` and `authenticated` roles so the app can check setup state safely without bypassing RLS manually.
- Replaces `public.handle_new_user()` so direct public Supabase Auth signup attempts are also rejected after the first profile exists.
- Allows service-role/admin-created users when `raw_app_meta_data.internal_created` is `true`, so future invite/client-portal flows can still create accounts intentionally.
## Behavior
1. Fresh install has no `public.profiles` rows.
2. `/register` stays open.
3. The first signup creates an auth user and the trigger creates the first profile.
4. The setup function starts returning `false`.
5. `/register` redirects to `/login`.
6. Further public signup attempts fail at the database trigger level.
7. Admin-created internal users can still be allowed by service-role flows that set `app_metadata.internal_created = true`.
## Notes
- This is intended for the MVP single-admin self-host model.
- If multi-user, client portal accounts, invites, or team members are re-enabled later, keep them behind service-role/admin-created flows instead of public signup.
- Do not add this SQL file to `query-log.md` until it has actually been run in the target Supabase environment.
+1
View File
@@ -7,6 +7,7 @@ This file is the canonical order of SQL files for database setup and migration.
| 0001 | `supabase/schema.sql` | `docs/database/0001-initial-schema.md` | Baseline registered |
| 0002 | `supabase/migrations/0002_add_freelancer_os_core_tables.sql` | `docs/database/0002-freelancer-os-core-tables.md` | Pending execution |
| 0003 | `supabase/migrations/0003_add_project_planning_assets.sql` | `docs/database/0003-project-planning-assets.md` | Pending execution |
| 0009 | `supabase/migrations/0009_lock_registration_after_first_admin.sql` | `docs/database/0009-lock-registration-after-first-admin.md` | Pending execution |
| seed-0001 | `supabase/seeds/0001_demo_freelancer_os_data.sql` | `docs/database/seed-0001-demo-freelancer-os-data.md` | Optional demo seed, pending execution |
## How To Add The Next Query
+21
View File
@@ -0,0 +1,21 @@
import { createClient } from "@/lib/supabase/server";
type FirstAdminSetupState = {
available: boolean;
errorMessage?: string;
};
export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
const supabase = await createClient();
const { data, error } = await supabase.rpc("is_first_admin_setup_available");
if (error) {
return {
available: false,
errorMessage:
"İlk kurulum kontrolü yapılamadı. 0009 kayıt kilidi migration dosyasını çalıştırdığından emin ol.",
};
}
return { available: Boolean(data) };
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,39 @@
-- 0009: Lock public registration after the first self-host admin account
-- Run after: supabase/migrations/0008_add_project_progress_and_quota.sql
create or replace function public.is_first_admin_setup_available()
returns boolean
language sql
security definer
set search_path = public
as $$
select not exists (
select 1
from public.profiles
limit 1
);
$$;
revoke all on function public.is_first_admin_setup_available() from public;
grant execute on function public.is_first_admin_setup_available() to anon;
grant execute on function public.is_first_admin_setup_available() to authenticated;
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if exists (select 1 from public.profiles limit 1)
and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true' then
raise exception 'Registration is closed. The first admin account already exists.';
end if;
insert into public.profiles (id, first_name, last_name, avatar_url)
values (new.id, '', '', '')
on conflict (id) do nothing;
return new;
end;
$$;