freemium F3: frontend dos extras (usuarios, esqueci-email, root_redirect, sino)
- SaasUsuariosPage + rota /saas/usuarios + menu: 1 linha/tenant com dono/slug/ email/plano, realce verde + selo "Novo" 24h (saas_list_account_owners) - esqueci-email no Login: dialog que chama a edge recover-access (acha dono por slug, manda magic link, mostra so dica mascarada). Edge function recover-access. - root_redirect: guard roteia "/" do visitante nao-logado pra /lp ou /auth/login conforme get_root_redirect (cache TTL 5min) - pegadinha #4: notificationStore.reset() no logout (limpa sino ao trocar user) - build OK Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Edge Function: recover-access (Freemium F3d)
|
||||
|--------------------------------------------------------------------------
|
||||
| "Esqueci meu e-mail": a pessoa informa o IDENTIFICADOR (slug) do seu
|
||||
| ambiente. O servidor acha o e-mail do dono, dispara um magic link
|
||||
| (signInWithOtp — mesmo pipeline de e-mail do GoTrue) e devolve só uma DICA
|
||||
| MASCARADA (jo****@gm****.com). O e-mail real NUNCA volta pro cliente.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// jo****@gm****.com
|
||||
function maskPart(s: string): string {
|
||||
if (!s) return '*'
|
||||
if (s.length <= 2) return s[0] + '***'
|
||||
return s.slice(0, 2) + '****'
|
||||
}
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = String(email).split('@')
|
||||
if (!domain) return maskPart(local)
|
||||
const dparts = domain.split('.')
|
||||
const dmasked = maskPart(dparts[0]) + (dparts.length > 1 ? '.' + dparts.slice(1).join('.') : '')
|
||||
return maskPart(local) + '@' + dmasked
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
|
||||
|
||||
try {
|
||||
const { slug } = await req.json().catch(() => ({}))
|
||||
const cleanSlug = String(slug || '').toLowerCase().trim()
|
||||
if (!cleanSlug || cleanSlug.length < 3) {
|
||||
return json({ ok: false, error: 'slug_required' }, 400)
|
||||
}
|
||||
|
||||
const admin = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// slug → tenant → dono (master) → user_id
|
||||
const { data: tenant } = await admin.from('tenants').select('id').eq('slug', cleanSlug).maybeSingle()
|
||||
if (!tenant) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
const { data: member } = await admin
|
||||
.from('tenant_members')
|
||||
.select('user_id')
|
||||
.eq('tenant_id', tenant.id)
|
||||
.eq('role', 'tenant_admin')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (!member?.user_id) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
const { data: userResp } = await admin.auth.admin.getUserById(member.user_id)
|
||||
const email = userResp?.user?.email
|
||||
if (!email) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
// dispara o magic link via GoTrue (cliente anon — não cria usuário novo)
|
||||
const anon = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
)
|
||||
const { error: otpErr } = await anon.auth.signInWithOtp({
|
||||
email,
|
||||
options: { shouldCreateUser: false },
|
||||
})
|
||||
if (otpErr) {
|
||||
console.error('[recover-access] signInWithOtp error:', otpErr.message)
|
||||
// ainda devolve a dica — o e-mail existe; o envio pode reprocessar
|
||||
}
|
||||
|
||||
// só a DICA mascarada volta pro cliente
|
||||
return json({ ok: true, hint: maskEmail(email) })
|
||||
} catch (err) {
|
||||
console.error('[recover-access] fatal:', err)
|
||||
return json({ ok: false, error: 'internal' }, 500)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user