feat: implement internal auth user creation and update related functionalities

This commit is contained in:
Poyraz Avsever
2026-06-14 12:41:00 +03:00
parent 8fa89168c3
commit b1620d4748
19 changed files with 382 additions and 129 deletions
+5 -6
View File
@@ -5,6 +5,7 @@ import {
type ProjectFinanceItem, type ProjectFinanceItem,
type ProjectPlanningSectionItem, type ProjectPlanningSectionItem,
} from "@/app/(dashboard)/projects/[id]/project-detail-client"; } from "@/app/(dashboard)/projects/[id]/project-detail-client";
import { createServiceRoleClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -105,7 +106,7 @@ export default async function ProjectDetailPage({
const projectData = projectRow as unknown as ProjectRow; const projectData = projectRow as unknown as ProjectRow;
const coverImageUrl = projectData.cover_image_path const coverImageUrl = projectData.cover_image_path
? await createProjectImageUrl(supabase, projectData.cover_image_path) ? await createProjectImageUrl(projectData.cover_image_path)
: null; : null;
const project: ProjectDetail = { const project: ProjectDetail = {
@@ -165,11 +166,9 @@ export default async function ProjectDetailPage({
); );
} }
async function createProjectImageUrl( async function createProjectImageUrl(path: string) {
supabase: Awaited<ReturnType<typeof createClient>>, const admin = createServiceRoleClient();
path: string, const { data } = await admin.storage
) {
const { data } = await supabase.storage
.from("project-assets") .from("project-assets")
.createSignedUrl(path, 60 * 15); .createSignedUrl(path, 60 * 15);
+3 -5
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { createServiceRoleClient } from "@/lib/supabase/admin";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@@ -114,12 +115,10 @@ function sanitizeFileName(name: string) {
} }
async function uploadCoverImage({ async function uploadCoverImage({
supabase,
userId, userId,
projectId, projectId,
formData, formData,
}: { }: {
supabase: Awaited<ReturnType<typeof createClient>>;
userId: string; userId: string;
projectId: string; projectId: string;
formData: FormData; formData: FormData;
@@ -132,7 +131,8 @@ async function uploadCoverImage({
const fileName = `${Date.now()}-${sanitizeFileName(file.name) || "cover-image"}`; const fileName = `${Date.now()}-${sanitizeFileName(file.name) || "cover-image"}`;
const path = `${userId}/projects/${projectId}/${fileName}`; const path = `${userId}/projects/${projectId}/${fileName}`;
const { error } = await supabase.storage const admin = createServiceRoleClient();
const { error } = await admin.storage
.from(PROJECT_ASSETS_BUCKET) .from(PROJECT_ASSETS_BUCKET)
.upload(path, file, { .upload(path, file, {
cacheControl: "3600", cacheControl: "3600",
@@ -157,7 +157,6 @@ export async function createProjectRecord(formData: FormData) {
} }
const coverImagePath = await uploadCoverImage({ const coverImagePath = await uploadCoverImage({
supabase,
userId, userId,
projectId, projectId,
formData, formData,
@@ -187,7 +186,6 @@ export async function updateProjectRecord(formData: FormData) {
} }
const coverImagePath = await uploadCoverImage({ const coverImagePath = await uploadCoverImage({
supabase,
userId, userId,
projectId: id, projectId: id,
formData, formData,
+3 -3
View File
@@ -4,6 +4,7 @@ import {
type ProjectListItem, type ProjectListItem,
} from "@/app/(dashboard)/projects/projects-client"; } from "@/app/(dashboard)/projects/projects-client";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { createServiceRoleClient } from "@/lib/supabase/admin";
type ProjectRow = { type ProjectRow = {
id: string; id: string;
@@ -59,7 +60,6 @@ export default async function ProjectsPage() {
const taskStats = countTasksByProject((taskRows || []) as TaskRow[]); const taskStats = countTasksByProject((taskRows || []) as TaskRow[]);
const clients = (clientRows || []) as ProjectClientOption[]; const clients = (clientRows || []) as ProjectClientOption[];
const signedUrls = await createProjectImageUrls( const signedUrls = await createProjectImageUrls(
supabase,
((projectRows || []) as unknown as ProjectRow[]) ((projectRows || []) as unknown as ProjectRow[])
.map((project) => project.cover_image_path) .map((project) => project.cover_image_path)
.filter(Boolean) as string[], .filter(Boolean) as string[],
@@ -93,15 +93,15 @@ export default async function ProjectsPage() {
} }
async function createProjectImageUrls( async function createProjectImageUrls(
supabase: Awaited<ReturnType<typeof createClient>>,
paths: string[], paths: string[],
) { ) {
const admin = createServiceRoleClient();
const urls = new Map<string, string>(); const urls = new Map<string, string>();
const uniquePaths = Array.from(new Set(paths)); const uniquePaths = Array.from(new Set(paths));
await Promise.all( await Promise.all(
uniquePaths.map(async (path) => { uniquePaths.map(async (path) => {
const { data } = await supabase.storage const { data } = await admin.storage
.from("project-assets") .from("project-assets")
.createSignedUrl(path, 60 * 15); .createSignedUrl(path, 60 * 15);
+13 -10
View File
@@ -19,6 +19,7 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
toast,
} from "poyraz-ui/molecules"; } from "poyraz-ui/molecules";
import { import {
CalendarDays, CalendarDays,
@@ -398,6 +399,13 @@ function ProjectDialog({
try { try {
await action(formData); await action(formData);
setOpen(false); setOpen(false);
toast.success(mode === "create" ? "Proje eklendi." : "Proje güncellendi.");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Proje kaydedilirken beklenmeyen bir hata oluştu.",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -506,14 +514,6 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
</div> </div>
)} )}
<div>
<div>
< ImageIcon className="h-6 w-6" />
</div>
</div>
{previewUrl ? ( {previewUrl ? (
<div className="absolute inset-x-0 bottom-0 bg-background/90 px-3 py-2 text-xs text-muted-foreground backdrop-blur"> <div className="absolute inset-x-0 bottom-0 bg-background/90 px-3 py-2 text-xs text-muted-foreground backdrop-blur">
Görseli değiştirmek için tıkla. Görseli değiştirmek için tıkla.
@@ -788,8 +788,11 @@ function AIProjectRiskDialog({ projectId }: { projectId?: string }) {
throw new Error(data.error || "Bilinmeyen bir hata oluştu."); throw new Error(data.error || "Bilinmeyen bir hata oluştu.");
} }
setResult(data.text); setResult(data.text);
} catch (err: any) { } catch (err) {
setResult("Hata: " + err.message); setResult(
"Hata: " +
(err instanceof Error ? err.message : "Bilinmeyen bir hata oluştu."),
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+4 -2
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { createServiceRoleClient } from '@/lib/supabase/admin'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
type ProfileUpdateData = { type ProfileUpdateData = {
@@ -30,8 +31,9 @@ export async function updateProfile(formData: FormData) {
if (avatarFile && avatarFile.size > 0) { if (avatarFile && avatarFile.size > 0) {
const fileExt = avatarFile.name.split('.').pop() const fileExt = avatarFile.name.split('.').pop()
const fileName = `${user.id}/${Math.random()}.${fileExt}` const fileName = `${user.id}/${Math.random()}.${fileExt}`
const admin = createServiceRoleClient()
const { error: uploadError } = await supabase.storage const { error: uploadError } = await admin.storage
.from('avatars') .from('avatars')
.upload(fileName, avatarFile, { upsert: true }) .upload(fileName, avatarFile, { upsert: true })
@@ -43,7 +45,7 @@ export async function updateProfile(formData: FormData) {
const { const {
data: { publicUrl }, data: { publicUrl },
} = supabase.storage.from('avatars').getPublicUrl(fileName) } = admin.storage.from('avatars').getPublicUrl(fileName)
avatarUrl = publicUrl avatarUrl = publicUrl
} }
+71 -91
View File
@@ -1,123 +1,103 @@
import { createInternalAuthUser } from "@/lib/auth/internal-users";
import { createClient } from "@/lib/supabase/server";
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();
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: "E-posta, şifre ve müşteri ID gereklidir." },
{ status: 400 }, { status: 400 },
); );
} }
const supabaseUrl = const supabase = await createClient();
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; const {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; data: { user },
error: userError,
} = await supabase.auth.getUser();
if (!supabaseUrl || !serviceRoleKey) { if (userError || !user) {
return NextResponse.json( return NextResponse.json(
{ error: "Supabase service role ayarı eksik." }, { error: "Müşteri hesabı oluşturmak için giriş yapmalısınız." },
{ status: 500 }, { status: 401 },
); );
} }
const userResponse = await fetch(`${supabaseUrl}/auth/v1/admin/users`, { const { data: client, error: clientError } = await supabase
method: "POST", .from("clients")
headers: getServiceHeaders(serviceRoleKey), .select("id, client_auth_id")
body: JSON.stringify({ .eq("id", client_id)
email, .eq("user_id", user.id)
password, .single();
email_confirm: true,
app_metadata: {
internal_created: true,
role: "client",
},
}),
});
const userPayload = (await userResponse.json()) as SupabaseAdminUserResponse;
if (!userResponse.ok || !userPayload.id) { if (clientError || !client) {
return NextResponse.json(
{ error: "Müşteri kaydı bulunamadı." },
{ status: 404 },
);
}
if (client.client_auth_id) {
return NextResponse.json(
{ error: "Bu müşteri için portal hesabı zaten oluşturulmuş." },
{ status: 409 },
);
}
const {
admin,
user: createdUser,
userId,
} = await createInternalAuthUser({
email,
password,
role: "client",
reason: "client_portal",
});
const { error: profileError } = await admin
.from("profiles")
.update({ role: "client" })
.eq("id", userId);
if (profileError) {
return NextResponse.json( return NextResponse.json(
{ {
error: error: `Kullanıcı oluşturuldu fakat profil rolü güncellenemedi: ${profileError.message}`,
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" },
});
const clientResponse = await patchRestRow({
supabaseUrl,
serviceRoleKey,
table: "clients",
filter: `id=eq.${encodeURIComponent(client_id)}`,
payload: { client_auth_id: userId },
});
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: userPayload }); const { error: updateClientError } = await admin
.from("clients")
.update({ client_auth_id: userId })
.eq("id", client_id)
.eq("user_id", user.id);
if (updateClientError) {
return NextResponse.json(
{
error: `Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi: ${updateClientError.message}`,
},
{ status: 500 },
);
}
return NextResponse.json({ success: true, user: createdUser });
} catch (error) { } catch (error) {
console.error("Create client user error:", error); console.error("Create client user error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Sunucu tarafında beklenmeyen bir hata oluştu." }, {
error:
error instanceof Error
? error.message
: "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),
});
}
+17 -4
View File
@@ -4,6 +4,7 @@ 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' import { getFirstAdminSetupState } from '@/lib/auth/first-admin-setup'
import { createInternalAuthUser } from '@/lib/auth/internal-users'
export async function login(formData: FormData) { export async function login(formData: FormData) {
const supabase = await createClient() const supabase = await createClient()
@@ -38,17 +39,29 @@ export async function signup(formData: FormData) {
) )
} }
const supabase = await createClient()
const data = { const data = {
email: formData.get('email') as string, email: formData.get('email') as string,
password: formData.get('password') as string, password: formData.get('password') as string,
} }
const { error } = await supabase.auth.signUp(data) try {
await createInternalAuthUser({
email: data.email,
password: data.password,
role: 'freelancer',
reason: 'first_admin',
})
} catch (error) {
const message =
error instanceof Error ? error.message : 'Kullanıcı oluşturulamadı.'
redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
}
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword(data)
if (error) { if (error) {
redirect(`/register?error=true&message=${encodeURIComponent(error.message)}`) redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`)
} }
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
+2 -2
View File
@@ -60,7 +60,7 @@ services:
GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env} GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env}
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "true" GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local} GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local}
@@ -115,7 +115,7 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1" NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.2"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
+2 -2
View File
@@ -63,7 +63,7 @@ services:
GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "true" GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local} GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local}
@@ -121,7 +121,7 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1" NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.2"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
@@ -0,0 +1,23 @@
# 0010 - Internal Auth User Creation
SQL file:
`supabase/migrations/0010_allow_internal_auth_user_creation.sql`
## Purpose
Allows Neta's trusted server-side flows to create Auth users after public registration is locked.
This is required for client portal accounts. Public signup remains blocked after the first admin account, but the application can create internal users through the service role key.
## Changes
- Adds `neta_internal.internal_auth_creations`.
- Adds `public.request_internal_auth_creation(target_email, target_reason)`.
- Grants that RPC only to `service_role`.
- Updates `public.handle_new_user()` so it accepts a short-lived internal creation request.
- Reloads the PostgREST schema cache.
## Operational Notes
The application calls the RPC immediately before calling the Supabase Auth Admin API. The pending request expires after two minutes and is consumed by the Auth user trigger.
+2 -1
View File
@@ -13,6 +13,7 @@ This file is the canonical order of SQL files for database setup and migration.
| 0007 | `supabase/migrations/0007_add_client_portal_tables.sql` | `docs/database/0007-client-portal-tables.md` | Pending execution | | 0007 | `supabase/migrations/0007_add_client_portal_tables.sql` | `docs/database/0007-client-portal-tables.md` | Pending execution |
| 0008 | `supabase/migrations/0008_add_project_progress_and_quota.sql` | `docs/database/0008-project-progress-and-quota.md` | Pending execution | | 0008 | `supabase/migrations/0008_add_project_progress_and_quota.sql` | `docs/database/0008-project-progress-and-quota.md` | Pending execution |
| 0009 | `supabase/migrations/0009_lock_registration_after_first_admin.sql` | `docs/database/0009-lock-registration-after-first-admin.md` | Pending execution | | 0009 | `supabase/migrations/0009_lock_registration_after_first_admin.sql` | `docs/database/0009-lock-registration-after-first-admin.md` | Pending execution |
| 0010 | `supabase/migrations/0010_allow_internal_auth_user_creation.sql` | `docs/database/0010-internal-auth-user-creation.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
@@ -37,4 +38,4 @@ Use the migration helper from the repository root:
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
``` ```
The helper applies missing queries from `0001` through `0009` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart. The helper applies missing queries from `0001` through `0010` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart.
+65
View File
@@ -0,0 +1,65 @@
import { createServiceRoleClient } from "@/lib/supabase/admin";
type InternalAuthUserRole = "freelancer" | "client";
type CreateInternalAuthUserInput = {
email: string;
password: string;
role: InternalAuthUserRole;
reason: string;
};
export async function createInternalAuthUser({
email,
password,
role,
reason,
}: CreateInternalAuthUserInput) {
const admin = createServiceRoleClient();
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
throw new Error("E-posta ve şifre zorunludur.");
}
const { error: guardError } = await admin.rpc(
"request_internal_auth_creation",
{
target_email: normalizedEmail,
target_reason: reason,
},
);
if (guardError) {
throw new Error(
`Kullanıcı oluşturma hazırlığı tamamlanamadı: ${guardError.message}`,
);
}
const { data, error } = await admin.auth.admin.createUser({
email: normalizedEmail,
password,
email_confirm: true,
app_metadata: {
internal_created: true,
role,
},
user_metadata: {
role,
},
});
if (error) {
throw new Error(error.message);
}
if (!data.user?.id) {
throw new Error("Kullanıcı oluşturuldu fakat kullanıcı kimliği alınamadı.");
}
return {
admin,
user: data.user,
userId: data.user.id,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { createClient } from "@supabase/supabase-js";
export function createServiceRoleClient() {
const supabaseUrl =
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
throw new Error("Supabase service role yapılandırması eksik.");
}
return createClient(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
+1
View File
@@ -34,6 +34,7 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@supabase/ssr": "^0.10.3", "@supabase/ssr": "^0.10.3",
"@supabase/supabase-js": "^2.105.3",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"ai": "^6.0.197", "ai": "^6.0.197",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+3
View File
@@ -71,6 +71,9 @@ importers:
'@supabase/ssr': '@supabase/ssr':
specifier: ^0.10.3 specifier: ^0.10.3
version: 0.10.3(@supabase/supabase-js@2.105.3) version: 0.10.3(@supabase/supabase-js@2.105.3)
'@supabase/supabase-js':
specifier: ^2.105.3
version: 2.105.3
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.3.0 version: 4.3.0
+4
View File
@@ -93,6 +93,9 @@ existing_objects_cover_migration() {
0009_first_admin_registration_lock) 0009_first_admin_registration_lock)
echo "no" echo "no"
;; ;;
0010_internal_auth_creation)
echo "no"
;;
*) *)
echo "no" echo "no"
;; ;;
@@ -161,6 +164,7 @@ done <<'SQL_FILES'
0007_client_portal|supabase/migrations/0007_add_client_portal_tables.sql 0007_client_portal|supabase/migrations/0007_add_client_portal_tables.sql
0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql 0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql
0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql 0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql
0010_internal_auth_creation|supabase/migrations/0010_allow_internal_auth_user_creation.sql
SQL_FILES SQL_FILES
reload_postgrest_schema_cache reload_postgrest_schema_cache
+2 -1
View File
@@ -36,7 +36,8 @@ Do not overwrite already executed SQL without also creating a new ordered migrat
7. `migrations/0007_add_client_portal_tables.sql` 7. `migrations/0007_add_client_portal_tables.sql`
8. `migrations/0008_add_project_progress_and_quota.sql` 8. `migrations/0008_add_project_progress_and_quota.sql`
9. `migrations/0009_lock_registration_after_first_admin.sql` 9. `migrations/0009_lock_registration_after_first_admin.sql`
10. Optional local/demo data: `seeds/0001_demo_freelancer_os_data.sql` 10. `migrations/0010_allow_internal_auth_user_creation.sql`
11. Optional local/demo data: `seeds/0001_demo_freelancer_os_data.sql`
## Apply Migrations ## Apply Migrations
@@ -0,0 +1,86 @@
-- 0010: Allow trusted server-side internal Auth user creation
-- Run after: supabase/migrations/0009_lock_registration_after_first_admin.sql
create schema if not exists neta_internal;
create table if not exists neta_internal.internal_auth_creations (
id uuid default uuid_generate_v4() primary key,
email text not null,
reason text default 'internal'::text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
expires_at timestamp with time zone default (timezone('utc'::text, now()) + interval '2 minutes') not null
);
create index if not exists internal_auth_creations_email_idx
on neta_internal.internal_auth_creations (lower(email));
revoke all on schema neta_internal from public;
revoke all on all tables in schema neta_internal from public;
create or replace function public.request_internal_auth_creation(
target_email text,
target_reason text default 'internal'
)
returns void
language plpgsql
security definer
set search_path = public, neta_internal
as $$
begin
if coalesce(current_setting('request.jwt.claim.role', true), '') <> 'service_role' then
raise exception 'Only service role can request internal auth creation.';
end if;
if target_email is null or btrim(target_email) = '' then
raise exception 'target_email is required.';
end if;
delete from neta_internal.internal_auth_creations
where expires_at <= timezone('utc'::text, now())
or lower(email) = lower(btrim(target_email));
insert into neta_internal.internal_auth_creations (email, reason)
values (btrim(target_email), coalesce(nullif(btrim(target_reason), ''), 'internal'));
end;
$$;
revoke all on function public.request_internal_auth_creation(text, text) from public;
grant execute on function public.request_internal_auth_creation(text, text) to service_role;
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public, neta_internal
as $$
declare
allowed_internal_creation boolean := false;
begin
delete from neta_internal.internal_auth_creations
where lower(email) = lower(new.email)
and expires_at > timezone('utc'::text, now())
returning true into allowed_internal_creation;
allowed_internal_creation := coalesce(allowed_internal_creation, false);
if exists (select 1 from public.profiles limit 1)
and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true'
and not allowed_internal_creation 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;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
notify pgrst, 'reload schema';
+58 -2
View File
@@ -193,16 +193,72 @@ 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 anon;
grant execute on function public.is_first_admin_setup_available() to authenticated; grant execute on function public.is_first_admin_setup_available() to authenticated;
create schema if not exists neta_internal;
create table if not exists neta_internal.internal_auth_creations (
id uuid default uuid_generate_v4() primary key,
email text not null,
reason text default 'internal'::text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
expires_at timestamp with time zone default (timezone('utc'::text, now()) + interval '2 minutes') not null
);
create index if not exists internal_auth_creations_email_idx
on neta_internal.internal_auth_creations (lower(email));
revoke all on schema neta_internal from public;
revoke all on all tables in schema neta_internal from public;
create or replace function public.request_internal_auth_creation(
target_email text,
target_reason text default 'internal'
)
returns void
language plpgsql
security definer
set search_path = public, neta_internal
as $$
begin
if coalesce(current_setting('request.jwt.claim.role', true), '') <> 'service_role' then
raise exception 'Only service role can request internal auth creation.';
end if;
if target_email is null or btrim(target_email) = '' then
raise exception 'target_email is required.';
end if;
delete from neta_internal.internal_auth_creations
where expires_at <= timezone('utc'::text, now())
or lower(email) = lower(btrim(target_email));
insert into neta_internal.internal_auth_creations (email, reason)
values (btrim(target_email), coalesce(nullif(btrim(target_reason), ''), 'internal'));
end;
$$;
revoke all on function public.request_internal_auth_creation(text, text) from public;
grant execute on function public.request_internal_auth_creation(text, text) to service_role;
-- Function to handle new user signup -- Function to handle new user signup
create or replace function public.handle_new_user() create or replace function public.handle_new_user()
returns trigger returns trigger
language plpgsql language plpgsql
security definer security definer
set search_path = public set search_path = public, neta_internal
as $$ as $$
declare
allowed_internal_creation boolean := false;
begin begin
delete from neta_internal.internal_auth_creations
where lower(email) = lower(new.email)
and expires_at > timezone('utc'::text, now())
returning true into allowed_internal_creation;
allowed_internal_creation := coalesce(allowed_internal_creation, false);
if exists (select 1 from public.profiles limit 1) if exists (select 1 from public.profiles limit 1)
and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true' then and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true'
and not allowed_internal_creation then
raise exception 'Registration is closed. The first admin account already exists.'; raise exception 'Registration is closed. The first admin account already exists.';
end if; end if;