feat(admin): onboarding por invitación — /admin/usuarios + /auth/confirm
Platform admin invita usuarios de empresa desde /admin/usuarios. inviteUserByEmail + insert inmediato en user_roles (org_admin). Rollback explícito si falla el insert. /auth/confirm maneja el link de invitación con PASSWORD_RECOVERY event. Middleware no requirió cambios — /auth/confirm ya queda fuera del matcher de rutas protegidas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
106
app/actions/invite-user.ts
Normal file
106
app/actions/invite-user.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
'use server'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||
|
||||
export type InviteUserResult = { success: true } | { success: false; error: string }
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo
|
||||
// para identificar a quien llama y leer SU PROPIA fila de user_roles
|
||||
// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las
|
||||
// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin).
|
||||
async function getSessionClient() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Action no siempre puede mutar cookies — se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin.
|
||||
// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles
|
||||
// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData.
|
||||
// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un
|
||||
// valor que el formulario pueda controlar para escalar privilegios.
|
||||
export async function inviteUser(formData: FormData): Promise<InviteUserResult> {
|
||||
const sessionClient = await getSessionClient()
|
||||
|
||||
// auth.getUser() valida el token contra el servidor de Auth — no decodifica
|
||||
// la cookie localmente (mismo patrón que middleware.ts).
|
||||
const { data: { user: caller } } = await sessionClient.auth.getUser()
|
||||
if (!caller) return { success: false, error: 'no_autenticado' }
|
||||
|
||||
// Verificación de autorización — lee la fila real de user_roles del llamante.
|
||||
const { data: callerRole, error: roleErr } = await sessionClient
|
||||
.from('user_roles')
|
||||
.select('app_role')
|
||||
.eq('user_id', caller.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (roleErr || callerRole?.app_role !== 'platform_admin') {
|
||||
return { success: false, error: 'forbidden' }
|
||||
}
|
||||
|
||||
const email = String(formData.get('email') ?? '').trim().toLowerCase()
|
||||
const orgId = String(formData.get('orgId') ?? '').trim()
|
||||
|
||||
if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' }
|
||||
if (!orgId) return { success: false, error: 'organizacion_requerida' }
|
||||
|
||||
const admin = getSupabaseAdmin()
|
||||
|
||||
// La organización debe existir — admin client porque ya está autorizado el
|
||||
// llamante (paso anterior) y queremos evitar depender del claim app_role del
|
||||
// JWT (puede estar stale si el rol cambió desde la última emisión de token).
|
||||
const { data: org } = await admin
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('id', orgId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!org) return { success: false, error: 'organizacion_no_encontrada' }
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? ''
|
||||
const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail(
|
||||
email,
|
||||
{ redirectTo: `${siteUrl}/auth/confirm` }
|
||||
)
|
||||
|
||||
if (inviteErr || !inviteData?.user) {
|
||||
return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` }
|
||||
}
|
||||
|
||||
const newUserId = inviteData.user.id
|
||||
|
||||
// Insert inmediato — antes de que el usuario confirme nada. user_roles no
|
||||
// tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin.
|
||||
const { error: roleInsertErr } = await admin
|
||||
.from('user_roles')
|
||||
.insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId })
|
||||
|
||||
if (roleInsertErr) {
|
||||
// Rollback — no dejar un usuario en auth.users sin rol asignado.
|
||||
await admin.auth.admin.deleteUser(newUserId)
|
||||
return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -241,6 +241,7 @@ export default async function AdminResponsesPage({
|
||||
</div>
|
||||
<div className="admin-header-actions">
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
<a href="/admin/usuarios" className="admin-refresh">Usuarios</a>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
67
app/admin/usuarios/invite-form.tsx
Normal file
67
app/admin/usuarios/invite-form.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
import { useActionState } from 'react'
|
||||
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
|
||||
forbidden: 'No tenés permisos para invitar usuarios.',
|
||||
email_invalido: 'El email no es válido.',
|
||||
organizacion_requerida: 'Elegí una organización.',
|
||||
organizacion_no_encontrada: 'La organización seleccionada no existe.',
|
||||
}
|
||||
|
||||
function describeError(code: string): string {
|
||||
if (code.startsWith('invite_failed'))
|
||||
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
|
||||
if (code.startsWith('role_insert_failed'))
|
||||
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
|
||||
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
|
||||
}
|
||||
|
||||
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
|
||||
INITIAL_STATE
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="login-form">
|
||||
<div className="login-field">
|
||||
<label htmlFor="email" className="login-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="orgId" className="login-label">Organización</label>
|
||||
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
|
||||
<option value="">Seleccioná una organización</option>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!state.success && state.error && (
|
||||
<p role="alert" className="field-error login-error">{describeError(state.error)}</p>
|
||||
)}
|
||||
{state.success && (
|
||||
<p className="admin-subtitle" role="status">Invitación enviada correctamente.</p>
|
||||
)}
|
||||
<button type="submit" disabled={isPending} className="login-submit">
|
||||
{isPending ? 'Enviando…' : 'Enviar invitación'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
196
app/admin/usuarios/page.tsx
Normal file
196
app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||
import { formatDate } from '../responses/admin-format'
|
||||
import { SignOutButton } from '../sign-out-button'
|
||||
import { InviteForm } from './invite-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RoleRow {
|
||||
user_id: string
|
||||
app_role: string
|
||||
org_id: string | null
|
||||
organizations: { name: string } | null
|
||||
}
|
||||
|
||||
interface UserDisplayRow {
|
||||
userId: string
|
||||
email: string
|
||||
orgName: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
async function getSupabaseSession() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Component — no puede mutar cookies, se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
|
||||
// forma de hacer un .from('auth.users') ni un join SQL directo desde
|
||||
// supabase-js. listUsers() es la API admin soportada para resolver el email;
|
||||
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
|
||||
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
|
||||
const map = new Map<string, { email: string; createdAt: string }>()
|
||||
const perPage = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
|
||||
if (error || !data) break
|
||||
data.users.forEach((u) => {
|
||||
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
|
||||
})
|
||||
if (data.users.length < perPage) break
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export default async function AdminUsuariosPage() {
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
|
||||
// redirigido a /login si no hay sesión.
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">No se encontró una sesión activa.</p>
|
||||
<a href="/login" className="admin-back-link">← Iniciar sesión</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
|
||||
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
|
||||
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
|
||||
const { data: callerRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('app_role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (callerRole?.app_role !== 'platform_admin') {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">
|
||||
Esta sección es exclusiva para administradores de plataforma.
|
||||
</p>
|
||||
<a href="/admin/responses" className="admin-back-link">← Volver</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Datos — admin client para todo a partir de acá. La autorización ya
|
||||
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
|
||||
// para el resto evita depender de policies/claims del JWT que podrían
|
||||
// haber quedado stale.
|
||||
const admin = getSupabaseAdmin()
|
||||
|
||||
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
|
||||
await Promise.all([
|
||||
admin.from('organizations').select('id, name').order('name'),
|
||||
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
|
||||
])
|
||||
|
||||
if (orgsError || rolesError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||
<pre className="admin-error-detail">
|
||||
{(orgsError ?? rolesError)?.message}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const organizations = (orgsData ?? []) as OrgOption[]
|
||||
const roles = (rolesData ?? []) as unknown as RoleRow[]
|
||||
const usersMap = await fetchAllUsersMap(admin)
|
||||
|
||||
const userRows: UserDisplayRow[] = roles.map((r) => {
|
||||
const u = usersMap.get(r.user_id)
|
||||
return {
|
||||
userId: r.user_id,
|
||||
email: u?.email ?? '—',
|
||||
orgName: r.organizations?.name ?? '—',
|
||||
role: r.app_role,
|
||||
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
|
||||
</div>
|
||||
<div className="admin-header-actions">
|
||||
<a href="/admin/responses" className="admin-refresh">Respuestas</a>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Invitar usuario</h2>
|
||||
<InviteForm organizations={organizations} />
|
||||
</section>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
|
||||
<div className="admin-table-wrapper">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Organización</th>
|
||||
<th>Rol</th>
|
||||
<th>Fecha de creación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.map((row) => (
|
||||
<tr key={row.userId}>
|
||||
<td>{row.email}</td>
|
||||
<td>{row.orgName}</td>
|
||||
<td>{row.role}</td>
|
||||
<td>{row.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{userRows.length === 0 && (
|
||||
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
app/auth/confirm/page.tsx
Normal file
159
app/auth/confirm/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Stage = 'validating' | 'ready' | 'submitting' | 'error'
|
||||
|
||||
export default function ConfirmPage() {
|
||||
const [stage, setStage] = useState<Stage>('validating')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite
|
||||
// cuando el token de la URL (#access_token) es válido.
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY') setStage('ready')
|
||||
})
|
||||
|
||||
// Carrera posible: si el token ya se procesó antes de montar el listener,
|
||||
// ya hay sesión activa aunque el evento no se vuelva a emitir.
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setStage((s) => (s === 'validating' && session ? 'ready' : s))
|
||||
})
|
||||
|
||||
// Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente.
|
||||
const timeout = setTimeout(() => {
|
||||
setStage((s) => (s === 'validating' ? 'error' : s))
|
||||
}, 8000)
|
||||
|
||||
return () => {
|
||||
listener.subscription.unsubscribe()
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [supabase])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const password = String(fd.get('password') ?? '')
|
||||
const confirmPassword = String(fd.get('confirmPassword') ?? '')
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('La contraseña debe tener al menos 8 caracteres.')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Las contraseñas no coinciden.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setStage('submitting')
|
||||
|
||||
const { error: updateErr } = await supabase.auth.updateUser({ password })
|
||||
|
||||
if (updateErr) {
|
||||
setError('No pudimos guardar tu contraseña. Probá de nuevo.')
|
||||
setStage('ready')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (stage === 'validating') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Validando tu invitación...</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'error') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Link inválido o vencido</h1>
|
||||
<p role="alert" className="field-error login-error">
|
||||
Pedile al administrador que te envíe una nueva invitación.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Creá tu contraseña</h1>
|
||||
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||
<div className="login-password-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={stage === 'submitting'}
|
||||
className="text-short-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-password-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="confirmPassword" className="login-label">Confirmar contraseña</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={stage === 'submitting'}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error login-error">{error}</p>
|
||||
)}
|
||||
<button type="submit" disabled={stage === 'submitting'} className="login-submit">
|
||||
{stage === 'submitting' ? 'Guardando…' : 'Crear contraseña'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,3 +11,14 @@ export function createClient() {
|
||||
|
||||
return createSupabaseClient(url, anonKey)
|
||||
}
|
||||
|
||||
// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa
|
||||
// desde Server Components / Server Actions ('use server'), nunca desde código
|
||||
// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props
|
||||
// de un Client Component.
|
||||
export function getSupabaseAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder'
|
||||
|
||||
return createSupabaseClient(url, serviceKey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user