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 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<ReturnType<typeof createClient>>,
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);
+3 -5
View File
@@ -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<ReturnType<typeof createClient>>;
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,
+3 -3
View File
@@ -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<ReturnType<typeof createClient>>,
paths: string[],
) {
const admin = createServiceRoleClient();
const urls = new Map<string, string>();
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);
+13 -10
View File
@@ -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 }) {
</div>
)}
<div>
<div>
< ImageIcon className="h-6 w-6" />
</div>
</div>
{previewUrl ? (
<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.
@@ -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);
}
+4 -2
View File
@@ -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
}
+67 -87
View File
@@ -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({
const { data: client, error: clientError } = await supabase
.from("clients")
.select("id, client_auth_id")
.eq("id", client_id)
.eq("user_id", user.id)
.single();
if (clientError || !client) {
return NextResponse.json(
{ error: "Müşteri kaydı bulunamadı." },
{ status: 404 },
);
}
if (client.client_auth_id) {
return NextResponse.json(
{ error: "Bu müşteri için portal hesabı zaten oluşturulmuş." },
{ status: 409 },
);
}
const {
admin,
user: createdUser,
userId,
} = await createInternalAuthUser({
email,
password,
email_confirm: true,
app_metadata: {
internal_created: true,
role: "client",
},
}),
reason: "client_portal",
});
const userPayload = (await userResponse.json()) as SupabaseAdminUserResponse;
if (!userResponse.ok || !userPayload.id) {
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<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 { 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')
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
@@ -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 |
| 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.
+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-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",
+3
View File
@@ -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
+4
View File
@@ -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
+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`
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
@@ -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 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;