feat: panel admin, perfil de usuario y config IMAP por usuario
This commit is contained in:
parent
69fc1011d8
commit
b4b201323a
146
app.py
146
app.py
|
|
@ -160,7 +160,8 @@ def inicializar_admin():
|
|||
users = {
|
||||
"admin": {
|
||||
"password_hash": generate_password_hash(pwd),
|
||||
"nombre": "Admin"
|
||||
"nombre": "Admin",
|
||||
"admin": True
|
||||
}
|
||||
}
|
||||
guardar_usuarios(users)
|
||||
|
|
@ -168,7 +169,7 @@ def inicializar_admin():
|
|||
print(f"[init] Cambialo en users.json o con ADMIN_PASSWORD en el entorno.")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Decorador de autenticacion
|
||||
# Decoradores de autenticacion
|
||||
# -----------------------------------------------------------------------
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
|
|
@ -178,6 +179,17 @@ def login_required(f):
|
|||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("usuario"):
|
||||
return redirect(url_for("login", next=request.path))
|
||||
users = cargar_usuarios()
|
||||
if not users.get(session["usuario"], {}).get("admin"):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Rutas de autenticacion
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
@ -204,6 +216,136 @@ def logout():
|
|||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Perfil de usuario
|
||||
# -----------------------------------------------------------------------
|
||||
@app.route("/perfil", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def perfil():
|
||||
usuario = session["usuario"]
|
||||
users = cargar_usuarios()
|
||||
user = users[usuario]
|
||||
error = None
|
||||
ok = None
|
||||
|
||||
if request.method == "POST":
|
||||
accion = request.form.get("accion", "")
|
||||
|
||||
if accion == "nombre":
|
||||
nombre = request.form.get("nombre", "").strip()
|
||||
if not nombre:
|
||||
error = "El nombre no puede estar vacio"
|
||||
else:
|
||||
user["nombre"] = nombre
|
||||
session["nombre"] = nombre
|
||||
guardar_usuarios(users)
|
||||
ok = "Nombre actualizado"
|
||||
|
||||
elif accion == "password":
|
||||
pwd_actual = request.form.get("pwd_actual", "")
|
||||
pwd_nueva = request.form.get("pwd_nueva", "")
|
||||
pwd_nueva2 = request.form.get("pwd_nueva2", "")
|
||||
if not check_password_hash(user["password_hash"], pwd_actual):
|
||||
error = "La contrasena actual no es correcta"
|
||||
elif len(pwd_nueva) < 6:
|
||||
error = "La nueva contrasena debe tener al menos 6 caracteres"
|
||||
elif pwd_nueva != pwd_nueva2:
|
||||
error = "Las contrasenas no coinciden"
|
||||
else:
|
||||
user["password_hash"] = generate_password_hash(pwd_nueva)
|
||||
guardar_usuarios(users)
|
||||
ok = "Contrasena actualizada"
|
||||
|
||||
elif accion == "email":
|
||||
user["email_config"] = {
|
||||
"imap_host": request.form.get("imap_host", "").strip(),
|
||||
"imap_port": request.form.get("imap_port", "993").strip(),
|
||||
"correo": request.form.get("correo", "").strip(),
|
||||
"password": request.form.get("email_pwd", "").strip(),
|
||||
"remitente": request.form.get("remitente", "noreply@mercadona.es").strip(),
|
||||
"ssl_verify": request.form.get("ssl_verify", "false"),
|
||||
}
|
||||
guardar_usuarios(users)
|
||||
ok = "Configuracion de email guardada"
|
||||
|
||||
email_cfg = user.get("email_config", {})
|
||||
return render_template("perfil.html",
|
||||
nombre=session.get("nombre", ""),
|
||||
usuario=usuario,
|
||||
email_cfg=email_cfg,
|
||||
error=error, ok=ok)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Panel de administracion
|
||||
# -----------------------------------------------------------------------
|
||||
@app.route("/admin")
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
users = cargar_usuarios()
|
||||
lista = []
|
||||
for uname, udata in users.items():
|
||||
tickets_dir = BASE_DIR / "tickets" / uname
|
||||
n_tickets = sum(1 for f in tickets_dir.iterdir() if f.is_file()) if tickets_dir.exists() else 0
|
||||
lista.append({
|
||||
"usuario": uname,
|
||||
"nombre": udata.get("nombre", uname),
|
||||
"admin": udata.get("admin", False),
|
||||
"n_tickets": n_tickets,
|
||||
})
|
||||
lista.sort(key=lambda x: x["usuario"])
|
||||
return render_template("admin.html", nombre=session.get("nombre", ""), usuarios=lista)
|
||||
|
||||
@app.route("/admin/crear", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_crear_usuario():
|
||||
usuario = request.form.get("usuario", "").strip().lower()
|
||||
nombre = request.form.get("nombre", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
es_admin = request.form.get("es_admin") == "on"
|
||||
users = cargar_usuarios()
|
||||
error = None
|
||||
|
||||
if not usuario or not nombre or not password:
|
||||
error = "Todos los campos son obligatorios"
|
||||
elif len(usuario) < 3 or not usuario.isalnum():
|
||||
error = "Usuario: min. 3 caracteres, solo letras y numeros"
|
||||
elif len(password) < 6:
|
||||
error = "Contrasena: min. 6 caracteres"
|
||||
elif usuario in users:
|
||||
error = "Ese nombre de usuario ya existe"
|
||||
|
||||
if error:
|
||||
# Redirigir con el error como query param (sencillo)
|
||||
return redirect(url_for("admin_panel") + f"?error={error}")
|
||||
|
||||
users[usuario] = {
|
||||
"password_hash": generate_password_hash(password),
|
||||
"nombre": nombre,
|
||||
}
|
||||
if es_admin:
|
||||
users[usuario]["admin"] = True
|
||||
guardar_usuarios(users)
|
||||
(BASE_DIR / "tickets" / usuario).mkdir(parents=True, exist_ok=True)
|
||||
(BASE_DIR / "datos" / usuario).mkdir(parents=True, exist_ok=True)
|
||||
return redirect(url_for("admin_panel"))
|
||||
|
||||
@app.route("/admin/eliminar", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_eliminar_usuario():
|
||||
import shutil
|
||||
objetivo = request.form.get("usuario", "").strip().lower()
|
||||
if not objetivo or objetivo == session["usuario"]:
|
||||
return redirect(url_for("admin_panel")) # no se puede autoeliminar
|
||||
users = cargar_usuarios()
|
||||
users.pop(objetivo, None)
|
||||
guardar_usuarios(users)
|
||||
# Borrar datos del usuario
|
||||
for carpeta in ["tickets", "datos"]:
|
||||
d = BASE_DIR / carpeta / objetivo
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
return redirect(url_for("admin_panel"))
|
||||
|
||||
@app.route("/registro", methods=["GET", "POST"])
|
||||
def registro():
|
||||
error = None
|
||||
|
|
|
|||
|
|
@ -83,14 +83,33 @@ def descargar_tickets(usuario: str) -> int:
|
|||
cfg = leer_config()
|
||||
sec = cfg["email"]
|
||||
|
||||
# Variables de entorno sobreescriben config.ini
|
||||
host = os.environ.get("EMAIL_IMAP_HOST", sec["imap_host"]).strip()
|
||||
port = int(os.environ.get("EMAIL_IMAP_PORT", sec["imap_port"]))
|
||||
correo = os.environ.get("EMAIL_CORREO", sec["correo"]).strip()
|
||||
password = os.environ.get("EMAIL_PASSWORD", sec["password"]).strip()
|
||||
remitente = sec["remitente"]
|
||||
# Config por usuario desde users.json (tiene prioridad sobre config.ini)
|
||||
users_file = BASE_DIR / "users.json"
|
||||
user_email_cfg = {}
|
||||
if users_file.exists():
|
||||
try:
|
||||
import json as _json
|
||||
with open(users_file, encoding="utf-8") as f:
|
||||
users = _json.load(f)
|
||||
user_email_cfg = users.get(usuario, {}).get("email_config", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get(key, default=""):
|
||||
return user_email_cfg.get(key) or os.environ.get(
|
||||
{"imap_host": "EMAIL_IMAP_HOST", "imap_port": "EMAIL_IMAP_PORT",
|
||||
"correo": "EMAIL_CORREO", "password": "EMAIL_PASSWORD"}.get(key, ""),
|
||||
""
|
||||
) or sec.get(key, default)
|
||||
|
||||
host = _get("imap_host", "localhost").strip()
|
||||
port = int(_get("imap_port", "993"))
|
||||
correo = _get("correo").strip()
|
||||
password = _get("password").strip()
|
||||
remitente = _get("remitente", "noreply@mercadona.es")
|
||||
solo_nuevos = _get("ssl_verify", "true") != "false" # reutilizamos lógica abajo
|
||||
ssl_verify = _get("ssl_verify", "true").lower() == "true"
|
||||
solo_nuevos = sec["solo_nuevos"].lower() == "true"
|
||||
ssl_verify = sec["ssl_verify"].lower() == "true"
|
||||
ejecutar_pipeline = sec["ejecutar_pipeline"].lower() == "true"
|
||||
|
||||
if not correo or not password:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Administración — Lista de la Compra</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<style>
|
||||
.admin-wrap { max-width: 800px; margin: 0 auto; }
|
||||
.admin-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: .7rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.admin-section h2 { margin: 0 0 1rem; font-size: 1rem;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: .6rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
th { text-align: left; padding: .5rem .75rem;
|
||||
font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--text-muted); border-bottom: 1px solid var(--border); }
|
||||
td { padding: .6rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.badge-admin { background:#1a2e42; color:#58a6ff;
|
||||
font-size:.7rem; padding:.15rem .5rem; border-radius:1rem; }
|
||||
label { display:block; font-size:.82rem; color:var(--text-muted); margin:.65rem 0 .2rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width:100%; padding:.5rem .7rem;
|
||||
background:var(--bg-input); border:1px solid var(--border);
|
||||
border-radius:.45rem; color:var(--text); font-size:.88rem;
|
||||
}
|
||||
input:focus { outline:none; border-color:var(--primary); }
|
||||
.form-row { display:flex; gap:.75rem; }
|
||||
.form-row > * { flex:1; }
|
||||
.check-row { display:flex; align-items:center; gap:.5rem; margin-top:.6rem; font-size:.85rem; }
|
||||
.check-row input[type=checkbox] { width:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
||||
<h1 style="margin:0;">⚙️ Administración</h1>
|
||||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||||
<a href="/" class="btn btn-secondary btn-sm">← Inicio</a>
|
||||
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-wrap">
|
||||
|
||||
{% if request.args.get('error') %}
|
||||
<div class="alert" style="margin-bottom:1rem;">{{ request.args.get('error') }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Lista de usuarios -->
|
||||
<div class="admin-section">
|
||||
<h2>Usuarios registrados ({{ usuarios|length }})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Usuario</th>
|
||||
<th>Nombre</th>
|
||||
<th>Tickets</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in usuarios %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u.usuario }}
|
||||
{% if u.admin %}<span class="badge-admin">admin</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.nombre }}</td>
|
||||
<td>{{ u.n_tickets }}</td>
|
||||
<td style="text-align:right;">
|
||||
{% if u.usuario != session['usuario'] %}
|
||||
<form method="post" action="/admin/eliminar"
|
||||
onsubmit="return confirm('¿Eliminar a {{ u.usuario }} y todos sus datos?')">
|
||||
<input type="hidden" name="usuario" value="{{ u.usuario }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm"
|
||||
style="color:#f85149; border-color:#f85149;">
|
||||
Eliminar
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="font-size:.75rem; color:var(--text-muted);">(tú)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Crear usuario -->
|
||||
<div class="admin-section">
|
||||
<h2>Crear nuevo usuario</h2>
|
||||
<form method="post" action="/admin/crear">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Usuario <span style="font-size:.75rem;">(letras/números, min. 3)</span></label>
|
||||
<input name="usuario" type="text" placeholder="usuario123" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Nombre visible</label>
|
||||
<input name="nombre" type="text" placeholder="Nombre Apellido" required>
|
||||
</div>
|
||||
</div>
|
||||
<label>Contraseña <span style="font-size:.75rem;">(min. 6 caracteres)</span></label>
|
||||
<input name="password" type="password" placeholder="••••••••" required>
|
||||
<div class="check-row" style="margin-top:.75rem;">
|
||||
<input type="checkbox" id="es_admin" name="es_admin">
|
||||
<label for="es_admin" style="margin:0; color:var(--text);">Dar permisos de administrador</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:1rem;">
|
||||
Crear usuario
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div><!-- /admin-wrap -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -169,6 +169,7 @@
|
|||
<h1>Estadísticas</h1>
|
||||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||||
<a href="/" class="btn btn-secondary btn-sm">← Lista de la compra</a>
|
||||
<a href="/perfil" class="btn btn-secondary btn-sm">👤 Perfil</a>
|
||||
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@
|
|||
<h1>Lista de la Compra</h1>
|
||||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||||
{% if session.get('usuario') == 'admin' %}
|
||||
<a href="/admin/usuarios" class="btn btn-secondary btn-sm">Usuarios</a>
|
||||
<a href="/admin" class="btn btn-secondary btn-sm">⚙️ Admin</a>
|
||||
{% endif %}
|
||||
<a href="/estadisticas" class="btn btn-secondary btn-sm">📊 Estadísticas</a>
|
||||
<a href="/perfil" class="btn btn-secondary btn-sm">👤 Perfil</a>
|
||||
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mi perfil — Lista de la Compra</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<style>
|
||||
.perfil-wrap { max-width: 560px; margin: 0 auto; }
|
||||
.perfil-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: .7rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.perfil-section h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: .6rem;
|
||||
}
|
||||
label { display: block; font-size: .82rem; color: var(--text-muted); margin-bottom: .25rem; margin-top: .75rem; }
|
||||
label:first-of-type { margin-top: 0; }
|
||||
input[type=text], input[type=password], input[type=number], select {
|
||||
width: 100%; padding: .55rem .75rem;
|
||||
background: var(--bg-input); border: 1px solid var(--border);
|
||||
border-radius: .45rem; color: var(--text); font-size: .9rem;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--primary); }
|
||||
.form-row { display: flex; gap: .75rem; }
|
||||
.form-row > * { flex: 1; }
|
||||
.check-row { display: flex; align-items: center; gap: .5rem; margin-top: .75rem; font-size: .88rem; }
|
||||
.check-row input[type=checkbox] { width: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
||||
<h1 style="margin:0;">Mi perfil</h1>
|
||||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||||
<a href="/" class="btn btn-secondary btn-sm">← Inicio</a>
|
||||
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perfil-wrap">
|
||||
|
||||
{% if error %}
|
||||
<div class="alert" style="margin-bottom:1rem;">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if ok %}
|
||||
<div class="alert alert-ok" style="margin-bottom:1rem;">✅ {{ ok }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Nombre -->
|
||||
<div class="perfil-section">
|
||||
<h2>Datos personales</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="accion" value="nombre">
|
||||
<label for="nombre">Nombre visible</label>
|
||||
<input id="nombre" name="nombre" type="text" value="{{ nombre }}" required>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Guardar nombre</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contraseña -->
|
||||
<div class="perfil-section">
|
||||
<h2>Cambiar contraseña</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="accion" value="password">
|
||||
<label for="pwd_actual">Contraseña actual</label>
|
||||
<input id="pwd_actual" name="pwd_actual" type="password" autocomplete="current-password" required>
|
||||
<label for="pwd_nueva">Nueva contraseña</label>
|
||||
<input id="pwd_nueva" name="pwd_nueva" type="password" autocomplete="new-password" placeholder="min. 6 caracteres" required>
|
||||
<label for="pwd_nueva2">Repite la nueva contraseña</label>
|
||||
<input id="pwd_nueva2" name="pwd_nueva2" type="password" autocomplete="new-password" placeholder="••••••••" required>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Cambiar contraseña</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Email / IMAP -->
|
||||
<div class="perfil-section">
|
||||
<h2>📧 Importación automática de tickets</h2>
|
||||
<p style="font-size:.82rem; color:var(--text-muted); margin:0 0 1rem;">
|
||||
Configura tu cuenta de correo para que la app descargue automáticamente
|
||||
los tickets PDF de Mercadona. Reenvía o configura
|
||||
<strong>noreply@mercadona.es</strong> como remitente.
|
||||
</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="accion" value="email">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="imap_host">Servidor IMAP</label>
|
||||
<input id="imap_host" name="imap_host" type="text"
|
||||
placeholder="imap.gmail.com"
|
||||
value="{{ email_cfg.get('imap_host', '') }}">
|
||||
</div>
|
||||
<div style="max-width:100px;">
|
||||
<label for="imap_port">Puerto</label>
|
||||
<input id="imap_port" name="imap_port" type="number"
|
||||
value="{{ email_cfg.get('imap_port', '993') }}">
|
||||
</div>
|
||||
</div>
|
||||
<label for="correo">Correo electrónico</label>
|
||||
<input id="correo" name="correo" type="text"
|
||||
placeholder="tu@correo.com"
|
||||
value="{{ email_cfg.get('correo', '') }}" autocomplete="off">
|
||||
<label for="email_pwd">Contraseña del correo</label>
|
||||
<input id="email_pwd" name="email_pwd" type="password"
|
||||
placeholder="contraseña o clave de aplicación"
|
||||
value="{{ email_cfg.get('password', '') }}" autocomplete="off">
|
||||
<label for="remitente">Remitente de tickets (no cambiar normalmente)</label>
|
||||
<input id="remitente" name="remitente" type="text"
|
||||
value="{{ email_cfg.get('remitente', 'noreply@mercadona.es') }}">
|
||||
<div class="check-row">
|
||||
<input type="checkbox" id="ssl_verify" name="ssl_verify"
|
||||
value="false"
|
||||
{% if email_cfg.get('ssl_verify', 'true') == 'false' %}checked{% endif %}>
|
||||
<label for="ssl_verify" style="margin:0; color:var(--text);">
|
||||
Desactivar verificación SSL (útil con servidores locales)
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.85rem;">Guardar configuración de email</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div><!-- /perfil-wrap -->
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue