From 96b7d7fee40b545fa802a4c29296751b2a2d712a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:39:28 -0300 Subject: [PATCH] add-magic-link-endpoint --- susconecta/app/auth/callback/page.tsx | 73 ++++++++++++++++++++++++++ susconecta/app/login-admin/page.tsx | 28 +++++++++- susconecta/app/login-paciente/page.tsx | 33 +++++++++++- susconecta/app/login/page.tsx | 28 +++++++++- susconecta/lib/auth.ts | 45 ++++++++++++++++ susconecta/lib/env-config.ts | 2 + 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 susconecta/app/auth/callback/page.tsx diff --git a/susconecta/app/auth/callback/page.tsx b/susconecta/app/auth/callback/page.tsx new file mode 100644 index 0000000..7f4e611 --- /dev/null +++ b/susconecta/app/auth/callback/page.tsx @@ -0,0 +1,73 @@ +"use client" +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { getCurrentUser, AuthenticationError } from '@/lib/auth' +import { AUTH_STORAGE_KEYS, USER_TYPE_ROUTES } from '@/types/auth' + +function parseHashOrQuery() { + // Try fragment first (#access_token=...&refresh_token=...) + const hash = typeof window !== 'undefined' ? window.location.hash.replace(/^#/, '') : '' + const query = typeof window !== 'undefined' ? window.location.search.replace(/^\?/, '') : '' + + const params = new URLSearchParams(hash || query) + const access_token = params.get('access_token') + const refresh_token = params.get('refresh_token') + const expires_in = params.get('expires_in') + return { access_token, refresh_token, expires_in } +} + +export default function AuthCallbackPage() { + const router = useRouter() + const [message, setMessage] = useState('Processando autenticação...') + + useEffect(() => { + async function run() { + try { + const { access_token, refresh_token } = parseHashOrQuery() + if (!access_token) { + setMessage('Não foi possível detectar o token de acesso. Verifique o link enviado por email.'); + return + } + + setMessage('Validando token e obtendo dados do usuário...') + + // Buscar dados do usuário com o token recebido + const user = await getCurrentUser(access_token) + + // Persistir no localStorage com as chaves esperadas + if (typeof window !== 'undefined') { + localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, access_token) + if (refresh_token) localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refresh_token) + try { localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(user)) } catch {} + try { localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, user.userType || 'paciente') } catch {} + } + + setMessage('Autenticação concluída! Redirecionando...') + + // Determinar rota com base no tipo do usuário + const target = (user?.userType && USER_TYPE_ROUTES[user.userType]) || '/paciente' + + // Pequeno delay para UX + setTimeout(() => router.replace(target), 600) + } catch (err) { + console.error('[AUTH CALLBACK] Erro ao processar callback:', err) + if (err instanceof AuthenticationError) { + setMessage(err.message) + } else { + setMessage('Erro ao processar o link de autenticação. Tente novamente.') + } + } + } + + run() + }, [router]) + + return ( +
+
+

Autenticando...

+

{message}

+
+
+ ) +} diff --git a/susconecta/app/login-admin/page.tsx b/susconecta/app/login-admin/page.tsx index 958c714..104b400 100644 --- a/susconecta/app/login-admin/page.tsx +++ b/susconecta/app/login-admin/page.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' -import { AuthenticationError } from '@/lib/auth' +import { AuthenticationError, sendMagicLink } from '@/lib/auth' export default function LoginAdminPage() { const [credentials, setCredentials] = useState({ email: '', password: '' }) @@ -15,6 +15,8 @@ export default function LoginAdminPage() { const [loading, setLoading] = useState(false) const router = useRouter() const { login } = useAuth() + const [magicSent, setMagicSent] = useState('') + const [sendingMagic, setSendingMagic] = useState(false) const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -44,6 +46,24 @@ export default function LoginAdminPage() { } } + const handleSendMagic = async () => { + if (!credentials.email) return setError('Informe um email válido para receber o magic link.') + setSendingMagic(true) + setError('') + setMagicSent('') + + try { + await sendMagicLink(credentials.email) + setMagicSent('Magic link enviado! Verifique seu email (pode levar alguns minutos).') + } catch (err) { + console.error('[LOGIN-ADMIN] Erro ao enviar magic link:', err) + if (err instanceof AuthenticationError) setError(err.message) + else setError('Erro inesperado ao enviar magic link. Tente novamente mais tarde.') + } finally { + setSendingMagic(false) + } + } + return (
@@ -107,6 +127,12 @@ export default function LoginAdminPage() { > {loading ? 'Entrando...' : 'Entrar no Sistema Administrativo'} +
+ + {magicSent &&

{magicSent}

} +
diff --git a/susconecta/app/login-paciente/page.tsx b/susconecta/app/login-paciente/page.tsx index 75e4b2e..5ca8753 100644 --- a/susconecta/app/login-paciente/page.tsx +++ b/susconecta/app/login-paciente/page.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' -import { AuthenticationError } from '@/lib/auth' +import { AuthenticationError, sendMagicLink } from '@/lib/auth' export default function LoginPacientePage() { const [credentials, setCredentials] = useState({ email: '', password: '' }) @@ -15,6 +15,8 @@ export default function LoginPacientePage() { const [loading, setLoading] = useState(false) const router = useRouter() const { login } = useAuth() + const [magicSent, setMagicSent] = useState('') + const [sendingMagic, setSendingMagic] = useState(false) const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -51,6 +53,28 @@ export default function LoginPacientePage() { } } + const handleSendMagic = async () => { + if (!credentials.email) return setError('Informe um email válido para receber o magic link.') + setSendingMagic(true) + setError('') + setMagicSent('') + + try { + // Use shared helper which reads the configured endpoint + await sendMagicLink(credentials.email) + setMagicSent('Magic link enviado! Verifique seu email (pode levar alguns minutos).') + } catch (err) { + console.error('[LOGIN-PACIENTE] Erro ao enviar magic link:', err) + if (err instanceof AuthenticationError) { + setError(err.message) + } else { + setError('Erro inesperado ao enviar magic link. Tente novamente mais tarde.') + } + } finally { + setSendingMagic(false) + } + } + return (
@@ -114,6 +138,13 @@ export default function LoginPacientePage() { > {loading ? 'Entrando...' : 'Entrar na Minha Área'} + +
+ + {magicSent &&

{magicSent}

} +
diff --git a/susconecta/app/login/page.tsx b/susconecta/app/login/page.tsx index f39edb6..5dc93b3 100644 --- a/susconecta/app/login/page.tsx +++ b/susconecta/app/login/page.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' -import { AuthenticationError } from '@/lib/auth' +import { AuthenticationError, sendMagicLink } from '@/lib/auth' export default function LoginPage() { const [credentials, setCredentials] = useState({ email: '', password: '' }) @@ -15,6 +15,8 @@ export default function LoginPage() { const [loading, setLoading] = useState(false) const router = useRouter() const { login } = useAuth() + const [magicSent, setMagicSent] = useState('') + const [sendingMagic, setSendingMagic] = useState(false) const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -53,6 +55,24 @@ export default function LoginPage() { } } + const handleSendMagic = async () => { + if (!credentials.email) return setError('Informe um email válido para receber o magic link.') + setSendingMagic(true) + setError('') + setMagicSent('') + + try { + await sendMagicLink(credentials.email) + setMagicSent('Magic link enviado! Verifique seu email (pode levar alguns minutos).') + } catch (err) { + console.error('[LOGIN-PROFISSIONAL] Erro ao enviar magic link:', err) + if (err instanceof AuthenticationError) setError(err.message) + else setError('Erro inesperado ao enviar magic link. Tente novamente mais tarde.') + } finally { + setSendingMagic(false) + } + } + return (
@@ -116,6 +136,12 @@ export default function LoginPage() { > {loading ? 'Entrando...' : 'Entrar'} +
+ + {magicSent &&

{magicSent}

} +
diff --git a/susconecta/lib/auth.ts b/susconecta/lib/auth.ts index e21a67d..f90c888 100644 --- a/susconecta/lib/auth.ts +++ b/susconecta/lib/auth.ts @@ -356,4 +356,49 @@ export function createAuthenticatedFetch(getToken: () => string | null) { return fetch(url, options); }; +} + +/** + * Envia um magic link (OTP) por email para login sem senha + */ +export async function sendMagicLink(email: string): Promise<{ message?: string }> { + const url = (AUTH_ENDPOINTS && (AUTH_ENDPOINTS as any).OTP) || `${ENV_CONFIG.SUPABASE_URL}/auth/v1/otp`; + + console.log('[AUTH] Enviando magic link para:', email, 'url:', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getLoginHeaders(), + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + const text = await response.text(); + console.error('[AUTH] Erro ao enviar magic link:', response.status, text); + // tentar extrair mensagem amigável + try { + const data = JSON.parse(text); + throw new AuthenticationError(data.error || data.message || 'Erro ao enviar magic link', String(response.status), data); + } catch (e) { + throw new AuthenticationError('Erro ao enviar magic link', String(response.status), text); + } + } + + // resposta 200 normalmente sem corpo ou com { message } + let data: any = {}; + try { + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + } catch { + data = {}; + } + + console.log('[AUTH] Magic link enviado com sucesso:', data); + return { message: data.message }; + } catch (error) { + console.error('[AUTH] sendMagicLink error:', error); + if (error instanceof AuthenticationError) throw error; + throw new AuthenticationError('Não foi possível enviar o magic link', 'OTP_ERROR', error); + } } \ No newline at end of file diff --git a/susconecta/lib/env-config.ts b/susconecta/lib/env-config.ts index cd41cda..367f451 100644 --- a/susconecta/lib/env-config.ts +++ b/susconecta/lib/env-config.ts @@ -70,6 +70,8 @@ export const ENV_CONFIG = { LOGOUT: `${SUPABASE_URL}/auth/v1/logout`, REFRESH: `${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, USER: `${SUPABASE_URL}/auth/v1/user`, + // Endpoint para enviar magic link/OTP por email + OTP: `${SUPABASE_URL}/auth/v1/otp`, }, // Headers padrão