Fix self-host migrations and first admin setup
This commit is contained in:
@@ -99,7 +99,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Docker Compose intentionally fails fast when required Supabase environment values are missing.
|
Docker Compose intentionally fails fast when required Supabase environment values are missing.
|
||||||
|
|
||||||
Coolify and Dokploy users should use `docker-compose.full.yml` for the no-external-service setup, or `docker-compose.yml` when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist.
|
Coolify users should use `docker-compose.full.yml` for the no-external-service setup. Dokploy users should use `docker-compose.dokploy.yml`, which avoids fixed container names and host port bindings. Use `docker-compose.yml` only when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist.
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ services:
|
|||||||
neta-storage:
|
neta-storage:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
|
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1"
|
||||||
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}
|
||||||
volumes:
|
volumes:
|
||||||
- ./supabase:/app/supabase:ro
|
- ./supabase:/app/supabase:ro
|
||||||
- ./scripts:/app/scripts:ro
|
- ./scripts:/app/scripts:ro
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ services:
|
|||||||
neta-storage:
|
neta-storage:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
|
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1"
|
||||||
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}
|
||||||
volumes:
|
volumes:
|
||||||
- ./supabase:/app/supabase:ro
|
- ./supabase:/app/supabase:ro
|
||||||
- ./scripts:/app/scripts:ro
|
- ./scripts:/app/scripts:ro
|
||||||
|
|||||||
@@ -37,4 +37,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 `0001` through `0009` in the order listed above. It uses local `psql` when available, otherwise it runs `psql` through Docker.
|
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.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Services:
|
|||||||
- `neta-rest`: PostgREST
|
- `neta-rest`: PostgREST
|
||||||
- `neta-storage`: Supabase Storage with local disk backend
|
- `neta-storage`: Supabase Storage with local disk backend
|
||||||
- `neta-supabase-proxy`: single public API entrypoint
|
- `neta-supabase-proxy`: single public API entrypoint
|
||||||
- `neta-migrations`: one-shot Neta database migration runner
|
- `neta-migrations`: Neta database migration runner
|
||||||
|
|
||||||
Generate production secrets:
|
Generate production secrets:
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Set the generated full-stack env values in Coolify's environment variables. Rout
|
|||||||
|
|
||||||
## Dokploy
|
## Dokploy
|
||||||
|
|
||||||
Create a Compose app from this repository. Use `docker-compose.full.yml` for full-stack deployments and paste the generated env values into the environment panel.
|
Create a Compose app from this repository. Use `docker-compose.dokploy.yml` for full-stack deployments and paste the generated env values into the environment panel. This compose file avoids fixed container names and host port bindings so Dokploy can route services itself.
|
||||||
|
|
||||||
Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`.
|
Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`.
|
||||||
|
|
||||||
@@ -127,3 +127,5 @@ NETA_RESTORE_FORCE=1 sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z
|
|||||||
## First Admin
|
## First Admin
|
||||||
|
|
||||||
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
|
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
|
||||||
|
|
||||||
|
Full-stack deployments apply the first-admin registration guard automatically through `neta-migrations`; the installer should not run SQL manually.
|
||||||
|
|||||||
@@ -5,6 +5,54 @@ type FirstAdminSetupState = {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isMissingSetupFunctionError(error: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
error.code === "PGRST202" ||
|
||||||
|
error.message?.includes("is_first_admin_setup_available")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSetupStateFromProfiles(): Promise<FirstAdminSetupState | null> {
|
||||||
|
const supabaseUrl =
|
||||||
|
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !serviceRoleKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = new URL("/rest/v1/profiles", supabaseUrl);
|
||||||
|
endpoint.searchParams.set("select", "id");
|
||||||
|
endpoint.searchParams.set("limit", "1");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
apikey: serviceRoleKey,
|
||||||
|
authorization: `Bearer ${serviceRoleKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("First admin setup fallback check failed", {
|
||||||
|
status: response.status,
|
||||||
|
body: await response.text(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await response.json()) as Array<{ id: string }>;
|
||||||
|
return { available: rows.length === 0 };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("First admin setup fallback request failed", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
|
export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data, error } = await supabase.rpc("is_first_admin_setup_available");
|
const { data, error } = await supabase.rpc("is_first_admin_setup_available");
|
||||||
@@ -17,15 +65,22 @@ export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
|
|||||||
hint: error.hint,
|
hint: error.hint,
|
||||||
});
|
});
|
||||||
|
|
||||||
const missingMigration =
|
const fallbackState = await getSetupStateFromProfiles();
|
||||||
error.code === "PGRST202" ||
|
|
||||||
error.message?.includes("is_first_admin_setup_available");
|
if (fallbackState) {
|
||||||
|
if (isMissingSetupFunctionError(error)) {
|
||||||
|
console.warn(
|
||||||
|
"First admin setup RPC is not available through PostgREST yet; using service-role profile fallback.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackState;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
errorMessage: missingMigration
|
errorMessage:
|
||||||
? "İlk kurulum kontrolü yapılamadı. 0009 kayıt kilidi migration dosyasını uygulamanın bağlı olduğu Supabase projesinde çalıştırdığından emin ol."
|
"İlk kurulum kontrolü yapılamadı. Veritabanı hazırlık servisi henüz tamamlanmamış olabilir; birkaç saniye sonra tekrar deneyin.",
|
||||||
: "İlk kurulum kontrolü yapılamadı. Supabase bağlantısını ve sunucu loglarını kontrol et.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-13
@@ -3,6 +3,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
PSQL_IMAGE="${NETA_PSQL_IMAGE:-postgres:16-alpine}"
|
||||||
|
|
||||||
if [ -z "${DATABASE_URL:-}" ]; then
|
if [ -z "${DATABASE_URL:-}" ]; then
|
||||||
echo "DATABASE_URL is required." >&2
|
echo "DATABASE_URL is required." >&2
|
||||||
@@ -11,6 +12,93 @@ if [ -z "${DATABASE_URL:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_sql() {
|
||||||
|
sql="$1"
|
||||||
|
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql"
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
docker run --rm -i "$PSQL_IMAGE" \
|
||||||
|
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql"
|
||||||
|
else
|
||||||
|
echo "Neither psql nor docker is available to run migrations." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
query_scalar() {
|
||||||
|
run_sql "$1" | tail -n 1 | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_migration_table() {
|
||||||
|
run_sql "
|
||||||
|
create schema if not exists neta_internal;
|
||||||
|
create table if not exists neta_internal.schema_migrations (
|
||||||
|
version text primary key,
|
||||||
|
file_path text not null,
|
||||||
|
applied_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||||
|
);
|
||||||
|
revoke all on schema neta_internal from public;
|
||||||
|
revoke all on all tables in schema neta_internal from public;
|
||||||
|
" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
is_migration_recorded() {
|
||||||
|
migration_id="$1"
|
||||||
|
result="$(query_scalar "select case when exists (select 1 from neta_internal.schema_migrations where version = '$migration_id') then 'yes' else 'no' end;")"
|
||||||
|
[ "$result" = "yes" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
record_migration() {
|
||||||
|
migration_id="$1"
|
||||||
|
file_path="$2"
|
||||||
|
|
||||||
|
run_sql "
|
||||||
|
insert into neta_internal.schema_migrations (version, file_path)
|
||||||
|
values ('$migration_id', '$file_path')
|
||||||
|
on conflict (version) do update set
|
||||||
|
file_path = excluded.file_path,
|
||||||
|
applied_at = timezone('utc'::text, now());
|
||||||
|
" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_objects_cover_migration() {
|
||||||
|
migration_id="$1"
|
||||||
|
|
||||||
|
case "$migration_id" in
|
||||||
|
0001_schema)
|
||||||
|
query_scalar "select case when to_regclass('public.profiles') is not null and to_regclass('public.tasks') is not null then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0002_freelancer_os_core)
|
||||||
|
query_scalar "select case when to_regclass('public.clients') is not null and to_regclass('public.projects') is not null and to_regclass('public.calendar_events') is not null and to_regclass('public.finance_transactions') is not null and to_regclass('public.daily_logs') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'tasks' and column_name = 'priority') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0003_project_planning_assets)
|
||||||
|
query_scalar "select case when to_regclass('public.project_planning_sections') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'cover_image_path') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0004_business_os_tables)
|
||||||
|
query_scalar "select case when to_regclass('public.proposals') is not null and to_regclass('public.contracts') is not null and to_regclass('public.invoices') is not null and to_regclass('public.subscriptions') is not null then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0005_advanced_crm)
|
||||||
|
query_scalar "select case when to_regclass('public.client_activities') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'pipeline_stage') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0006_pgvector_embeddings)
|
||||||
|
query_scalar "select case when to_regclass('public.document_embeddings') is not null and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'match_documents') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0007_client_portal)
|
||||||
|
query_scalar "select case when to_regclass('public.project_revisions') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'client_auth_id') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0008_project_progress_quota)
|
||||||
|
query_scalar "select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'progress_type') and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'update_project_progress_on_task_change') then 'yes' else 'no' end;"
|
||||||
|
;;
|
||||||
|
0009_first_admin_registration_lock)
|
||||||
|
echo "no"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "no"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
run_sql_file() {
|
run_sql_file() {
|
||||||
file_path="$1"
|
file_path="$1"
|
||||||
absolute_path="$ROOT_DIR/$file_path"
|
absolute_path="$ROOT_DIR/$file_path"
|
||||||
@@ -25,7 +113,7 @@ run_sql_file() {
|
|||||||
if command -v psql >/dev/null 2>&1; then
|
if command -v psql >/dev/null 2>&1; then
|
||||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$absolute_path"
|
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$absolute_path"
|
||||||
elif command -v docker >/dev/null 2>&1; then
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
docker run --rm -i postgres:16-alpine \
|
docker run --rm -i "$PSQL_IMAGE" \
|
||||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f - < "$absolute_path"
|
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f - < "$absolute_path"
|
||||||
else
|
else
|
||||||
echo "Neither psql nor docker is available to run migrations." >&2
|
echo "Neither psql nor docker is available to run migrations." >&2
|
||||||
@@ -33,19 +121,48 @@ run_sql_file() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
while IFS= read -r sql_file; do
|
run_migration() {
|
||||||
[ -n "$sql_file" ] || continue
|
migration_id="$1"
|
||||||
run_sql_file "$sql_file"
|
file_path="$2"
|
||||||
|
|
||||||
|
if is_migration_recorded "$migration_id"; then
|
||||||
|
echo "Skipping $file_path; migration $migration_id is already recorded."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(existing_objects_cover_migration "$migration_id")" = "yes" ]; then
|
||||||
|
echo "Detected existing database objects for $migration_id; recording without replay."
|
||||||
|
record_migration "$migration_id" "$file_path"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_sql_file "$file_path"
|
||||||
|
record_migration "$migration_id" "$file_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
reload_postgrest_schema_cache() {
|
||||||
|
echo "Reloading PostgREST schema cache."
|
||||||
|
run_sql "notify pgrst, 'reload schema';" >/dev/null
|
||||||
|
sleep "${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_migration_table
|
||||||
|
|
||||||
|
while IFS='|' read -r migration_id sql_file; do
|
||||||
|
[ -n "$migration_id" ] || continue
|
||||||
|
run_migration "$migration_id" "$sql_file"
|
||||||
done <<'SQL_FILES'
|
done <<'SQL_FILES'
|
||||||
supabase/schema.sql
|
0001_schema|supabase/schema.sql
|
||||||
supabase/migrations/0002_add_freelancer_os_core_tables.sql
|
0002_freelancer_os_core|supabase/migrations/0002_add_freelancer_os_core_tables.sql
|
||||||
supabase/migrations/0003_add_project_planning_assets.sql
|
0003_project_planning_assets|supabase/migrations/0003_add_project_planning_assets.sql
|
||||||
supabase/migrations/0004_add_business_os_tables.sql
|
0004_business_os_tables|supabase/migrations/0004_add_business_os_tables.sql
|
||||||
supabase/migrations/0005_add_advanced_crm_tables.sql
|
0005_advanced_crm|supabase/migrations/0005_add_advanced_crm_tables.sql
|
||||||
supabase/migrations/0006_add_pgvector_and_embeddings.sql
|
0006_pgvector_embeddings|supabase/migrations/0006_add_pgvector_and_embeddings.sql
|
||||||
supabase/migrations/0007_add_client_portal_tables.sql
|
0007_client_portal|supabase/migrations/0007_add_client_portal_tables.sql
|
||||||
supabase/migrations/0008_add_project_progress_and_quota.sql
|
0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql
|
||||||
supabase/migrations/0009_lock_registration_after_first_admin.sql
|
0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql
|
||||||
SQL_FILES
|
SQL_FILES
|
||||||
|
|
||||||
|
reload_postgrest_schema_cache
|
||||||
|
|
||||||
echo "All Neta migrations were applied."
|
echo "All Neta migrations were applied."
|
||||||
|
|||||||
@@ -37,3 +37,11 @@ begin
|
|||||||
return new;
|
return new;
|
||||||
end;
|
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';
|
||||||
|
|||||||
+29
-2
@@ -175,17 +175,44 @@ drop policy if exists "Users can update their own profile." on public.profiles;
|
|||||||
create policy "Users can update their own profile." on public.profiles
|
create policy "Users can update their own profile." on public.profiles
|
||||||
for update using (auth.uid() = id);
|
for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- First admin setup guard for self-hosted installations
|
||||||
|
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;
|
||||||
|
|
||||||
-- 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 as $$
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
begin
|
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)
|
insert into public.profiles (id, first_name, last_name, avatar_url)
|
||||||
values (new.id, '', '', '')
|
values (new.id, '', '', '')
|
||||||
on conflict (id) do nothing;
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
return new;
|
return new;
|
||||||
end;
|
end;
|
||||||
$$ language plpgsql security definer;
|
$$;
|
||||||
|
|
||||||
-- Trigger to automatically create profile on signup
|
-- Trigger to automatically create profile on signup
|
||||||
drop trigger if exists on_auth_user_created on auth.users;
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
|||||||
Reference in New Issue
Block a user