feat: configure Kanban login with optional email
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Publish images and notify Portainer (push) Successful in 56s

This commit is contained in:
Cauê Faleiros
2026-09-18 14:11:55 -03:00
parent d4190ebfeb
commit 4d707009ba
14 changed files with 65 additions and 49 deletions

View File

@@ -14,7 +14,7 @@ S3_APP_USER=dtf_app
S3_APP_PASSWORD=local-app-storage-only
S3_BUCKET=dtf-local-artwork
S3_PUBLIC_ENDPOINT=http://localhost:9000
OPERATOR_USER=operator
OPERATOR_EMAIL=operator@example.test
OPERATOR_PASSWORD=local-operator-only
APP_ENV=local
PAYMENT_ADAPTER=fake

View File

@@ -96,6 +96,9 @@ resumes when local access returns.
Actions workflow tests/scans, publishes `latest` plus commit-SHA application
images, and calls the Portainer webhook. Activation remains blocked pending
the production work listed below.
- **Kanban access:** configure `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` as
stack environment variables. If the email is absent, only Kanban login is
blocked; the rest of the stack remains available.
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing
VPS capacity may be used if it safely meets or exceeds this baseline.
- **Backups:** PostgreSQL backups go to R2. Application secrets are never

View File

@@ -62,8 +62,9 @@ operator interfaces.
5. Open Kanban and log in. In **Cotações**, download the original if needed,
confirm/correct total metres and grade, tick the manual confirmation, and
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative
server total, then click **Criar pedido pago local**. No real payment occurs.
6. Return to the Site and click its checkout action again. Inspect the
authoritative server total, then click **Criar pedido de teste**. No real
payment occurs.
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
select the manually prepared final files for every item, enter a review note,

View File

@@ -24,7 +24,7 @@ POSTGRES_DB=dtf
POSTGRES_USER=dtf_admin
APP_DB_USER=dtf_app
POSTGRES_VOLUME=TBD
OPERATOR_USER=TBD
OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD
FREIGHT_ADAPTER=TBD

View File

@@ -12,7 +12,10 @@ x-app-environment: &app-environment
AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION: auto
OPERATOR_USER: operator
# Optional at deploy time so a missing Kanban credential cannot make the
# entire Swarm stack invalid. The Kanban login endpoint fails closed until
# this value is configured.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake
FREIGHT_ADAPTER: fake

View File

@@ -1217,6 +1217,8 @@ footer a:hover{color:var(--laranja2)}
<button id="bPagar">Ir para o pagamento</button>
<button id="bMais" class="sec">Adicionar outro produto</button>
</div>
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 90 dias e o histórico
do pedido por 12 meses, para você repetir sem subir de novo.</p>
</aside>
@@ -2806,7 +2808,7 @@ $('bMais').addEventListener('click',()=>{
$('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout();
else alert('Abra o Site pela stack local para criar um pedido de teste.');
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
});
pintaEntrega();
</script>

View File

@@ -49,8 +49,12 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
@app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
throttle('operator:'+body.username, request)
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode())
configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
if not configured_email:
raise HTTPException(503, 'Kanban operator email is not configured')
email = body.email
throttle('operator:'+email, request)
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed')
@@ -60,10 +64,10 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), body.username))
(hashlib.sha256(token.encode()).hexdigest(), email))
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=body.username)
audit('operator_login_success', operator=email)
return {'ok': True}
@app.post('/api/operator/logout')

View File

@@ -77,7 +77,7 @@ try{
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
@@ -85,10 +85,10 @@ try{
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
await site.eval('window.dtfCheckout()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
await waitFor(async()=> (await site.text()).includes('Pedido #'),'test payment');
await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);

View File

@@ -88,9 +88,17 @@ class Login(StrictModel):
password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel):
username: str = Field(min_length=1, max_length=100)
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def operator_email_valid(cls, value):
value = value.strip().lower()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
raise ValueError('Invalid email')
return value
class FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)

View File

@@ -28,8 +28,8 @@ def run():
print('PASS: CSP, frame protection, Host and cross-origin rejection')
operator=Client()
credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
credentials={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['email']+':'+credentials['password']).encode()).decode()
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
operator.call('/operator/login',credentials)
token=next(c for c in operator.jar if c.name=='dtf_operator')
@@ -58,9 +58,9 @@ def run():
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
# Unique identity avoids locking out the real local operator.
attacker=Client();username='test-'+uuid4().hex
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
attacker=Client();email='test-'+uuid4().hex+'@example.test'
for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429)
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
if __name__=='__main__':run()

View File

@@ -30,7 +30,7 @@ class Client:
if operator:
if self.operator_client is None:
self.operator_client=Client()
self.operator_client.call('/operator/login',{'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
try:

View File

@@ -1,11 +1,7 @@
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
(() => {
const box = document.createElement('section');
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
document.body.prepend(box);
const status = document.getElementById('local-status');
const actions = document.getElementById('local-actions');
const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions');
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
@@ -15,7 +11,10 @@
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
});
const data = await response.json();
const text = await response.text();
let data;
try { data = text ? JSON.parse(text) : {}; }
catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); }
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
return data;
};
@@ -23,7 +22,7 @@
window.dtfSessionReady=ready;
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = ' · '+text; }
function message(text) { status.textContent = text; }
function button(label, handler) {
const el = document.createElement('button');
el.textContent = label;
@@ -43,13 +42,13 @@
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100;
entrega.cotado = true;
$('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; }
};
window.dtfCheckout = async () => {
if (busy) return;
if (draftId) { await refresh(); box.scrollIntoView(); return; }
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if (!clienteOk() || !entrega.cotado) return;
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
@@ -75,7 +74,7 @@
const quote = await api('/quotes',{request_key:requestKey,...content});
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh();
box.scrollIntoView({behavior:'smooth'});
status.scrollIntoView({behavior:'smooth',block:'nearest'});
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
@@ -89,19 +88,23 @@
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
button('Criar pedido pago local',async event=>{
if ((await ready).environment !== 'local') {
message('Cotação revisada. O pagamento online ainda não está disponível.');
return;
}
button('Criar pedido de teste',async event=>{
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
message('Pedido local #'+order.number+' pago e disponível no Kanban.');
message('Pedido #'+order.number+' criado e disponível no Kanban.');
await refresh();
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else if (quote.status==='paid') {
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
button('Novo pedido',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
@@ -109,17 +112,9 @@
} catch(error) {
message(error.message);
actions.replaceChildren();
button('Limpar referência local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
button('Limpar referência e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
}
}
// Explicit refresh avoids replacing focused payment controls during interaction.
const refreshButton = document.createElement('button');
refreshButton.textContent='Atualizar pedido local';
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
refreshButton.onclick=refresh;
box.append(refreshButton);
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
refresh();

View File

@@ -12,9 +12,9 @@ label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:gri
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
</style></head><body>
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<p id="status" role="status"></p>
<section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>

View File

@@ -73,7 +73,7 @@ async function move(order,state){
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{username:$('user').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
$('refresh').onclick=load;
load();