diff --git a/src/components/mika/AuthCard.tsx b/src/components/mika/AuthCard.tsx new file mode 100644 index 0000000..12fcb0b --- /dev/null +++ b/src/components/mika/AuthCard.tsx @@ -0,0 +1,38 @@ +import { Link } from "@tanstack/react-router"; +import { Logo } from "./Logo"; +import { ThemeToggle } from "./ThemeToggle"; + +export function AuthCard({ + title, + subtitle, + children, + footer, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+
+ + + + +
+
+
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/src/components/mika/GoogleButton.tsx b/src/components/mika/GoogleButton.tsx new file mode 100644 index 0000000..59c87b7 --- /dev/null +++ b/src/components/mika/GoogleButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { lovable } from "@/integrations/lovable/index"; +import { translateAuthError } from "@/lib/auth-errors"; + +export function GoogleButton({ redirectTo }: { redirectTo?: string }) { + const [loading, setLoading] = useState(false); + + const onClick = async () => { + setLoading(true); + try { + const url = redirectTo + ? `${window.location.origin}${redirectTo}` + : window.location.origin; + const result = await lovable.auth.signInWithOAuth("google", { redirect_uri: url }); + if (result.error) { + toast.error(translateAuthError(result.error.message)); + setLoading(false); + return; + } + if (result.redirected) return; + // tokens recebidos: redireciona + window.location.href = url; + } catch (e) { + toast.error(translateAuthError(e instanceof Error ? e.message : String(e))); + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/mika/PasswordStrengthMeter.tsx b/src/components/mika/PasswordStrengthMeter.tsx new file mode 100644 index 0000000..1c5fe85 --- /dev/null +++ b/src/components/mika/PasswordStrengthMeter.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { passwordStrength, passwordStrengthLabel } from "@/lib/password"; +import { cn } from "@/lib/utils"; + +export function PasswordStrengthMeter({ password }: { password: string }) { + if (!password) return null; + const score = passwordStrength(password); + const colors = [ + "bg-destructive", + "bg-destructive", + "bg-warning", + "bg-success", + ]; + return ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+

+ Força: {passwordStrengthLabel(score)} +

+
+ ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts new file mode 100644 index 0000000..6522992 --- /dev/null +++ b/src/hooks/use-auth.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Session, User } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +interface AuthState { + user: User | null; + session: Session | null; + loading: boolean; +} + +export function useAuth(): AuthState { + const [state, setState] = useState({ + user: null, + session: null, + loading: true, + }); + + useEffect(() => { + // CRITICAL: subscribe BEFORE getSession (avoid missed events) + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setState({ user: session?.user ?? null, session, loading: false }); + }); + + supabase.auth.getSession().then(({ data: { session } }) => { + setState({ user: session?.user ?? null, session, loading: false }); + }); + + return () => subscription.unsubscribe(); + }, []); + + return state; +} diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts new file mode 100644 index 0000000..7855eec --- /dev/null +++ b/src/hooks/use-profile.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "./use-auth"; + +export interface Profile { + id: string; + full_name: string; + company_name: string | null; + cpf_cnpj: string | null; + phone: string | null; + avatar_url: string | null; + stripe_customer_id: string | null; + onboarding_completed: boolean; +} + +export function useProfile() { + const { user } = useAuth(); + + return useQuery({ + queryKey: ["profile", user?.id], + enabled: !!user, + queryFn: async (): Promise => { + if (!user) return null; + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .maybeSingle(); + if (error) throw error; + return data as Profile | null; + }, + }); +} + +export interface SubscriptionRow { + id: string; + user_id: string; + plan_id: string | null; + stripe_subscription_id: string | null; + status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid"; + billing_cycle: "monthly" | "yearly"; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; +} + +export function useSubscription() { + const { user } = useAuth(); + + return useQuery({ + queryKey: ["subscription", user?.id], + enabled: !!user, + queryFn: async (): Promise => { + if (!user) return null; + const { data, error } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw error; + return data as SubscriptionRow | null; + }, + }); +} diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 0000000..27c8c0a --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,26 @@ +/** + * Traduz mensagens de erro do Supabase Auth para mensagens em português específicas. + */ +export function translateAuthError(message: string | undefined): string { + if (!message) return "Algo deu errado. Tente novamente."; + const m = message.toLowerCase(); + + if (m.includes("invalid login credentials") || m.includes("invalid_credentials")) + return "E-mail ou senha incorretos. Tente novamente."; + if (m.includes("user already registered") || m.includes("already been registered") || m.includes("already exists")) + return "Esse e-mail já possui cadastro. Deseja fazer login?"; + if (m.includes("password") && (m.includes("short") || m.includes("weak") || m.includes("characters"))) + return "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número."; + if (m.includes("email") && m.includes("invalid")) + return "Formato de e-mail inválido."; + if (m.includes("rate limit") || m.includes("too many requests")) + return "Muitas tentativas. Aguarde 1 minuto e tente novamente."; + if (m.includes("expired") || m.includes("jwt") || m.includes("session")) + return "Sua sessão expirou. Faça login novamente."; + if (m.includes("email not confirmed")) + return "Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada."; + if (m.includes("pwned") || m.includes("compromised") || m.includes("breach")) + return "Esta senha apareceu em vazamentos públicos. Escolha outra mais forte."; + + return message; +} diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..58ffa5f --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,14 @@ +export type PasswordStrength = 0 | 1 | 2 | 3; + +export function passwordStrength(password: string): PasswordStrength { + let score = 0; + if (password.length >= 8) score++; + if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++; + if (/\d/.test(password) && /[^A-Za-z0-9]/.test(password)) score++; + if (password.length >= 12) score = Math.min(3, score + 1) as PasswordStrength; + return Math.min(3, score) as PasswordStrength; +} + +export function passwordStrengthLabel(s: PasswordStrength): string { + return ["Muito fraca", "Fraca", "Média", "Forte"][s]; +}