diff --git a/app/routers/minha_conta.py b/app/routers/minha_conta.py
new file mode 100644
index 0000000..d86cfc7
--- /dev/null
+++ b/app/routers/minha_conta.py
@@ -0,0 +1,75 @@
+"""
+Tela "Minha conta" — qualquer usuário logado (admin ou padrão) troca a
+própria senha aqui, sem precisar de um admin. Pede a senha atual antes de
+trocar, mesmo já estando logado (proteção extra contra sessão esquecida
+aberta em máquina compartilhada).
+
+A conta de bootstrap (ADMIN_USER/ADMIN_PASSWORD do .env) não tem linha em
+FUNCIONARIO_PAINEL — não há senha para trocar pelo painel; a tela avisa
+que a troca é editando o .env do servidor.
+"""
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from ..auth import UsuarioLogado, exigir_login
+from ..csrf import exigir_csrf, token_do_contexto
+from ..db import funcionarios as db_funcionarios
+from ..db import log as db_log
+from ..senha import verificar_senha
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+
+@router.get("/minha-conta", response_class=HTMLResponse)
+def form_minha_conta(request: Request, usuario: UsuarioLogado = Depends(exigir_login)):
+ funcionario = db_funcionarios.obter_por_login(usuario.login)
+ return templates.TemplateResponse(
+ request,
+ "minha_conta.html",
+ {
+ "usuario_atual": usuario, "eh_conta_env": funcionario is None,
+ "csrf_token": token_do_contexto(request),
+ },
+ )
+
+
+@router.post("/minha-conta/senha", dependencies=[Depends(exigir_csrf)])
+def trocar_senha(
+ request: Request,
+ senha_atual: str = Form(...),
+ nova_senha: str = Form(...),
+ confirmar_nova_senha: str = Form(...),
+ usuario: UsuarioLogado = Depends(exigir_login),
+):
+ funcionario = db_funcionarios.obter_por_login(usuario.login)
+
+ erro = None
+ if funcionario is None:
+ erro = "Esta conta é configurada por variável de ambiente (.env) — troque a senha lá, não por aqui."
+ elif not verificar_senha(senha_atual, funcionario["SENHA_HASH"]):
+ erro = "A senha atual digitada não confere."
+ elif not nova_senha or nova_senha != confirmar_nova_senha:
+ erro = "A nova senha e a confirmação não coincidem."
+
+ if erro:
+ return templates.TemplateResponse(
+ request,
+ "minha_conta.html",
+ {
+ "usuario_atual": usuario, "eh_conta_env": funcionario is None, "erro": erro,
+ "csrf_token": token_do_contexto(request),
+ },
+ )
+
+ db_funcionarios.redefinir_senha(funcionario["ID"], nova_senha)
+ db_log.registrar(str(usuario), "trocar_senha", "funcionario", str(funcionario["ID"]), True)
+ return templates.TemplateResponse(
+ request,
+ "minha_conta.html",
+ {
+ "usuario_atual": usuario, "eh_conta_env": False, "mensagem": "Senha alterada com sucesso.",
+ "csrf_token": token_do_contexto(request),
+ },
+ )
diff --git a/app/templates/minha_conta.html b/app/templates/minha_conta.html
new file mode 100644
index 0000000..f594a28
--- /dev/null
+++ b/app/templates/minha_conta.html
@@ -0,0 +1,31 @@
+
+{% extends "base.html" %}
+{% block titulo %}Minha conta{% endblock %}
+{% block conteudo %}
+
Minha conta
+ Nome: {{ usuario_atual.nome }}
+ Login: {{ usuario_atual.login }}
+
+ {% if erro %}{{ erro }}
{% endif %}
+ {% if mensagem %}{{ mensagem }}
{% endif %}
+
+ {% if eh_conta_env %}
+
+ Esta conta é a conta de administrador configurada por variável de ambiente
+ (ADMIN_USER/ADMIN_PASSWORD no .env do servidor) — a senha se troca lá,
+ não existe uma linha correspondente na tabela de funcionários.
+
+ {% else %}
+ Trocar senha
+
+ {% endif %}
+{% endblock %}
diff --git a/tests/test_router_minha_conta.py b/tests/test_router_minha_conta.py
new file mode 100644
index 0000000..19a872a
--- /dev/null
+++ b/tests/test_router_minha_conta.py
@@ -0,0 +1,142 @@
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.auth import UsuarioLogado, exigir_login
+from app.routers import minha_conta as router_minha_conta
+
+app_teste = FastAPI()
+app_teste.include_router(router_minha_conta.router)
+client = TestClient(app_teste, follow_redirects=False)
+CSRF_TESTE = "teste-csrf-token"
+client.cookies.set("csrf_token", CSRF_TESTE)
+
+
+@pytest.fixture(autouse=True)
+def _sem_log_real(monkeypatch):
+ monkeypatch.setattr(router_minha_conta.db_log, "registrar", lambda *args, **kwargs: None)
+
+
+def _logar_como(login: str, nome: str, perfil: str = "PADRAO"):
+ app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login=login, nome=nome, perfil=perfil)
+
+
+def test_get_minha_conta_mostra_nome_e_login(monkeypatch):
+ _logar_como("fulano", "Fulano da Silva")
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios,
+ "obter_por_login",
+ lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "x", "PERFIL": "PADRAO", "ATIVO": 1},
+ )
+ resposta = client.get("/minha-conta")
+ assert resposta.status_code == 200
+ assert "Fulano da Silva" in resposta.text
+ assert "fulano" in resposta.text
+
+
+def test_get_minha_conta_admin_env_mostra_aviso(monkeypatch):
+ import os
+
+ monkeypatch.setenv("ADMIN_USER", "admin")
+ _logar_como("admin", "admin")
+ monkeypatch.setattr(router_minha_conta.db_funcionarios, "obter_por_login", lambda login: None)
+ resposta = client.get("/minha-conta")
+ assert resposta.status_code == 200
+ assert ".env" in resposta.text
+
+
+def test_post_trocar_senha_com_sucesso(monkeypatch):
+ _logar_como("fulano", "Fulano da Silva")
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios,
+ "obter_por_login",
+ lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
+ )
+ monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: True)
+ capturado = {}
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios,
+ "redefinir_senha",
+ lambda fid, nova: capturado.setdefault("dados", (fid, nova)),
+ )
+
+ resposta = client.post(
+ "/minha-conta/senha",
+ data={
+ "senha_atual": "senhaAtual123", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
+ "csrf_token": CSRF_TESTE,
+ },
+ follow_redirects=True,
+ )
+ assert resposta.status_code == 200
+ assert capturado["dados"] == (1, "senhaNova456")
+
+
+def test_post_trocar_senha_atual_errada_bloqueia(monkeypatch):
+ _logar_como("fulano", "Fulano da Silva")
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios,
+ "obter_por_login",
+ lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
+ )
+ monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: False)
+ chamou = {}
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
+ )
+
+ resposta = client.post(
+ "/minha-conta/senha",
+ data={
+ "senha_atual": "senhaErrada", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
+ "csrf_token": CSRF_TESTE,
+ },
+ )
+ assert resposta.status_code == 200
+ assert "senha atual" in resposta.text.lower()
+ assert "ok" not in chamou
+
+
+def test_post_trocar_senha_confirmacao_nao_bate_bloqueia(monkeypatch):
+ _logar_como("fulano", "Fulano da Silva")
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios,
+ "obter_por_login",
+ lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
+ )
+ monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: True)
+ chamou = {}
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
+ )
+
+ resposta = client.post(
+ "/minha-conta/senha",
+ data={
+ "senha_atual": "senhaAtual123", "nova_senha": "senhaNova456", "confirmar_nova_senha": "outraCoisa",
+ "csrf_token": CSRF_TESTE,
+ },
+ )
+ assert resposta.status_code == 200
+ assert "não coincidem" in resposta.text
+ assert "ok" not in chamou
+
+
+def test_post_trocar_senha_admin_env_bloqueia(monkeypatch):
+ _logar_como("admin", "admin")
+ monkeypatch.setattr(router_minha_conta.db_funcionarios, "obter_por_login", lambda login: None)
+ chamou = {}
+ monkeypatch.setattr(
+ router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
+ )
+
+ resposta = client.post(
+ "/minha-conta/senha",
+ data={
+ "senha_atual": "qualquer", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
+ "csrf_token": CSRF_TESTE,
+ },
+ )
+ assert resposta.status_code == 200
+ assert ".env" in resposta.text
+ assert "ok" not in chamou