diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index b86db75..da178ba 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -5,6 +5,7 @@ import { type ProjectFinanceItem, type ProjectPlanningSectionItem, } from "@/app/(dashboard)/projects/[id]/project-detail-client"; +import { createServiceRoleClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; import { notFound } from "next/navigation"; @@ -105,7 +106,7 @@ export default async function ProjectDetailPage({ const projectData = projectRow as unknown as ProjectRow; const coverImageUrl = projectData.cover_image_path - ? await createProjectImageUrl(supabase, projectData.cover_image_path) + ? await createProjectImageUrl(projectData.cover_image_path) : null; const project: ProjectDetail = { @@ -165,11 +166,9 @@ export default async function ProjectDetailPage({ ); } -async function createProjectImageUrl( - supabase: Awaited>, - path: string, -) { - const { data } = await supabase.storage +async function createProjectImageUrl(path: string) { + const admin = createServiceRoleClient(); + const { data } = await admin.storage .from("project-assets") .createSignedUrl(path, 60 * 15); diff --git a/app/(dashboard)/projects/actions.ts b/app/(dashboard)/projects/actions.ts index 5645287..2072b00 100644 --- a/app/(dashboard)/projects/actions.ts +++ b/app/(dashboard)/projects/actions.ts @@ -1,6 +1,7 @@ "use server"; import { createClient } from "@/lib/supabase/server"; +import { createServiceRoleClient } from "@/lib/supabase/admin"; import { randomUUID } from "crypto"; import { revalidatePath } from "next/cache"; @@ -114,12 +115,10 @@ function sanitizeFileName(name: string) { } async function uploadCoverImage({ - supabase, userId, projectId, formData, }: { - supabase: Awaited>; userId: string; projectId: string; formData: FormData; @@ -132,7 +131,8 @@ async function uploadCoverImage({ const fileName = `${Date.now()}-${sanitizeFileName(file.name) || "cover-image"}`; const path = `${userId}/projects/${projectId}/${fileName}`; - const { error } = await supabase.storage + const admin = createServiceRoleClient(); + const { error } = await admin.storage .from(PROJECT_ASSETS_BUCKET) .upload(path, file, { cacheControl: "3600", @@ -157,7 +157,6 @@ export async function createProjectRecord(formData: FormData) { } const coverImagePath = await uploadCoverImage({ - supabase, userId, projectId, formData, @@ -187,7 +186,6 @@ export async function updateProjectRecord(formData: FormData) { } const coverImagePath = await uploadCoverImage({ - supabase, userId, projectId: id, formData, diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index bbe9a28..0408e8b 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -4,6 +4,7 @@ import { type ProjectListItem, } from "@/app/(dashboard)/projects/projects-client"; import { createClient } from "@/lib/supabase/server"; +import { createServiceRoleClient } from "@/lib/supabase/admin"; type ProjectRow = { id: string; @@ -59,7 +60,6 @@ export default async function ProjectsPage() { const taskStats = countTasksByProject((taskRows || []) as TaskRow[]); const clients = (clientRows || []) as ProjectClientOption[]; const signedUrls = await createProjectImageUrls( - supabase, ((projectRows || []) as unknown as ProjectRow[]) .map((project) => project.cover_image_path) .filter(Boolean) as string[], @@ -93,15 +93,15 @@ export default async function ProjectsPage() { } async function createProjectImageUrls( - supabase: Awaited>, paths: string[], ) { + const admin = createServiceRoleClient(); const urls = new Map(); const uniquePaths = Array.from(new Set(paths)); await Promise.all( uniquePaths.map(async (path) => { - const { data } = await supabase.storage + const { data } = await admin.storage .from("project-assets") .createSignedUrl(path, 60 * 15); diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx index fc15709..8897906 100644 --- a/app/(dashboard)/projects/projects-client.tsx +++ b/app/(dashboard)/projects/projects-client.tsx @@ -19,6 +19,7 @@ import { SelectItem, SelectTrigger, SelectValue, + toast, } from "poyraz-ui/molecules"; import { CalendarDays, @@ -398,6 +399,13 @@ function ProjectDialog({ try { await action(formData); 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 { setIsSubmitting(false); } @@ -506,14 +514,6 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) { )} -
- -
- < ImageIcon className="h-6 w-6" /> - -
-
- {previewUrl ? (
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."); } setResult(data.text); - } catch (err: any) { - setResult("Hata: " + err.message); + } catch (err) { + setResult( + "Hata: " + + (err instanceof Error ? err.message : "Bilinmeyen bir hata oluştu."), + ); } finally { setLoading(false); } diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 99c688c..c241422 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from 'next/cache' +import { createServiceRoleClient } from '@/lib/supabase/admin' import { createClient } from '@/lib/supabase/server' type ProfileUpdateData = { @@ -30,8 +31,9 @@ export async function updateProfile(formData: FormData) { if (avatarFile && avatarFile.size > 0) { const fileExt = avatarFile.name.split('.').pop() const fileName = `${user.id}/${Math.random()}.${fileExt}` + const admin = createServiceRoleClient() - const { error: uploadError } = await supabase.storage + const { error: uploadError } = await admin.storage .from('avatars') .upload(fileName, avatarFile, { upsert: true }) @@ -43,7 +45,7 @@ export async function updateProfile(formData: FormData) { const { data: { publicUrl }, - } = supabase.storage.from('avatars').getPublicUrl(fileName) + } = admin.storage.from('avatars').getPublicUrl(fileName) avatarUrl = publicUrl } diff --git a/app/api/create-client-user/route.ts b/app/api/create-client-user/route.ts index a7edd8e..bc8204d 100644 --- a/app/api/create-client-user/route.ts +++ b/app/api/create-client-user/route.ts @@ -1,123 +1,103 @@ +import { createInternalAuthUser } from "@/lib/auth/internal-users"; +import { createClient } from "@/lib/supabase/server"; 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(); if (!email || !password || !client_id) { return NextResponse.json( - { error: "Email, şifre ve müşteri ID gereklidir." }, + { error: "E-posta, şifre ve müşteri ID gereklidir." }, { status: 400 }, ); } - const supabaseUrl = - process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + const supabase = await createClient(); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); - if (!supabaseUrl || !serviceRoleKey) { + if (userError || !user) { return NextResponse.json( - { error: "Supabase service role ayarı eksik." }, - { status: 500 }, + { error: "Müşteri hesabı oluşturmak için giriş yapmalısınız." }, + { status: 401 }, ); } - 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; + const { data: client, error: clientError } = await supabase + .from("clients") + .select("id, client_auth_id") + .eq("id", client_id) + .eq("user_id", user.id) + .single(); - if (!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( { - error: - userPayload.message || - userPayload.error_description || - "Kullanıcı oluşturulamadı.", + error: `Kullanıcı oluşturuldu fakat profil rolü güncellenemedi: ${profileError.message}`, }, - { 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 }, ); } - 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) { console.error("Create client user error:", error); 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 }, ); } } - -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; -}) { - return fetch(`${supabaseUrl}/rest/v1/${table}?${filter}`, { - method: "PATCH", - headers: { - ...getServiceHeaders(serviceRoleKey), - prefer: "return=minimal", - }, - body: JSON.stringify(payload), - }); -} diff --git a/app/login/actions.ts b/app/login/actions.ts index dbff741..29f44e5 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -4,6 +4,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' +import { createInternalAuthUser } from '@/lib/auth/internal-users' export async function login(formData: FormData) { const supabase = await createClient() @@ -38,17 +39,29 @@ export async function signup(formData: FormData) { ) } - const supabase = await createClient() - const data = { email: formData.get('email') 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) { - redirect(`/register?error=true&message=${encodeURIComponent(error.message)}`) + redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`) } revalidatePath('/', 'layout') diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 7ed25f9..2db3cec 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -60,7 +60,7 @@ services: GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env} GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_DISABLE_SIGNUP: "true" GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_MAILER_AUTOCONFIRM: "true" GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local} @@ -115,7 +115,7 @@ services: neta-storage: condition: service_started 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 NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 2b3e340..142a28c 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -63,7 +63,7 @@ services: GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_DISABLE_SIGNUP: "true" GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_MAILER_AUTOCONFIRM: "true" GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local} @@ -121,7 +121,7 @@ services: neta-storage: condition: service_started 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 NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: diff --git a/docs/database/0010-internal-auth-user-creation.md b/docs/database/0010-internal-auth-user-creation.md new file mode 100644 index 0000000..35de429 --- /dev/null +++ b/docs/database/0010-internal-auth-user-creation.md @@ -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. diff --git a/docs/database/query-order.md b/docs/database/query-order.md index 62ee3ed..d8698c7 100644 --- a/docs/database/query-order.md +++ b/docs/database/query-order.md @@ -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 | | 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 | +| 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 | ## 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 ``` -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. diff --git a/lib/auth/internal-users.ts b/lib/auth/internal-users.ts new file mode 100644 index 0000000..cb5e429 --- /dev/null +++ b/lib/auth/internal-users.ts @@ -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, + }; +} diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts new file mode 100644 index 0000000..6a43d3c --- /dev/null +++ b/lib/supabase/admin.ts @@ -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, + }, + }); +} diff --git a/package.json b/package.json index ff58feb..6430075 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-toast": "^1.2.15", "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.105.3", "@tailwindcss/postcss": "^4.3.0", "ai": "^6.0.197", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2af8911..c603716 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@supabase/ssr': specifier: ^0.10.3 version: 0.10.3(@supabase/supabase-js@2.105.3) + '@supabase/supabase-js': + specifier: ^2.105.3 + version: 2.105.3 '@tailwindcss/postcss': specifier: ^4.3.0 version: 4.3.0 diff --git a/scripts/apply-migrations.sh b/scripts/apply-migrations.sh index bf51f0d..bbc2f60 100644 --- a/scripts/apply-migrations.sh +++ b/scripts/apply-migrations.sh @@ -93,6 +93,9 @@ existing_objects_cover_migration() { 0009_first_admin_registration_lock) echo "no" ;; + 0010_internal_auth_creation) + echo "no" + ;; *) echo "no" ;; @@ -161,6 +164,7 @@ done <<'SQL_FILES' 0007_client_portal|supabase/migrations/0007_add_client_portal_tables.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 +0010_internal_auth_creation|supabase/migrations/0010_allow_internal_auth_user_creation.sql SQL_FILES reload_postgrest_schema_cache diff --git a/supabase/README.md b/supabase/README.md index 4ce5387..405dfe2 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -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` 8. `migrations/0008_add_project_progress_and_quota.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 diff --git a/supabase/migrations/0010_allow_internal_auth_user_creation.sql b/supabase/migrations/0010_allow_internal_auth_user_creation.sql new file mode 100644 index 0000000..2663c3e --- /dev/null +++ b/supabase/migrations/0010_allow_internal_auth_user_creation.sql @@ -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'; diff --git a/supabase/schema.sql b/supabase/schema.sql index 0deeb09..51ab6d6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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 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 create or replace function public.handle_new_user() returns trigger language plpgsql security definer -set search_path = public +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' 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.'; end if;