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"; "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 { createClient } from "@/lib/supabase/client";
import { Button } from "poyraz-ui/atoms";
import { useChat } from "@ai-sdk/react"; 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"; import toast from "react-hot-toast";
interface ChatSession { type ChatSession = {
id: string; id: string;
title: string; title: string;
created_at: string; created_at: string;
} };
export default function AIChatPage() { export default function AIChatPage() {
const [supabase] = useState(() => createClient()); const [supabase] = useState(() => createClient());
const [sessions, setSessions] = useState<ChatSession[]>([]); const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState(""); const { messages, sendMessage, setMessages, status, stop } = useChat({
const { messages, append, setMessages, isLoading, stop } = useChat({ transport: new DefaultChatTransport({ api: "/api/chat" }),
api: "/api/chat", onError: (error) => {
body: { console.error(error);
sessionId: activeSessionId 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 isLoading = status === "submitted" || status === "streaming";
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
};
// Auto-scroll to bottom
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => { useEffect(() => {
scrollToBottom(); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
// 1. Fetch Sessions on mount
useEffect(() => { useEffect(() => {
const fetchSessions = async () => { async function fetchSessions() {
const { data: { user } } = await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return; if (!user) return;
const { data } = await supabase const { data } = await supabase
.from("chat_sessions") .from("chat_sessions")
.select("*") .select("id, title, created_at")
.eq("user_id", user.id) .eq("user_id", user.id)
.order("updated_at", { ascending: false }); .order("created_at", { ascending: false });
if (data) { if (data) {
setSessions(data); setSessions(data);
if (data.length > 0) { setActiveSessionId(data[0]?.id || null);
setActiveSessionId(data[0].id);
}
} }
};
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 const { data } = await supabase
.from("chat_messages") .from("chat_messages")
.select("*") .select("id, role, content")
.eq("session_id", activeSessionId) .eq("session_id", activeSessionId)
.order("created_at", { ascending: true }); .order("created_at", { ascending: true });
if (data) { const formattedMessages: UIMessage[] = (data || []).map((message) => ({
// Map Supabase messages to AI SDK format id: message.id,
const formattedMessages = data.map((msg: any) => ({ role: message.role as UIMessage["role"],
id: msg.id, parts: [{ type: "text", text: message.content || "" }],
role: msg.role as 'user' | 'assistant' | 'system', }));
content: msg.content,
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
};
fetchMessages();
}, [activeSessionId, supabase, setMessages]);
const handleNewChat = async () => { setMessages(formattedMessages);
}
void fetchMessages();
}, [activeSessionId, setMessages, supabase]);
async function handleNewChat() {
setActiveSessionId(null); setActiveSessionId(null);
setMessages([]); setMessages([]);
}; }
const handleDeleteSession = async (id: string, e: React.MouseEvent) => { async function handleDeleteSession(id: string, event: React.MouseEvent) {
e.stopPropagation(); event.stopPropagation();
await supabase.from("chat_sessions").delete().eq("id", id); 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) => { const nextSessions = sessions.filter((session) => session.id !== id);
e.preventDefault(); setSessions(nextSessions);
if (!(input || "").trim() || isLoading) return;
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; let sessionId = activeSessionId;
const currentInput = input || "";
setInput(""); setInput("");
if (!sessionId) { if (!sessionId) {
const { data: { user } } = await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return; if (!user) return;
const { data: newSess } = await supabase const { data: newSession } = await supabase
.from("chat_sessions") .from("chat_sessions")
.insert({ user_id: user.id, title: currentInput.slice(0, 30) + "..." }) .insert({
.select() user_id: user.id,
title: currentInput.length > 32 ? `${currentInput.slice(0, 32)}...` : currentInput,
})
.select("id, title, created_at")
.single(); .single();
if (newSess) { if (!newSession) return;
sessionId = newSess.id;
setActiveSessionId(sessionId); sessionId = newSession.id;
setSessions([newSess, ...sessions]); setActiveSessionId(sessionId);
} else { setSessions((currentSessions) => [newSession, ...currentSessions]);
return;
}
} }
// Append using AI SDK with explicit body to ensure sessionId is passed immediately await sendMessage({ text: currentInput }, { body: { sessionId } });
append({ role: 'user', content: currentInput }, { body: { sessionId } }); }
};
return ( return (
<div className="flex h-[calc(100vh-6rem)] w-full overflow-hidden border border-border rounded-lg bg-background"> <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">
{/* Sidebar - Sessions List */} <div className="flex items-center justify-between border-b border-border p-4">
<div className="w-80 border-r border-border bg-muted/20 flex flex-col hidden md:flex"> <h2 className="flex items-center gap-2 font-semibold text-foreground">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
Geçmiş Sohbetler Sohbetler
</h2> </h2>
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleNewChat}> <Button variant="outline" size="icon" className="h-8 w-8" onClick={handleNewChat}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </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 ? ( {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 => ( sessions.map((session) => (
<div <button
key={session.id} key={session.id}
type="button"
onClick={() => setActiveSessionId(session.id)} 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"> <span className="truncate pr-2 text-sm font-medium">
{session.title || "İsimsiz Sohbet"} {session.title || "İsimsiz sohbet"}
</div> </span>
<button <span
onClick={(e) => handleDeleteSession(session.id, e)} role="button"
className={`opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all`} 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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </span>
</div> </button>
)) ))
)} )}
</div> </div>
</div> </aside>
{/* Main Chat Area */} <section className="flex min-w-0 flex-1 flex-col">
<div className="flex-1 flex flex-col bg-background relative"> <header className="flex h-14 items-center justify-between border-b border-border px-6">
{/* 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">
<div className="flex items-center gap-3"> <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" /> <Brain className="h-4 w-4" />
</div> </div>
<div> <div>
<h2 className="text-sm font-semibold text-foreground">Ajan Asistan</h2> <h1 className="text-sm font-semibold text-foreground">AI Asistan</h1>
<p className="text-[10px] text-muted-foreground flex items-center gap-1"> <p className="text-xs text-muted-foreground">Kayıtlı verilerin hakkında soru sor.</p>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Sisteme görev ve veri ekleyebilir
</p>
</div> </div>
</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" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </header>
{/* Messages */} <div className="tiny-scrollbar flex-1 space-y-5 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto p-6 space-y-6 scroll-smooth">
{messages.length === 0 ? ( {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="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
<div className="h-16 w-16 bg-primary/10 rounded-2xl flex items-center justify-center mb-6 text-primary"> <div className="mb-5 flex h-14 w-14 items-center justify-center rounded-sm bg-primary/10 text-primary">
<Brain className="h-8 w-8" /> <Brain className="h-7 w-7" />
</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> </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> </div>
) : ( ) : (
messages.map((msg) => ( messages.map((message) => {
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> const text = getMessageText(message);
<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>
)}
{/* Render Tool Invocations for AI Messages */} return (
{msg.toolInvocations && msg.toolInvocations.map((tool) => ( <div
<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"> key={message.id}
{tool.state === 'result' ? ( className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
<> >
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" /> <div
<span><span className="font-medium text-foreground">{tool.toolName}</span> aracı başarıyla çalıştırıldı.</span> 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"
<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> >
</> {text}
)}
</div>
))}
</div> </div>
</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 ref={messagesEndRef} />
</div> </div>
{/* Input Area */} <form onSubmit={handleSubmit} className="border-t border-border p-4">
<div className="p-4 border-t border-border bg-background"> <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">
<form onSubmit={customHandleSubmit} className="relative flex items-end gap-2 max-w-4xl mx-auto"> <textarea
<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"> value={input}
<textarea onChange={(event) => setInput(event.target.value)}
value={input} onKeyDown={(event) => {
onChange={handleInputChange} if (event.key === "Enter" && !event.shiftKey) {
onKeyDown={(e) => { event.preventDefault();
if (e.key === 'Enter' && !e.shiftKey) { void handleSubmit(event);
e.preventDefault(); }
customHandleSubmit(e); }}
} 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"
placeholder="Verilerinize dayalı bir soru sorun veya görev/finans kaydı eklemesini isteyin..." rows={1}
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]" disabled={isLoading}
rows={1} />
disabled={isLoading} {isLoading ? (
/> <Button type="button" variant="outline" size="icon" onClick={() => void stop()}>
<div className="absolute right-2 bottom-2 flex gap-1"> <span className="h-3 w-3 bg-current" />
{isLoading ? ( </Button>
<Button ) : (
type="button" <Button type="submit" size="icon" disabled={!input.trim()}>
variant="outline" <Send className="h-4 w-4" />
size="icon" </Button>
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>
</div> </div>
</div> </form>
</div> </section>
</div> </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" /> <Plus className="h-4 w-4" />
Hızlı Ekle Hızlı Ekle
</Button> </Button>
<QuickActionsSheet open={isQuickActionOpen} onOpenChange={setIsQuickActionOpen} /> <QuickActionsSheet isOpen={isQuickActionOpen} onClose={() => setIsQuickActionOpen(false)} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; 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 { updatePassword, updateProfile } from "./actions";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
+119 -139
View File
@@ -1,156 +1,57 @@
import { streamText, tool } from 'ai'; import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { z } from 'zod'; import { createClient } from "@/lib/supabase/server";
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 { try {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
if (!user) { 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 const { data: appSettings } = await supabase
.from("app_settings") .from("app_settings")
.select("*") .select("ai_provider, ai_model, api_key")
.eq("user_id", user.id) .eq("user_id", user.id)
.single(); .single();
const provider = clientProvider || appSettings?.ai_provider || "openai"; const provider = body.provider || appSettings?.ai_provider || "openai";
const apiKey = clientApiKey || appSettings?.api_key || ""; const apiKey = body.apiKey || appSettings?.api_key || "";
const modelName = appSettings?.ai_model || getDefaultModel(provider);
let model; const model = getModel(provider, apiKey, modelName);
if (provider === 'gemini') { const context = await buildUserContext(user.id);
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 result = streamText({ const result = streamText({
model, model,
system: systemPrompt, system: `Sen Cognis içindeki kişisel Freelancer OS asistanısın.
messages, Kullanıcının kayıtlı verileri hakkında kısa, net ve Türkçe cevap ver.
tools: { Veri yoksa bunu açıkça söyle. Klinik, finansal veya hukuki kesin hüküm verme.
getFinancialSummary: tool({
description: 'Son X gündeki gelir ve gider işlemlerinin listesini getirir.', Kullanıcının güncel veri özeti:
parameters: z.object({ days: z.number().default(30) }), ${context}`,
execute: async ({ days }) => { messages: await convertToModelMessages(messages),
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 };
}
})
},
onFinish: async ({ text }) => { onFinish: async ({ text }) => {
// Save assistant response to DB
if (sessionId && text) { if (sessionId && text) {
await supabase.from("chat_messages").insert({ await supabase.from("chat_messages").insert({
session_id: sessionId, 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, content: text,
}); });
} }
} },
}); });
return result.toDataStreamResponse(); return result.toUIMessageStreamResponse();
} catch (error: any) { } catch (error) {
console.error("Chat API error:", 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"; import { NextResponse } from "next/server";
type SupabaseAdminUserResponse = {
id?: string;
email?: string;
message?: string;
error_description?: string;
};
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { email, password, client_id } = await request.json(); const { email, password, client_id } = await request.json();
@@ -8,64 +14,109 @@ export async function POST(request: Request) {
if (!email || !password || !client_id) { if (!email || !password || !client_id) {
return NextResponse.json( return NextResponse.json(
{ error: "Email, şifre ve müşteri ID gereklidir." }, { error: "Email, şifre ve müşteri ID gereklidir." },
{ status: 400 } { status: 400 },
); );
} }
// Initialize Supabase Admin client with service role key const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAdmin = createClient( const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// 1. Create the user in auth.users if (!supabaseUrl || !serviceRoleKey) {
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({ return NextResponse.json(
email, { error: "Supabase service role ayarı eksik." },
password, { status: 500 },
email_confirm: true, );
}
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) { const clientResponse = await patchRestRow({
return NextResponse.json( supabaseUrl,
{ error: authError?.message || "Kullanıcı oluşturulamadı." }, serviceRoleKey,
{ status: 400 } table: "clients",
); filter: `id=eq.${encodeURIComponent(client_id)}`,
} payload: { client_auth_id: userId },
});
const userId = authData.user.id; if (!clientResponse.ok) {
// 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);
return NextResponse.json( return NextResponse.json(
{ error: "Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi." }, { error: "Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi." },
{ status: 500 } { status: 500 },
); );
} }
return NextResponse.json({ success: true, user: authData.user }); return NextResponse.json({ success: true, user: userPayload });
} catch (err: any) { } catch (error) {
console.error("Create client user error:", err); console.error("Create client user error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Sunucu tarafında beklenmeyen bir hata oluştu." }, { 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 { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { getFirstAdminSetupState } from '@/lib/auth/first-admin-setup'
export async function login(formData: FormData) { export async function login(formData: FormData) {
const supabase = await createClient() const supabase = await createClient()
@@ -23,6 +24,20 @@ export async function login(formData: FormData) {
} }
export async function signup(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 supabase = await createClient()
const data = { const data = {
@@ -33,7 +48,7 @@ export async function signup(formData: FormData) {
const { error } = await supabase.auth.signUp(data) const { error } = await supabase.auth.signUp(data)
if (error) { if (error) {
redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`) redirect(`/register?error=true&message=${encodeURIComponent(error.message)}`)
} }
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
+19 -11
View File
@@ -1,7 +1,8 @@
import { login } from "@/app/login/actions"; import { login } from "@/app/login/actions";
import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { ErrorToaster } from "@/components/error-toaster"; 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 Link from "next/link";
import { Button, Input, Label } from "poyraz-ui/atoms"; import { Button, Input, Label } from "poyraz-ui/atoms";
@@ -10,7 +11,10 @@ export default async function LoginPage({
}: { }: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
const resolvedParams = await searchParams; const [resolvedParams, setupState] = await Promise.all([
searchParams,
getFirstAdminSetupState(),
]);
const error = resolvedParams?.error; const error = resolvedParams?.error;
const message = resolvedParams?.message; const message = resolvedParams?.message;
@@ -69,15 +73,19 @@ export default async function LoginPage({
} }
secondaryAction={null} secondaryAction={null}
footer={ footer={
<div className="text-center text-sm"> setupState.available ? (
Hesabın yok mu?{" "} <div className="text-center text-sm">
<Link İlk kurulumu yapmadın mı?{" "}
href="/register" <Link
className="font-medium text-primary transition-colors hover:text-primary-hover" href="/register"
> className="font-medium text-primary transition-colors hover:text-primary-hover"
Kayıt ol >
</Link> Admin hesabını oluştur
</div> </Link>
</div>
) : (
<span></span>
)
} }
/> />
</> </>
+9 -1
View File
@@ -5,6 +5,14 @@ import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { tr } from "date-fns/locale"; 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() { export default async function PortalRevisionsPage() {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -32,7 +40,7 @@ export default async function PortalRevisionsPage() {
const projectIds = projectsData?.map(p => p.id) || []; const projectIds = projectsData?.map(p => p.id) || [];
let revisions = []; let revisions: RevisionRow[] = [];
if (projectIds.length > 0) { if (projectIds.length > 0) {
const { data: revisionsData } = await supabase const { data: revisionsData } = await supabase
.from("project_revisions") .from("project_revisions")
+11 -1
View File
@@ -5,6 +5,16 @@ import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { tr } from "date-fns/locale"; 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() { export default async function PortalTasksPage() {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -32,7 +42,7 @@ export default async function PortalTasksPage() {
const projectIds = projectsData?.map(p => p.id) || []; const projectIds = projectsData?.map(p => p.id) || [];
let tasks = []; let tasks: PortalTaskRow[] = [];
if (projectIds.length > 0) { if (projectIds.length > 0) {
const { data: tasksData } = await supabase const { data: tasksData } = await supabase
.from("tasks") .from("tasks")
+20 -4
View File
@@ -1,8 +1,10 @@
import { signup } from "@/app/login/actions"; import { signup } from "@/app/login/actions";
import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { ErrorToaster } from "@/components/error-toaster"; 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 Link from "next/link";
import { redirect } from "next/navigation";
import { Button, Input, Label } from "poyraz-ui/atoms"; import { Button, Input, Label } from "poyraz-ui/atoms";
export default async function RegisterPage({ export default async function RegisterPage({
@@ -10,6 +12,20 @@ export default async function RegisterPage({
}: { }: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 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 resolvedParams = await searchParams;
const error = resolvedParams?.error; const error = resolvedParams?.error;
const message = resolvedParams?.message; const message = resolvedParams?.message;
@@ -18,8 +34,8 @@ export default async function RegisterPage({
<> <>
{error && message && <ErrorToaster message={String(message)} />} {error && message && <ErrorToaster message={String(message)} />}
<AuthPageShell <AuthPageShell
title="Kayıt ol" title="İlk admin hesabını oluştur"
description="Freelancer operasyon panelini kullanmak için 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={
<form className="space-y-6"> <form className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
@@ -55,7 +71,7 @@ export default async function RegisterPage({
<Button formAction={signup} className="h-11 w-full gap-2"> <Button formAction={signup} className="h-11 w-full gap-2">
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
Hesap oluştur Admin hesabını oluştur
</Button> </Button>
</form> </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 | | 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 | | 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 | | 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 | | 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 ## 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" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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;
$$;