Skip to main content

Overview

Portal Ciudadano Manta implements a robust authentication system using Supabase Auth with role-based access control for citizens and administrators. The system includes email verification, password recovery, session management, and security features like rate limiting.

User Types

The system supports two distinct user roles:

Ciudadano

Regular citizens who can submit reports, participate in surveys, and view news

Administrador

Administrative users who manage content, review reports, and access the admin panel

Authentication Store

The authentication logic is centralized in auth.store.ts using Pinia for state management.

State Interface

import type { User } from "@supabase/supabase-js";
import type { Database } from "../types/database.types";

type Usuario = Database["public"]["Tables"]["usuarios"]["Row"];

interface AuthState {
  user: User | null;                    // Supabase auth user
  usuario: Usuario | null;              // Extended user profile
  loading: boolean;                     // Loading state
  error: string | null;                 // Error messages
  fetchingUser: boolean;                // Prevents concurrent fetches
}

User Profile Structure

interface Usuario {
  id: string;                           // UUID from Supabase Auth
  email: string;
  nombres: string;
  apellidos: string;
  cedula: string;                       // 10-digit Ecuadorian ID
  parroquia: string;                    // Parish (district)
  barrio: string;                       // Neighborhood
  tipo: "ciudadano" | "administrador";
  activo: boolean;
  created_at: string;
  updated_at: string;
}

Core Authentication Functions

User Registration

Registration creates both a Supabase Auth user and a profile record in the appropriate table.
const register = async (
  email: string,
  password: string,
  userData: {
    nombres: string;
    apellidos: string;
    cedula: string;
    parroquia?: string;
    barrio?: string;
    tipo: "ciudadano" | "administrador";
  }
) => {
  loading.value = true;
  error.value = null;

  try {
    // Create Supabase Auth user
    const { data: authData, error: authError } = await supabase.auth.signUp({
      email,
      password,
      options: {
        data: {
          nombres: userData.nombres,
          apellidos: userData.apellidos,
          cedula: userData.cedula,
          parroquia: userData.parroquia,
          barrio: userData.barrio,
          tipo: userData.tipo,
        },
        emailRedirectTo: `${window.location.origin}/login?verified=true`,
      },
    });

    if (authError) throw authError;
    if (!authData.user) throw new Error("Error al crear usuario");

    user.value = authData.user;

    // Create profile in appropriate table
    if (userData.tipo === "ciudadano") {
      await supabase.from("usuarios").insert({
        id: authData.user.id,
        email,
        nombres: userData.nombres,
        apellidos: userData.apellidos,
        cedula: userData.cedula,
        parroquia: userData.parroquia ?? "",
        barrio: userData.barrio ?? "",
        tipo: "ciudadano",
        activo: true,
      });
    } else if (userData.tipo === "administrador") {
      await supabase.from("administradores").insert({
        id: authData.user.id,
        email,
        nombres: userData.nombres,
        apellidos: userData.apellidos,
        cedula: userData.cedula,
        activo: true,
      });
    }

    return { success: true };
  } catch (err: any) {
    error.value = err.message;
    return { success: false, error: err.message };
  } finally {
    loading.value = false;
  }
};

User Login

Login authenticates the user and fetches their complete profile.
const login = async (email: string, password: string) => {
  loading.value = true;
  error.value = null;

  try {
    const { data, error: loginError } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (loginError) throw loginError;
    if (!data.user) throw new Error("Error al iniciar sesión");

    user.value = data.user;

    // Fetch user profile from usuarios table
    const usuarioData = await fetchUsuario(data.user.id);

    if (!usuarioData) {
      // Check if user is an administrator
      const { data: admin } = await supabase
        .from("administradores")
        .select("id, nombres")
        .eq("id", data.user.id)
        .single();

      if (admin) {
        usuario.value = {
          id: admin.id,
          email: data.user.email ?? "",
          nombres: admin.nombres,
          apellidos: "",
          cedula: "",
          parroquia: "",
          barrio: "",
          tipo: "administrador",
          activo: true,
          created_at: "",
          updated_at: "",
        };
      }
    }

    return { success: true };
  } catch (err: any) {
    error.value = err.message;
    return { success: false, error: err.message };
  } finally {
    loading.value = false;
  }
};

Session Management

The system automatically manages sessions and listens for auth state changes.
const initAuth = async () => {
  loading.value = true;
  try {
    // Get current session
    const { data: { session } } = await supabase.auth.getSession();

    if (session?.user) {
      user.value = session.user;
      await fetchUsuario(session.user.id);
    }

    // Listen for auth state changes
    supabase.auth.onAuthStateChange(async (event, session) => {
      console.log("🔐 Auth state change:", event);

      if (event === "SIGNED_OUT") {
        user.value = null;
        usuario.value = null;
      } else if (event === "TOKEN_REFRESHED") {
        console.log("🔄 Token refrescado exitosamente");
        user.value = session?.user ?? null;
      } else if (event === "SIGNED_IN") {
        if (!usuario.value || user.value?.id !== session?.user?.id) {
          user.value = session?.user ?? null;
          if (session?.user) {
            await fetchUsuario(session.user.id);
          }
        }
      }
    });
  } catch (err: any) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
};

