mirror of https://github.com/interlegis/sapl.git
11 changed files with 1285 additions and 25 deletions
@ -1,9 +1,19 @@ |
|||||
FROM python:3.6 |
FROM python:3.12-slim-bookworm |
||||
ENV PYTHONUNBUFFERED 1 |
|
||||
|
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 \ |
||||
|
DEBIAN_FRONTEND=noninteractive |
||||
|
|
||||
WORKDIR /sapl-dev |
WORKDIR /sapl-dev |
||||
|
|
||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \ |
||||
|
build-essential git pkg-config \ |
||||
|
libpq-dev libxml2-dev libjpeg-dev libssl-dev libffi-dev libxslt1-dev \ |
||||
|
libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libharfbuzz-dev \ |
||||
|
libfreetype6-dev zlib1g-dev graphviz libmagic1 \ |
||||
|
&& rm -rf /var/lib/apt/lists/* |
||||
|
|
||||
COPY ./requirements ./requirements/ |
COPY ./requirements ./requirements/ |
||||
RUN apt update && \ |
RUN pip install --upgrade pip && \ |
||||
apt -y install graphviz-dev && \ |
|
||||
pip install --upgrade pip && \ |
|
||||
pip install -r ./requirements/dev-requirements.txt |
pip install -r ./requirements/dev-requirements.txt |
||||
|
|
||||
EXPOSE 8000 |
EXPOSE 8000 |
||||
|
|||||
@ -0,0 +1,277 @@ |
|||||
|
# Fluxo de Proposição do Vereador no SAPL |
||||
|
|
||||
|
Este documento descreve o processo completo de criação, envio e tramitação de uma proposição por um Vereador no Sistema de Apoio ao Processo Legislativo (SAPL). |
||||
|
|
||||
|
## Visão Geral |
||||
|
|
||||
|
O fluxo de proposição envolve três atores principais: |
||||
|
|
||||
|
| Ator | Papel | |
||||
|
|------|-------| |
||||
|
| **Vereador/Autor** | Cria e envia a proposição | |
||||
|
| **Protocolo/Mesa** | Recebe, valida e incorpora a proposição | |
||||
|
| **Sistema** | Gerencia estados, gera hash e registra histórico | |
||||
|
|
||||
|
## Diagrama de Estados |
||||
|
|
||||
|
``` |
||||
|
┌─────────────────┐ |
||||
|
│ ELABORAÇÃO │ ← Vereador cria/edita proposição |
||||
|
└────────┬────────┘ |
||||
|
│ clica "Enviar" |
||||
|
▼ |
||||
|
┌─────────────────┐ |
||||
|
│ ENVIADA │ ← Aguardando Protocolo |
||||
|
└────────┬────────┘ |
||||
|
│ Protocolo digita código hash |
||||
|
▼ |
||||
|
┌─────────────────┐ |
||||
|
│ CONFIRMAÇÃO │ ← Protocolo revisa |
||||
|
└────────┬────────┘ |
||||
|
│ |
||||
|
┌────┴───────┐ |
||||
|
▼ ▼ |
||||
|
┌────────┐ ┌──────────┐ |
||||
|
│DEVOLVER│ │INCORPORAR│ |
||||
|
└───┬────┘ └────┬─────┘ |
||||
|
│ │ |
||||
|
▼ ▼ |
||||
|
┌──────────┐ ┌───────────────────┐ |
||||
|
│ELABORAÇÃO│ │MATÉRIA LEGISLATIVA│ |
||||
|
│(corrigir)│ │ ou DOCUMENTO │ |
||||
|
└──────────┘ └───────────────────┘ |
||||
|
``` |
||||
|
|
||||
|
## Etapas Detalhadas |
||||
|
|
||||
|
### 1. Criação da Proposição (Vereador) |
||||
|
|
||||
|
**Acesso:** `/proposicao/create` |
||||
|
|
||||
|
O Vereador cria uma nova proposição preenchendo: |
||||
|
|
||||
|
| Campo | Descrição | |
||||
|
|-------|-----------| |
||||
|
| Tipo | Tipo de proposição (Projeto de Lei, Moção, Requerimento, etc.) | |
||||
|
| Descrição/Ementa | Texto descritivo da proposição | |
||||
|
| Texto | Pode ser: arquivo digital (upload), Texto Articulado ou OnlyOffice | |
||||
|
|
||||
|
**Opções de criação de texto:** |
||||
|
|
||||
|
- **Arquivo Digital (D):** Upload de arquivo DOCX/DOC/PDF |
||||
|
- **Texto Articulado (T):** Editor estruturado de normas (XML/LexML) |
||||
|
- **OnlyOffice (O):** Editor online de documentos |
||||
|
|
||||
|
### 2. Envio da Proposição (Vereador) |
||||
|
|
||||
|
**Acesso:** `/proposicao/<id>` → botão "Enviar" |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/views.py:912-970` |
||||
|
|
||||
|
#### O que acontece ao clicar "Enviar": |
||||
|
|
||||
|
1. **Validações:** |
||||
|
- Proposição não pode ter sido enviada anteriormente |
||||
|
- Deve possuir pelo menos um tipo de texto associado |
||||
|
|
||||
|
2. **Processamento:** |
||||
|
- Se houver Texto Articulado, ele é **bloqueado para edição** |
||||
|
- Data de envio é registrada (`data_envio = timestamp atual`) |
||||
|
- Hash MD5 é gerado para verificação de integridade |
||||
|
|
||||
|
3. **Registro:** |
||||
|
- Histórico é criado com status `E` (Enviada) |
||||
|
- IP e usuário são registrados |
||||
|
|
||||
|
4. **Resposta:** |
||||
|
- Mensagem de sucesso com número provável da futura matéria |
||||
|
- Recibo disponibilizado com código de barras/QR |
||||
|
|
||||
|
#### Código do hash gerado: |
||||
|
|
||||
|
``` |
||||
|
Formato: P<hash_md5>/<proposicao_id> |
||||
|
Exemplo: P8f14e45fceea167a5a36dedd4bea2543/26 |
||||
|
``` |
||||
|
|
||||
|
### 3. Geração do Recibo |
||||
|
|
||||
|
**Acesso:** `/proposicao/recibo/<id>` |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/views.py:1224-1279` |
||||
|
|
||||
|
O sistema gera um recibo contendo: |
||||
|
|
||||
|
- Código de barras com o hash |
||||
|
- QR Code para leitura rápida |
||||
|
- Informações da proposição (tipo, autor, data) |
||||
|
|
||||
|
> **Importante:** Este recibo é usado pelo Protocolo para recuperar a proposição. Não constitui assinatura digital. |
||||
|
|
||||
|
### 4. Recebimento pelo Protocolo |
||||
|
|
||||
|
**Acesso:** `/proposicao/receber/` |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/views.py:617-667` |
||||
|
|
||||
|
**Permissão necessária:** `materia.detail_proposicao_enviada` |
||||
|
|
||||
|
#### Processo: |
||||
|
|
||||
|
1. Operador do Protocolo acessa a área de recebimento |
||||
|
2. Digita ou escaneia o código hash do recibo |
||||
|
3. Sistema valida: |
||||
|
- Proposição existe e está enviada |
||||
|
- Hash confere (documento não foi alterado) |
||||
|
4. Redireciona para tela de confirmação |
||||
|
|
||||
|
### 5. Confirmação/Incorporação (Protocolo) |
||||
|
|
||||
|
**Acesso:** `/proposicao/confirmar/<hash>/<id>` |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/views.py:701-800` |
||||
|
|
||||
|
O Protocolo visualiza os dados da proposição e tem duas opções: |
||||
|
|
||||
|
#### Opção A: Incorporar |
||||
|
|
||||
|
Campos do formulário: |
||||
|
|
||||
|
| Campo | Obrigatório | Descrição | |
||||
|
|-------|-------------|-----------| |
||||
|
| Número de Matéria | Não | Número sugerido ou automático | |
||||
|
| Regime de Tramitação | Sim* | Como a matéria tramitará | |
||||
|
| Matéria de Vínculo | Condicional | Obrigatório para documentos | |
||||
|
| Gerar Protocolo | Configurável | Se deve criar protocolo administrativo | |
||||
|
| Número de Páginas | Não | Para o protocolo | |
||||
|
|
||||
|
*Obrigatório apenas para Matérias Legislativas |
||||
|
|
||||
|
**Resultado da incorporação:** |
||||
|
|
||||
|
1. Proposição marcada como recebida (`data_recebimento = timestamp`) |
||||
|
2. Criação de **MateriaLegislativa** ou **DocumentoAcessorio** |
||||
|
3. Registro de **Autoria** vinculando o autor |
||||
|
4. Opcionalmente, criação de **Protocolo** administrativo |
||||
|
5. Histórico atualizado com status `R` (Recebida) |
||||
|
|
||||
|
#### Opção B: Devolver |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/forms.py:2244-2330` |
||||
|
|
||||
|
Campos: |
||||
|
|
||||
|
| Campo | Obrigatório | Descrição | |
||||
|
|-------|-------------|-----------| |
||||
|
| Justificativa | Sim | Motivo da devolução | |
||||
|
| Observação | Não | Informações adicionais | |
||||
|
|
||||
|
**Resultado da devolução:** |
||||
|
|
||||
|
1. Proposição marcada como devolvida (`data_devolucao = timestamp`) |
||||
|
2. Datas de envio e recebimento são limpas |
||||
|
3. Texto Articulado é **desbloqueado para edição** |
||||
|
4. Histórico atualizado com status `D` (Devolvida) |
||||
|
5. Vereador pode corrigir e reenviar |
||||
|
|
||||
|
### 6. Retorno pelo Autor (Opcional) |
||||
|
|
||||
|
**Acesso:** `/proposicao/<id>` → botão "Retornar" |
||||
|
|
||||
|
**Código fonte:** `sapl/materia/views.py:971-998` |
||||
|
|
||||
|
Se a proposição foi enviada mas **ainda não foi recebida** pelo Protocolo, o Vereador pode retorná-la: |
||||
|
|
||||
|
- Data de envio é limpa |
||||
|
- Texto Articulado é desbloqueado |
||||
|
- Histórico registra status `T` (Retornada) |
||||
|
|
||||
|
## Modelo de Dados |
||||
|
|
||||
|
### Proposição (`sapl/materia/models.py:754-1050`) |
||||
|
|
||||
|
```python |
||||
|
class Proposicao(models.Model): |
||||
|
autor # Quem propôs |
||||
|
tipo # Tipo de proposição |
||||
|
descricao # Ementa/descrição |
||||
|
texto_original # Arquivo digital (se houver) |
||||
|
texto_articulado # Texto estruturado (se houver) |
||||
|
|
||||
|
# Controle de fluxo |
||||
|
data_envio # Quando foi enviada |
||||
|
data_recebimento # Quando foi recebida/incorporada |
||||
|
data_devolucao # Quando foi devolvida |
||||
|
|
||||
|
# Usuários envolvidos |
||||
|
usuario_envio # Quem enviou |
||||
|
usuario_recebimento # Quem recebeu |
||||
|
usuario_devolucao # Quem devolveu |
||||
|
|
||||
|
# Resultado |
||||
|
conteudo_gerado_related # Matéria ou Documento criado |
||||
|
materia_de_vinculo # Matéria relacionada |
||||
|
|
||||
|
# Segurança |
||||
|
hash_code # MD5 para verificação |
||||
|
cancelado # Se foi cancelada |
||||
|
``` |
||||
|
|
||||
|
### Histórico (`sapl/materia/models.py`) |
||||
|
|
||||
|
```python |
||||
|
class HistoricoProposicao(models.Model): |
||||
|
proposicao # Referência à proposição |
||||
|
status # E=Enviada, R=Recebida, T=Retornada, D=Devolvida |
||||
|
data_hora # Timestamp da ação |
||||
|
user # Usuário que executou |
||||
|
ip # IP do usuário |
||||
|
observacao # Justificativa (em devoluções) |
||||
|
``` |
||||
|
|
||||
|
## Permissões |
||||
|
|
||||
|
| Permissão | Descrição | |
||||
|
|-----------|-----------| |
||||
|
| `detail_proposicao_enviada` | Acessar proposições enviadas (Protocolo) | |
||||
|
| `detail_proposicao_devolvida` | Acessar proposições devolvidas | |
||||
|
| `detail_proposicao_incorporada` | Acessar proposições incorporadas | |
||||
|
|
||||
|
## Sobre Assinatura Digital |
||||
|
|
||||
|
> **Importante:** O SAPL atualmente **não possui integração com assinatura digital** (PKI/ICP-Brasil). |
||||
|
|
||||
|
O que existe para segurança: |
||||
|
|
||||
|
| Mecanismo | Função | |
||||
|
|-----------|--------| |
||||
|
| Hash MD5 | Verificar integridade do documento | |
||||
|
| Registro de IP | Rastreabilidade de ações | |
||||
|
| Registro de Usuário | Identificação do responsável | |
||||
|
| Histórico completo | Auditoria de mudanças de estado | |
||||
|
| Bloqueio de edição | Impedir alterações após envio | |
||||
|
|
||||
|
O hash gerado serve para verificar que o documento não foi alterado entre o envio e o recebimento, mas **não constitui assinatura eletrônica** no sentido jurídico. |
||||
|
|
||||
|
## Arquivos do Código-Fonte |
||||
|
|
||||
|
| Arquivo | Responsabilidade | |
||||
|
|---------|------------------| |
||||
|
| `sapl/materia/models.py` | Modelos Proposicao, HistoricoProposicao, TipoProposicao | |
||||
|
| `sapl/materia/views.py` | Views de envio, recebimento, confirmação | |
||||
|
| `sapl/materia/forms.py` | Formulários de proposição | |
||||
|
| `sapl/materia/onlyoffice_views.py` | Integração com editor OnlyOffice | |
||||
|
| `sapl/materia/urls.py` | Rotas e URLs | |
||||
|
| `sapl/templates/materia/proposicao_*.html` | Templates de apresentação | |
||||
|
|
||||
|
## Resumo do Fluxo |
||||
|
|
||||
|
1. **Vereador cria** proposição com texto (upload, Texto Articulado ou OnlyOffice) |
||||
|
2. **Vereador envia** → Sistema gera hash e bloqueia edição |
||||
|
3. **Sistema gera recibo** com código de barras |
||||
|
4. **Protocolo recebe** digitando/escaneando o código hash |
||||
|
5. **Protocolo valida** integridade (hash confere?) |
||||
|
6. **Protocolo decide:** |
||||
|
- **Incorporar** → Cria Matéria Legislativa ou Documento |
||||
|
- **Devolver** → Volta para Vereador corrigir |
||||
|
7. **Se incorporado** → Matéria entra em tramitação normal |
||||
@ -0,0 +1,327 @@ |
|||||
|
/** |
||||
|
* Estilos para o Modal de Assinatura Digital |
||||
|
* SAPL - Sistema de Apoio ao Processo Legislativo |
||||
|
*/ |
||||
|
|
||||
|
/* Modal de Assinatura */ |
||||
|
#assinaturaModal .modal-header { |
||||
|
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); |
||||
|
border-bottom: none; |
||||
|
border-radius: 0.3rem 0.3rem 0 0; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .modal-header .modal-title { |
||||
|
font-weight: 600; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .modal-header .close { |
||||
|
color: white; |
||||
|
opacity: 0.8; |
||||
|
text-shadow: none; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .modal-header .close:hover { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .modal-footer { |
||||
|
border-top: 1px solid #e9ecef; |
||||
|
background-color: #f8f9fa; |
||||
|
} |
||||
|
|
||||
|
/* Cards de Tipo de Certificado */ |
||||
|
.tipo-certificado-card { |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s ease; |
||||
|
border: 2px solid #dee2e6; |
||||
|
border-radius: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card:hover { |
||||
|
border-color: #007bff; |
||||
|
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.2); |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card.selected { |
||||
|
border-color: #28a745; |
||||
|
background-color: #f8fff8; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card .card-body { |
||||
|
padding: 2rem 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card .fa { |
||||
|
transition: transform 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card:hover .fa { |
||||
|
transform: scale(1.1); |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card .card-title { |
||||
|
font-weight: 600; |
||||
|
margin-bottom: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card .card-text { |
||||
|
font-size: 0.9rem; |
||||
|
min-height: 2.5rem; |
||||
|
} |
||||
|
|
||||
|
/* Formulário A1 */ |
||||
|
#formulario-a1 .form-group label { |
||||
|
font-weight: 600; |
||||
|
color: #495057; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
#formulario-a1 .custom-file-label { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
#formulario-a1 .custom-file-label::after { |
||||
|
content: "Procurar"; |
||||
|
background-color: #007bff; |
||||
|
color: white; |
||||
|
border-left: 1px solid #007bff; |
||||
|
} |
||||
|
|
||||
|
#formulario-a1 .custom-file-input:focus ~ .custom-file-label { |
||||
|
border-color: #80bdff; |
||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); |
||||
|
} |
||||
|
|
||||
|
/* Formulário A3 */ |
||||
|
#a3-detectando { |
||||
|
padding: 2rem; |
||||
|
} |
||||
|
|
||||
|
#a3-detectando .fa-spinner { |
||||
|
color: #007bff; |
||||
|
} |
||||
|
|
||||
|
#a3-nao-detectado ul { |
||||
|
margin-top: 1rem; |
||||
|
padding-left: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
#a3-nao-detectado ul li { |
||||
|
margin-bottom: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
#a3-nao-detectado ul a { |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
#a3-cert-info { |
||||
|
background-color: #f8f9fa; |
||||
|
border: 1px solid #e9ecef; |
||||
|
} |
||||
|
|
||||
|
#a3-cert-info .card-title { |
||||
|
font-size: 0.9rem; |
||||
|
color: #6c757d; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.5px; |
||||
|
} |
||||
|
|
||||
|
/* Status da Assinatura */ |
||||
|
#status-assinatura { |
||||
|
min-height: 200px; |
||||
|
} |
||||
|
|
||||
|
#status-processando { |
||||
|
padding: 2rem; |
||||
|
} |
||||
|
|
||||
|
#status-processando .fa-spinner { |
||||
|
animation: fa-spin 1s infinite linear; |
||||
|
} |
||||
|
|
||||
|
#status-processando .progress { |
||||
|
height: 8px; |
||||
|
border-radius: 4px; |
||||
|
overflow: hidden; |
||||
|
background-color: #e9ecef; |
||||
|
} |
||||
|
|
||||
|
#status-processando .progress-bar { |
||||
|
background: linear-gradient(90deg, #007bff 0%, #00d4ff 100%); |
||||
|
transition: width 0.5s ease; |
||||
|
} |
||||
|
|
||||
|
#status-sucesso { |
||||
|
padding: 2rem; |
||||
|
} |
||||
|
|
||||
|
#status-sucesso .fa-check-circle { |
||||
|
color: #28a745; |
||||
|
} |
||||
|
|
||||
|
#status-sucesso h5 { |
||||
|
color: #28a745; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
#status-erro { |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
#status-erro .alert { |
||||
|
margin-bottom: 0; |
||||
|
} |
||||
|
|
||||
|
/* Alertas customizados */ |
||||
|
#assinaturaModal .alert-info { |
||||
|
background-color: #e7f3ff; |
||||
|
border-color: #b8daff; |
||||
|
color: #004085; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .alert-warning { |
||||
|
background-color: #fff3cd; |
||||
|
border-color: #ffeeba; |
||||
|
color: #856404; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .alert-success { |
||||
|
background-color: #d4edda; |
||||
|
border-color: #c3e6cb; |
||||
|
color: #155724; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .alert-danger { |
||||
|
background-color: #f8d7da; |
||||
|
border-color: #f5c6cb; |
||||
|
color: #721c24; |
||||
|
} |
||||
|
|
||||
|
/* Botões */ |
||||
|
#assinaturaModal .btn-voltar-selecao { |
||||
|
padding: 0; |
||||
|
font-size: 0.9rem; |
||||
|
color: #6c757d; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .btn-voltar-selecao:hover { |
||||
|
color: #007bff; |
||||
|
text-decoration: none; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .btn-assinar { |
||||
|
min-width: 160px; |
||||
|
} |
||||
|
|
||||
|
/* Card de Assinatura no Detail */ |
||||
|
.card.border-success .card-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.card.border-success .badge-success { |
||||
|
background-color: #28a745; |
||||
|
font-size: 0.85rem; |
||||
|
padding: 0.35em 0.65em; |
||||
|
} |
||||
|
|
||||
|
/* Responsividade */ |
||||
|
@media (max-width: 768px) { |
||||
|
.tipo-certificado-card .card-body { |
||||
|
padding: 1.5rem 1rem; |
||||
|
} |
||||
|
|
||||
|
.tipo-certificado-card .fa { |
||||
|
font-size: 2rem !important; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .modal-body { |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal .row.mb-4 > .col-md-6 { |
||||
|
margin-bottom: 1rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* Animação de entrada */ |
||||
|
@keyframes fadeInUp { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: translateY(20px); |
||||
|
} |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#assinaturaModal.show .modal-content { |
||||
|
animation: fadeInUp 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
/* Estados de loading */ |
||||
|
.btn-assinar.loading { |
||||
|
pointer-events: none; |
||||
|
opacity: 0.7; |
||||
|
} |
||||
|
|
||||
|
.btn-assinar.loading::after { |
||||
|
content: ''; |
||||
|
display: inline-block; |
||||
|
width: 1rem; |
||||
|
height: 1rem; |
||||
|
margin-left: 0.5rem; |
||||
|
border: 2px solid transparent; |
||||
|
border-top-color: currentColor; |
||||
|
border-radius: 50%; |
||||
|
animation: fa-spin 0.75s linear infinite; |
||||
|
} |
||||
|
|
||||
|
/* Tooltip para informações adicionais */ |
||||
|
.info-tooltip { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
border-radius: 50%; |
||||
|
background-color: #6c757d; |
||||
|
color: white; |
||||
|
font-size: 0.7rem; |
||||
|
cursor: help; |
||||
|
margin-left: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.info-tooltip:hover { |
||||
|
background-color: #007bff; |
||||
|
} |
||||
|
|
||||
|
/* Estilo para certificados expirados */ |
||||
|
.certificado-expirado { |
||||
|
color: #dc3545; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.certificado-valido { |
||||
|
color: #28a745; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
/* Estilo para lista de certificados A3 */ |
||||
|
#certificado-a3-select { |
||||
|
font-size: 0.95rem; |
||||
|
} |
||||
|
|
||||
|
#certificado-a3-select option { |
||||
|
padding: 0.5rem; |
||||
|
} |
||||
@ -0,0 +1,320 @@ |
|||||
|
/** |
||||
|
* Módulo JavaScript para Assinatura Digital com Certificado A3 |
||||
|
* Gerencia a comunicação com aplicações de assinatura local (token USB/smartcard) |
||||
|
*/ |
||||
|
|
||||
|
(function(global) { |
||||
|
'use strict'; |
||||
|
|
||||
|
var AssinaturaA3 = { |
||||
|
/** |
||||
|
* Configurações das aplicações suportadas |
||||
|
*/ |
||||
|
aplicacoes: { |
||||
|
serpro: { |
||||
|
nome: 'Assinador SERPRO', |
||||
|
porta: 10443, |
||||
|
protocolo: 'https', |
||||
|
endpoints: { |
||||
|
listarCertificados: '/certificados', |
||||
|
assinar: '/assinar', |
||||
|
verificar: '/verificar' |
||||
|
} |
||||
|
}, |
||||
|
webpki: { |
||||
|
nome: 'Lacuna Web PKI', |
||||
|
porta: 5000, |
||||
|
protocolo: 'http', |
||||
|
endpoints: { |
||||
|
listarCertificados: '/api/certificates', |
||||
|
assinar: '/api/sign', |
||||
|
verificar: '/api/verify' |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Aplicação detectada |
||||
|
*/ |
||||
|
appDetectada: null, |
||||
|
|
||||
|
/** |
||||
|
* Detecta qual aplicação de assinatura está rodando |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
detectar: function() { |
||||
|
var self = this; |
||||
|
var apps = Object.keys(this.aplicacoes); |
||||
|
|
||||
|
return new Promise(function(resolve, reject) { |
||||
|
var tentativas = 0; |
||||
|
|
||||
|
apps.forEach(function(appKey) { |
||||
|
var app = self.aplicacoes[appKey]; |
||||
|
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; |
||||
|
|
||||
|
// Usando timeout com AbortController
|
||||
|
var controller = new AbortController(); |
||||
|
var timeoutId = setTimeout(function() { |
||||
|
controller.abort(); |
||||
|
}, 3000); |
||||
|
|
||||
|
fetch(url, { |
||||
|
method: 'GET', |
||||
|
mode: 'cors', |
||||
|
credentials: 'omit', |
||||
|
signal: controller.signal |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
clearTimeout(timeoutId); |
||||
|
if (response.ok) { |
||||
|
self.appDetectada = { |
||||
|
key: appKey, |
||||
|
config: app |
||||
|
}; |
||||
|
resolve({ |
||||
|
success: true, |
||||
|
app: app.nome, |
||||
|
key: appKey |
||||
|
}); |
||||
|
} else { |
||||
|
throw new Error('Resposta não OK'); |
||||
|
} |
||||
|
}) |
||||
|
.catch(function(error) { |
||||
|
clearTimeout(timeoutId); |
||||
|
tentativas++; |
||||
|
if (tentativas >= apps.length) { |
||||
|
reject({ |
||||
|
success: false, |
||||
|
error: 'Nenhuma aplicação de assinatura A3 detectada. Certifique-se de que a aplicação está em execução.' |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Testa se uma aplicação específica está respondendo |
||||
|
* @param {string} appKey - Chave da aplicação (serpro, webpki) |
||||
|
* @returns {Promise<boolean>} |
||||
|
*/ |
||||
|
testarConexao: function(appKey) { |
||||
|
var app = this.aplicacoes[appKey]; |
||||
|
if (!app) { |
||||
|
return Promise.resolve(false); |
||||
|
} |
||||
|
|
||||
|
var url = app.protocolo + '://localhost:' + app.porta + '/'; |
||||
|
|
||||
|
return fetch(url, { |
||||
|
method: 'GET', |
||||
|
mode: 'no-cors' |
||||
|
}) |
||||
|
.then(function() { |
||||
|
return true; |
||||
|
}) |
||||
|
.catch(function() { |
||||
|
return false; |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Lista os certificados disponíveis no token/smartcard |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
listarCertificados: function() { |
||||
|
var self = this; |
||||
|
|
||||
|
if (!this.appDetectada) { |
||||
|
return Promise.reject({ |
||||
|
error: 'Nenhuma aplicação detectada. Execute detectar() primeiro.' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
var app = this.appDetectada.config; |
||||
|
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; |
||||
|
|
||||
|
return fetch(url, { |
||||
|
method: 'GET', |
||||
|
mode: 'cors', |
||||
|
credentials: 'omit', |
||||
|
headers: { |
||||
|
'Accept': 'application/json' |
||||
|
} |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Erro ao listar certificados'); |
||||
|
} |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(function(data) { |
||||
|
// Normaliza o formato dos certificados
|
||||
|
return self._normalizarCertificados(data); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Normaliza o formato dos certificados para um formato comum |
||||
|
* @param {Object} data - Dados retornados pela API |
||||
|
* @returns {Array} |
||||
|
*/ |
||||
|
_normalizarCertificados: function(data) { |
||||
|
var certificados = []; |
||||
|
|
||||
|
if (Array.isArray(data)) { |
||||
|
certificados = data; |
||||
|
} else if (data.certificados) { |
||||
|
certificados = data.certificados; |
||||
|
} else if (data.certificates) { |
||||
|
certificados = data.certificates; |
||||
|
} |
||||
|
|
||||
|
return certificados.map(function(cert) { |
||||
|
return { |
||||
|
id: cert.id || cert.thumbprint || cert.serial, |
||||
|
subject: cert.subject || cert.subjectName || cert.nome, |
||||
|
issuer: cert.issuer || cert.issuerName || cert.emissor, |
||||
|
validFrom: cert.validFrom || cert.notBefore || cert.validade_inicio, |
||||
|
validTo: cert.validTo || cert.notAfter || cert.validade_fim, |
||||
|
thumbprint: cert.thumbprint || cert.hash, |
||||
|
raw: cert |
||||
|
}; |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Assina dados com o certificado selecionado |
||||
|
* @param {string} certificadoId - ID/thumbprint do certificado |
||||
|
* @param {string} dados - Dados a serem assinados (base64 ou hash) |
||||
|
* @param {string} algoritmo - Algoritmo de hash (sha256, sha384, sha512) |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
assinar: function(certificadoId, dados, algoritmo) { |
||||
|
var self = this; |
||||
|
|
||||
|
if (!this.appDetectada) { |
||||
|
return Promise.reject({ |
||||
|
error: 'Nenhuma aplicação detectada.' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
var app = this.appDetectada.config; |
||||
|
var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.assinar; |
||||
|
|
||||
|
var payload = { |
||||
|
certificateId: certificadoId, |
||||
|
data: dados, |
||||
|
algorithm: algoritmo || 'sha256' |
||||
|
}; |
||||
|
|
||||
|
// Adapta payload para formato específico de cada aplicação
|
||||
|
if (this.appDetectada.key === 'serpro') { |
||||
|
payload = { |
||||
|
certificado: certificadoId, |
||||
|
dados: dados, |
||||
|
algoritmo: algoritmo || 'SHA256' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return fetch(url, { |
||||
|
method: 'POST', |
||||
|
mode: 'cors', |
||||
|
credentials: 'omit', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
'Accept': 'application/json' |
||||
|
}, |
||||
|
body: JSON.stringify(payload) |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Erro ao assinar'); |
||||
|
} |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(function(data) { |
||||
|
return { |
||||
|
success: true, |
||||
|
signature: data.signature || data.assinatura, |
||||
|
certificate: data.certificate || data.certificado |
||||
|
}; |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Retorna informações sobre a aplicação detectada |
||||
|
* @returns {Object|null} |
||||
|
*/ |
||||
|
getAplicacaoDetectada: function() { |
||||
|
return this.appDetectada; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Retorna lista de aplicações suportadas |
||||
|
* @returns {Array} |
||||
|
*/ |
||||
|
getAplicacoesSuportadas: function() { |
||||
|
var self = this; |
||||
|
return Object.keys(this.aplicacoes).map(function(key) { |
||||
|
return { |
||||
|
key: key, |
||||
|
nome: self.aplicacoes[key].nome, |
||||
|
porta: self.aplicacoes[key].porta |
||||
|
}; |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Fluxo completo de assinatura A3 |
||||
|
* @param {string} hashPdf - Hash SHA256 do PDF em hexadecimal |
||||
|
* @param {function} onProgress - Callback de progresso |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
assinarPdf: function(hashPdf, onProgress) { |
||||
|
var self = this; |
||||
|
|
||||
|
return new Promise(function(resolve, reject) { |
||||
|
// Etapa 1: Detectar aplicação
|
||||
|
if (onProgress) onProgress(10, 'Detectando aplicação de assinatura...'); |
||||
|
|
||||
|
self.detectar() |
||||
|
.then(function(deteccao) { |
||||
|
if (onProgress) onProgress(30, 'Aplicação detectada: ' + deteccao.app); |
||||
|
|
||||
|
// Etapa 2: Listar certificados
|
||||
|
return self.listarCertificados(); |
||||
|
}) |
||||
|
.then(function(certificados) { |
||||
|
if (!certificados || certificados.length === 0) { |
||||
|
throw { error: 'Nenhum certificado encontrado no token' }; |
||||
|
} |
||||
|
|
||||
|
if (onProgress) onProgress(50, 'Certificados encontrados: ' + certificados.length); |
||||
|
|
||||
|
// Por enquanto, usa o primeiro certificado
|
||||
|
// Em produção, deveria mostrar seletor para o usuário
|
||||
|
var certificado = certificados[0]; |
||||
|
|
||||
|
if (onProgress) onProgress(70, 'Assinando com: ' + certificado.subject); |
||||
|
|
||||
|
// Etapa 3: Assinar
|
||||
|
return self.assinar(certificado.id, hashPdf, 'sha256'); |
||||
|
}) |
||||
|
.then(function(resultado) { |
||||
|
if (onProgress) onProgress(100, 'Assinatura concluída!'); |
||||
|
resolve(resultado); |
||||
|
}) |
||||
|
.catch(function(error) { |
||||
|
reject(error); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Expor globalmente
|
||||
|
global.AssinaturaA3 = AssinaturaA3; |
||||
|
|
||||
|
})(typeof window !== 'undefined' ? window : this); |
||||
@ -0,0 +1,287 @@ |
|||||
|
/** |
||||
|
* Módulo JavaScript para Assinatura Digital de Matérias Legislativas |
||||
|
* Gerencia o fluxo de assinatura com certificados A1 e A3 |
||||
|
*/ |
||||
|
|
||||
|
(function(global) { |
||||
|
'use strict'; |
||||
|
|
||||
|
var AssinaturaMateria = { |
||||
|
/** |
||||
|
* Configurações |
||||
|
*/ |
||||
|
config: { |
||||
|
materiaId: null, |
||||
|
csrfToken: null, |
||||
|
urls: { |
||||
|
assinarA1: '/materia/{pk}/assinar/a1/', |
||||
|
assinarA3Preparar: '/materia/{pk}/assinar/a3/preparar/', |
||||
|
assinarA3Finalizar: '/materia/{pk}/assinar/a3/finalizar/', |
||||
|
pdfAssinado: '/materia/{pk}/pdf-assinado/', |
||||
|
verificarAssinatura: '/materia/{pk}/verificar-assinatura/', |
||||
|
detectarA3: '/materia/assinatura/detectar-a3/' |
||||
|
}, |
||||
|
portasA3: [ |
||||
|
{ nome: 'Assinador SERPRO', porta: 10443, protocolo: 'https' }, |
||||
|
{ nome: 'Web PKI Local', porta: 5000, protocolo: 'http' } |
||||
|
] |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Inicializa o módulo |
||||
|
* @param {number} materiaId - ID da matéria legislativa |
||||
|
* @param {string} csrfToken - Token CSRF para requisições POST |
||||
|
*/ |
||||
|
init: function(materiaId, csrfToken) { |
||||
|
this.config.materiaId = materiaId; |
||||
|
this.config.csrfToken = csrfToken || this._getCSRFToken(); |
||||
|
return this; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Obtém o token CSRF do cookie |
||||
|
*/ |
||||
|
_getCSRFToken: function() { |
||||
|
var name = 'csrftoken'; |
||||
|
var cookieValue = null; |
||||
|
if (document.cookie && document.cookie !== '') { |
||||
|
var cookies = document.cookie.split(';'); |
||||
|
for (var i = 0; i < cookies.length; i++) { |
||||
|
var cookie = cookies[i].trim(); |
||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) { |
||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return cookieValue; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Gera URL substituindo {pk} pelo ID da matéria |
||||
|
*/ |
||||
|
_getUrl: function(urlKey) { |
||||
|
return this.config.urls[urlKey].replace('{pk}', this.config.materiaId); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Assina com certificado A1 |
||||
|
* @param {File} certificadoFile - Arquivo .pfx ou .p12 |
||||
|
* @param {string} senha - Senha do certificado |
||||
|
* @param {function} onProgress - Callback de progresso (opcional) |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
assinarComA1: function(certificadoFile, senha, onProgress) { |
||||
|
var self = this; |
||||
|
|
||||
|
return new Promise(function(resolve, reject) { |
||||
|
if (!certificadoFile) { |
||||
|
reject({ error: 'Arquivo do certificado não informado' }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!senha) { |
||||
|
reject({ error: 'Senha do certificado não informada' }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var formData = new FormData(); |
||||
|
formData.append('certificado', certificadoFile); |
||||
|
formData.append('senha', senha); |
||||
|
|
||||
|
var xhr = new XMLHttpRequest(); |
||||
|
xhr.open('POST', self._getUrl('assinarA1'), true); |
||||
|
xhr.setRequestHeader('X-CSRFToken', self.config.csrfToken); |
||||
|
|
||||
|
xhr.upload.onprogress = function(e) { |
||||
|
if (e.lengthComputable && onProgress) { |
||||
|
var percentComplete = (e.loaded / e.total) * 50; |
||||
|
onProgress(percentComplete, 'Enviando certificado...'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
xhr.onload = function() { |
||||
|
if (xhr.status >= 200 && xhr.status < 300) { |
||||
|
try { |
||||
|
var response = JSON.parse(xhr.responseText); |
||||
|
if (response.success) { |
||||
|
resolve(response); |
||||
|
} else { |
||||
|
reject(response); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
reject({ error: 'Erro ao processar resposta do servidor' }); |
||||
|
} |
||||
|
} else { |
||||
|
try { |
||||
|
var errorResponse = JSON.parse(xhr.responseText); |
||||
|
reject(errorResponse); |
||||
|
} catch (e) { |
||||
|
reject({ error: 'Erro na comunicação com o servidor' }); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
xhr.onerror = function() { |
||||
|
reject({ error: 'Erro de rede ao comunicar com o servidor' }); |
||||
|
}; |
||||
|
|
||||
|
xhr.send(formData); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Detecta aplicações de assinatura A3 rodando localmente |
||||
|
* @returns {Promise} - Resolve com informações da aplicação encontrada |
||||
|
*/ |
||||
|
detectarAplicacaoA3: function() { |
||||
|
var self = this; |
||||
|
var portas = this.config.portasA3; |
||||
|
|
||||
|
return new Promise(function(resolve, reject) { |
||||
|
var tentativas = 0; |
||||
|
var encontrado = false; |
||||
|
|
||||
|
portas.forEach(function(app) { |
||||
|
var url = app.protocolo + '://localhost:' + app.porta + '/'; |
||||
|
|
||||
|
// Tenta conectar usando fetch com timeout
|
||||
|
var controller = new AbortController(); |
||||
|
var timeoutId = setTimeout(function() { |
||||
|
controller.abort(); |
||||
|
}, 3000); |
||||
|
|
||||
|
fetch(url, { |
||||
|
method: 'GET', |
||||
|
mode: 'no-cors', |
||||
|
signal: controller.signal |
||||
|
}) |
||||
|
.then(function() { |
||||
|
clearTimeout(timeoutId); |
||||
|
if (!encontrado) { |
||||
|
encontrado = true; |
||||
|
resolve({ |
||||
|
success: true, |
||||
|
app: app, |
||||
|
message: 'Aplicação ' + app.nome + ' detectada na porta ' + app.porta |
||||
|
}); |
||||
|
} |
||||
|
}) |
||||
|
.catch(function() { |
||||
|
clearTimeout(timeoutId); |
||||
|
tentativas++; |
||||
|
if (tentativas >= portas.length && !encontrado) { |
||||
|
reject({ |
||||
|
success: false, |
||||
|
error: 'Nenhuma aplicação de assinatura A3 detectada' |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// Timeout global
|
||||
|
setTimeout(function() { |
||||
|
if (!encontrado) { |
||||
|
reject({ |
||||
|
success: false, |
||||
|
error: 'Timeout ao detectar aplicação A3' |
||||
|
}); |
||||
|
} |
||||
|
}, 5000); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Prepara a assinatura A3 obtendo o hash do PDF |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
prepararAssinaturaA3: function() { |
||||
|
var self = this; |
||||
|
|
||||
|
return fetch(this._getUrl('assinarA3Preparar'), { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'X-CSRFToken': this.config.csrfToken, |
||||
|
'Content-Type': 'application/json' |
||||
|
} |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(function(data) { |
||||
|
if (data.success) { |
||||
|
return data; |
||||
|
} else { |
||||
|
throw data; |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Finaliza a assinatura A3 enviando a assinatura do token |
||||
|
* @param {string} signatureBase64 - Assinatura em base64 |
||||
|
* @param {string} certificateBase64 - Certificado em base64 |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
finalizarAssinaturaA3: function(signatureBase64, certificateBase64) { |
||||
|
var self = this; |
||||
|
|
||||
|
return fetch(this._getUrl('assinarA3Finalizar'), { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'X-CSRFToken': this.config.csrfToken, |
||||
|
'Content-Type': 'application/json' |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
signature: signatureBase64, |
||||
|
certificate: certificateBase64 |
||||
|
}) |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
return response.json(); |
||||
|
}) |
||||
|
.then(function(data) { |
||||
|
if (data.success) { |
||||
|
return data; |
||||
|
} else { |
||||
|
throw data; |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Verifica a assinatura do PDF da matéria |
||||
|
* @returns {Promise} |
||||
|
*/ |
||||
|
verificarAssinatura: function() { |
||||
|
return fetch(this._getUrl('verificarAssinatura'), { |
||||
|
method: 'GET', |
||||
|
headers: { |
||||
|
'X-CSRFToken': this.config.csrfToken |
||||
|
} |
||||
|
}) |
||||
|
.then(function(response) { |
||||
|
return response.json(); |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Retorna a URL do PDF assinado |
||||
|
* @returns {string} |
||||
|
*/ |
||||
|
getUrlPdfAssinado: function() { |
||||
|
return this._getUrl('pdfAssinado'); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Baixa o PDF assinado |
||||
|
*/ |
||||
|
baixarPdfAssinado: function() { |
||||
|
window.open(this.getUrlPdfAssinado(), '_blank'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Expor globalmente
|
||||
|
global.AssinaturaMateria = AssinaturaMateria; |
||||
|
|
||||
|
})(typeof window !== 'undefined' ? window : this); |
||||
Loading…
Reference in new issue