Revert "feat: configure Kanban login by operator email"
All checks were successful
Build and deploy / Validate source (push) Successful in 12s
Build and deploy / Publish images and notify Portainer (push) Successful in 54s

This reverts commit 508fa03664.
This commit is contained in:
Cauê Faleiros
2026-09-18 13:45:08 -03:00
parent 508fa03664
commit d4190ebfeb
14 changed files with 48 additions and 59 deletions

View File

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

View File

@@ -96,9 +96,6 @@ resumes when local access returns.
Actions workflow tests/scans, publishes `latest` plus commit-SHA application Actions workflow tests/scans, publishes `latest` plus commit-SHA application
images, and calls the Portainer webhook. Activation remains blocked pending images, and calls the Portainer webhook. Activation remains blocked pending
the production work listed below. the production work listed below.
- **Kanban access:** the single operator credential is configured only through
`OPERATOR_EMAIL` and `OPERATOR_PASSWORD` stack environment variables; it is
not stored in browser storage or source code.
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing - **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. VPS capacity may be used if it safely meets or exceeds this baseline.
- **Backups:** PostgreSQL backups go to R2. Application secrets are never - **Backups:** PostgreSQL backups go to R2. Application secrets are never

View File

@@ -62,9 +62,8 @@ operator interfaces.
5. Open Kanban and log in. In **Cotações**, download the original if needed, 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 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. click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
6. Return to the Site and click its checkout action again. Inspect the 6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative
authoritative server total, then click **Criar pedido de teste**. No real server total, then click **Criar pedido pago local**. No real payment occurs.
payment occurs.
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using 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**, 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, 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 POSTGRES_USER=dtf_admin
APP_DB_USER=dtf_app APP_DB_USER=dtf_app
POSTGRES_VOLUME=TBD POSTGRES_VOLUME=TBD
OPERATOR_EMAIL=TBD OPERATOR_USER=TBD
PAYMENT_ADAPTER=TBD PAYMENT_ADAPTER=TBD
FREIGHT_ADAPTER=TBD FREIGHT_ADAPTER=TBD

View File

@@ -12,7 +12,7 @@ x-app-environment: &app-environment
AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID} 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_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION: auto AWS_DEFAULT_REGION: auto
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL} OPERATOR_USER: operator
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake PAYMENT_ADAPTER: fake
FREIGHT_ADAPTER: fake FREIGHT_ADAPTER: fake

View File

@@ -1217,8 +1217,6 @@ footer a:hover{color:var(--laranja2)}
<button id="bPagar">Ir para o pagamento</button> <button id="bPagar">Ir para o pagamento</button>
<button id="bMais" class="sec">Adicionar outro produto</button> <button id="bMais" class="sec">Adicionar outro produto</button>
</div> </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 <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> do pedido por 12 meses, para você repetir sem subir de novo.</p>
</aside> </aside>
@@ -2808,7 +2806,7 @@ $('bMais').addEventListener('click',()=>{
$('bPagar').addEventListener('click',()=>{ $('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout(); if(window.dtfCheckout) window.dtfCheckout();
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.'); else alert('Abra o Site pela stack local para criar um pedido de teste.');
}); });
pintaEntrega(); pintaEntrega();
</script> </script>

View File

@@ -49,9 +49,8 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
@app.post('/api/operator/login') @app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response): def operator_login(body: OperatorLogin, request: Request, response: Response):
email = body.email throttle('operator:'+body.username, request)
throttle('operator:'+email, request) valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode())
valid_user = secrets.compare_digest(email.encode(), os.environ['OPERATOR_EMAIL'].strip().lower().encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode()) valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password): if not (valid_user and valid_password):
audit('operator_login_failed') audit('operator_login_failed')
@@ -61,10 +60,10 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() 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('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)', c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), email)) (hashlib.sha256(token.encode()).hexdigest(), body.username))
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE, response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
samesite='strict', path='/api/operator', max_age=28800) samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=email) audit('operator_login_success', operator=body.username)
return {'ok': True} return {'ok': True}
@app.post('/api/operator/logout') @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); await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
const qid=await site.eval('localStorage.getItem("dtf-quote")'); const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081)); const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test'); await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()'); await kanban.eval('document.getElementById("login").requestSubmit()');
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban'); 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'),''); 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 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 waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
await site.eval('window.dtfCheckout()'); await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed'); 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 de teste").click()'); await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
await waitFor(async()=> (await site.text()).includes('Pedido #'),'test payment'); await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
await kanban.click('#refresh'); await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card'); 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`); const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);

View File

