feat: configure Kanban login with optional email
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
local/app.py
12
local/app.py
@@ -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')
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user