feat(vcard): agregar login con email+password para obtener token automáticamente

- Nuevo endpoint POST /app/vcard-api/login que llama a /api/login del sistema VCard externo
- Modal de configuración con campos email+password y botón 'Obtener Token'
- El token se auto-rellena en el campo Bearer Token al conectar exitosamente
- Se mantiene la opción de pegar el token manualmente como fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Lizandro Guarnizo
2026-05-19 20:44:40 -05:00
co-authored by Copilot
parent 31259cbb71
commit 45032dae3f
3 changed files with 92 additions and 1 deletions
+52 -1
View File
@@ -333,11 +333,34 @@
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p class="text-xs text-gray-400 mt-1">Sin barra final. Ej: https://midominio.com</p>
</div>
<!-- Login para obtener token automáticamente -->
<div class="border border-gray-200 rounded-lg p-4 space-y-3 bg-gray-50">
<p class="text-xs font-medium text-gray-600 uppercase tracking-wide">Obtener token automáticamente</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-600 mb-1">Email admin</label>
<input x-model="loginForm.email" type="email" placeholder="admin@dominio.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] bg-white" />
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">Contraseña</label>
<input x-model="loginForm.password" type="password" placeholder="••••••••"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] bg-white" />
</div>
</div>
<button @click="obtenerToken()" :disabled="loginLoading"
class="w-full px-4 py-2 text-sm border border-[#8eb02f] text-[#8eb02f] rounded-lg hover:bg-[#8eb02f] hover:text-white transition-colors disabled:opacity-50">
<span x-text="loginLoading ? 'Conectando...' : '⚡ Obtener Token'"></span>
</button>
<div x-show="loginMsg" class="text-xs rounded px-3 py-2"
:class="loginOk ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-600 border border-red-200'"
x-text="loginMsg"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token <span class="text-red-500">*</span></label>
<input x-model="form.bearer_token" type="password" placeholder="1|abc123..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p class="text-xs text-gray-400 mt-1">Token Sanctum de un usuario con rol administrador.</p>
<p class="text-xs text-gray-400 mt-1">Se rellena automáticamente al obtener el token, o pégalo manualmente.</p>
</div>
<div class="flex items-center gap-2">
<input x-model="form.activo" type="checkbox" id="vcardActivo" class="rounded" />
@@ -441,6 +464,10 @@ function vcardApiApp() {
configModal: false,
configError: '',
form: { nombre: '', base_url: '', bearer_token: '', activo: true },
loginForm: { email: '', password: '' },
loginLoading: false,
loginMsg: '',
loginOk: false,
membresiaModal: false,
membresiaUser: null,
membresiaInfo: null,
@@ -472,6 +499,30 @@ function vcardApiApp() {
} catch(e) {}
},
async obtenerToken() {
this.loginMsg = '';
if (!this.form.base_url.trim()) { this.loginMsg = 'Ingresa primero la URL Base.'; this.loginOk = false; return; }
if (!this.loginForm.email || !this.loginForm.password) { this.loginMsg = 'Email y contraseña son requeridos.'; this.loginOk = false; return; }
this.loginLoading = true;
try {
const r = await fetch('/app/vcard-api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base_url: this.form.base_url, email: this.loginForm.email, password: this.loginForm.password })
});
const d = await r.json();
if (d.ok && d.token) {
this.form.bearer_token = d.token;
this.loginMsg = '✓ Token obtenido. Haz clic en Guardar para aplicar.';
this.loginOk = true;
} else {
this.loginMsg = d.error || 'Error al obtener el token.';
this.loginOk = false;
}
} catch(e) { this.loginMsg = e.message; this.loginOk = false; }
this.loginLoading = false;
},
async saveConfig() {
this.configError = '';
if (!this.form.base_url.trim()) { this.configError = 'La URL base es requerida.'; return; }
+39
View File
@@ -23,6 +23,45 @@ func VcardApiIndex(c *fiber.Ctx) error {
return c.Render("vcard_api", data, "layouts/main")
}
// VcardApiLogin llama a /api/login del sistema VCard externo y devuelve el access_token.
func VcardApiLogin(c *fiber.Ctx) error {
type Req struct {
BaseURL string `json:"base_url"`
Email string `json:"email"`
Password string `json:"password"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if req.BaseURL == "" || req.Email == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "base_url, email y password son requeridos"})
}
payload, _ := json.Marshal(map[string]string{"email": req.Email, "password": req.Password})
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(req.BaseURL+"/api/login", "application/json", strings.NewReader(string(payload)))
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "No se pudo conectar: " + err.Error()})
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "Respuesta inválida del servidor"})
}
if token, ok := result["access_token"].(string); ok && token != "" {
return c.JSON(fiber.Map{"ok": true, "token": token})
}
msg := "Credenciales incorrectas"
if m, ok := result["message"].(string); ok && m != "" {
msg = m
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": msg})
}
// VcardApiSaveConfig guarda / actualiza la configuración (base URL + token).
func VcardApiSaveConfig(c *fiber.Ctx) error {
type Req struct {
+1
View File
@@ -217,6 +217,7 @@ func UserRoutes(app fiber.Router) {
// ─── VCard API (integración Admin Laravel) ────────────────────────────────
protected.Get("/vcard-api", middlewares.MenuMiddleware, controllers.VcardApiIndex)
protected.Post("/vcard-api/login", controllers.VcardApiLogin)
protected.Get("/vcard-api/config", controllers.VcardApiGetConfig)
protected.Post("/vcard-api/config", controllers.VcardApiSaveConfig)
// Usuarios (GET)