@@ -88,17 +88,9 @@ class Login(StrictModel):
password: str = Field(min_length=1, max_length=128) password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel): class OperatorLogin(StrictModel):
email: str = Field(min_length=3, max_length=254) username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=128) 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): class FileReference(StrictModel):
upload_id: UUID upload_id: UUID
item_index: int = Field(ge=0, strict=True) 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') print('PASS: CSP, frame protection, Host and cross-origin rejection')
operator=Client() operator=Client()
credentials={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')} credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['email']+':'+credentials['password']).encode()).decode() encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded}) raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
operator.call('/operator/login',credentials) operator.call('/operator/login',credentials)
token=next(c for c in operator.jar if c.name=='dtf_operator') 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') print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
# Unique identity avoids locking out the real local operator. # Unique identity avoids locking out the real local operator.
attacker=Client();email='test-'+uuid4().hex+'@example.test' attacker=Client();username='test-'+uuid4().hex
for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401) for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429) attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
print('PASS: operator login throttling (only synthetic account bucket exhausted)') print('PASS: operator login throttling (only synthetic account bucket exhausted)')
if __name__=='__main__':run() if __name__=='__main__':run()

View File

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

View File

@@ -1,7 +1,11 @@
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */ /* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
(() => { (() => {
const status = document.getElementById('checkoutStatus'); const box = document.createElement('section');
const actions = document.getElementById('checkoutActions'); 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');
let busy = false; let busy = false;
let draftId = localStorage.getItem('dtf-quote'); let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key'); let requestKey = localStorage.getItem('dtf-request-key');
@@ -11,10 +15,7 @@
credentials: 'same-origin', headers: {'Content-Type':'application/json'}, credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)}) ...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
}); });
const text = await response.text(); const data = await response.json();
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; } 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; return data;
}; };
@@ -22,7 +23,7 @@
window.dtfSessionReady=ready; window.dtfSessionReady=ready;
window.dtfApi=api; window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; }); ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = text; } function message(text) { status.textContent = ' · '+text; }
function button(label, handler) { function button(label, handler) {
const el = document.createElement('button'); const el = document.createElement('button');
el.textContent = label; el.textContent = label;
@@ -42,13 +43,13 @@
if (entrega.cep !== cep || entrega.tipo !== 'frete') return; if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100; entrega.valor = result.total_cents/100;
entrega.cotado = true; entrega.cotado = true;
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.'; $('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
pintaEntrega(); pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; } } catch(error) { $('cepMsg').textContent = error.message; }
}; };
window.dtfCheckout = async () => { window.dtfCheckout = async () => {
if (busy) return; if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; } if (draftId) { await refresh(); box.scrollIntoView(); return; }
if (!clienteOk() || !entrega.cotado) return; if (!clienteOk() || !entrega.cotado) return;
const cart = [...pedido,...(itemAtual?[itemAtual]:[])]; const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.'); if (!cart.length) return message('Adicione um item ao pedido.');
@@ -74,7 +75,7 @@
const quote = await api('/quotes',{request_key:requestKey,...content}); const quote = await api('/quotes',{request_key:requestKey,...content});
draftId=quote.id; localStorage.setItem('dtf-quote',draftId); draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh(); await refresh();
status.scrollIntoView({behavior:'smooth',block:'nearest'}); box.scrollIntoView({behavior:'smooth'});
} catch(error) { message(error.message); } } catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); } finally { busy=false; pintaEntrega(); }
}; };
@@ -88,23 +89,19 @@
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'); message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') { } 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.'); message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).environment !== 'local') { button('Criar pedido pago local',async event=>{
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; event.target.disabled=true;
try { try {
const order=await api('/orders/dev-paid',{quote_id:draftId}); const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis(); pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.(); await window.dtfClearCart?.();
message('Pedido #'+order.number+' criado e disponível no Kanban.'); message('Pedido local #'+order.number+' pago e disponível no Kanban.');
await refresh(); await refresh();
} catch(error) { message(error.message); event.target.disabled=false; } } catch(error) { message(error.message); event.target.disabled=false; }
}); });
} else if (quote.status==='paid') { } else if (quote.status==='paid') {
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.'); message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
button('Novo pedido',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();}); button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
} else { } else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.'); 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();}); button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
@@ -112,10 +109,17 @@
} catch(error) { } catch(error) {
message(error.message); message(error.message);
actions.replaceChildren(); 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();}); button('Limpar referência local 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. // 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'); const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);} if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
refresh(); 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)} .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)} .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> </style></head><body>
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header> <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">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> <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>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> <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>
<p id="status" role="status"></p> <p id="status" role="status"></p>
<section id="reviews"></section><div id="kan"></div> <section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details> <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;} 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(); await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
} }
$('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='';}}; $('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='';}};
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();}; $('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; $('refresh').onclick=load;
load(); load();