Security Features

Rate Limiting

The login form implements progressive rate limiting to prevent brute force attacks:
1

Track Failed Attempts

The system tracks failed login attempts using localStorage and sessionStorage.
const INTENTOS_MAXIMOS = 3;
const intentosFallidos = ref(0);
const bloqueosAcumulados = ref(0);
const estaBloqueado = ref(false);
2

Progressive Lockout

Each failed attempt increases the lockout duration:
  • First lockout: 5 minutes
  • Second lockout: 10 minutes
  • Third lockout: 15 minutes, and so on
const bloquearFormulario = () => {
  bloqueosAcumulados.value++;
  const tiempoBloqueoMinutos = bloqueosAcumulados.value * 5;
  tiempoRestanteBloqueo.value = tiempoBloqueoMinutos * 60;
  estaBloqueado.value = true;
  guardarDatosBloqueo();
  iniciarContadorBloqueo();
};
3

Browser Fingerprinting

The system generates a unique browser identifier to track attempts across sessions.
const generarIdentificadorNavegador = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  // ... canvas fingerprinting logic ...
  
  const fingerprint = [
    navigator.userAgent,
    navigator.language,
    new Date().getTimezoneOffset(),
    screen.width + 'x' + screen.height,
    screen.colorDepth,
    canvas.toDataURL()
  ].join('|');
  
  // Generate hash
  let hash = 0;
  for (let i = 0; i < fingerprint.length; i++) {
    const char = fingerprint.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash;
  }
  
  return 'browser_' + Math.abs(hash).toString(36);
};

Password Recovery

Users can reset their password via email:
const resetPassword = async (email: string) => {
  loading.value = true;
  error.value = null;

  try {
    const { error: resetError } = await supabase.auth.resetPasswordForEmail(
      email,
      {
        redirectTo: `${window.location.origin}/reset-password`,
      }
    );

    if (resetError) throw resetError;
    return { success: true };
  } catch (err: any) {
    error.value = err.message;
    return { success: false, error: err.message };
  } finally {
    loading.value = false;
  }
};

Email Recovery

Users who forget their email can retrieve it using their Ecuadorian ID (cédula):
const searchUserByCedula = async (cedula: string) => {
  // Validate cédula format (10 digits)
  if (cedula.length !== 10 || !validateCedula(cedula)) {
    throw new Error("Cédula inválida");
  }

  // Use RPC function for security
  const { data, error } = await supabase.rpc("buscar_email_por_cedula", {
    cedula_input: cedula,
  });

  if (error) throw error;
  
  const usuario = data && data.length > 0 ? data[0] : null;
  return usuario;
};

Cedula Validation

The system validates Ecuadorian national IDs using the official algorithm:
const validarCedulaEcuatoriana = (cedula: string): boolean => {
  if (cedula.length !== 10) return false;
  
  const digitoRegion = Number(cedula.substring(0, 2));
  if (digitoRegion < 1 || digitoRegion > 24) return false;
  
  const ultimoDigito = Number(cedula.substring(9, 10));
  
  // Calculate checksum for even positions
  const pares =
    Number(cedula.substring(1, 2)) +
    Number(cedula.substring(3, 4)) +
    Number(cedula.substring(5, 6)) +
    Number(cedula.substring(7, 8));
  
  // Calculate checksum for odd positions (with special multiplication)
  let impares = 0;
  for (let i = 0; i < 9; i += 2) {
    let num = Number(cedula.substring(i, i + 1)) * 2;
    if (num > 9) num -= 9;
    impares += num;
  }
  
  const sumaTotal = pares + impares;
  const primerDigitoSuma = String(sumaTotal).substring(0, 1);
  const decena = (Number(primerDigitoSuma) + 1) * 10;
  let digitoValidador = decena - sumaTotal;
  if (digitoValidador === 10) digitoValidador = 0;
  
  return digitoValidador === ultimoDigito;
};

Role-Based Access Control

The system provides helper functions to check user roles:
// Getters
const isAuthenticated = () => !!user.value;
const isCiudadano = () => usuario.value?.tipo === "ciudadano";
const isAdministrador = () => usuario.value?.tipo === "administrador";

Usage in Components

<script setup lang="ts">
import { useAuthStore } from "@/stores/auth.store";

const authStore = useAuthStore();
</script>

<template>
  <div v-if="authStore.isAuthenticated()">
    <div v-if="authStore.isAdministrador()">
      <!-- Admin-only content -->
    </div>
    <div v-else-if="authStore.isCiudadano()">
      <!-- Citizen-only content -->
    </div>
  </div>
</template>

Best Practices

Supabase automatically handles session persistence. The rememberMe parameter is maintained for compatibility but sessions persist by default through localStorage.
Always handle authentication errors gracefully and provide user-friendly messages. The system includes translations for common error scenarios.
Supabase automatically refreshes tokens. The auth state listener handles TOKEN_REFRESHED events to update the UI without refetching user data.
The fetchingUser flag prevents multiple concurrent user profile fetches, improving performance and preventing race conditions.

Supabase Auth Docs

Official Supabase authentication documentation

Vue Router Guards

Protecting routes with navigation guards

Build docs developers (and LLMs) love