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_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_USER=operator OPERATOR_EMAIL=operator@example.test
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,6 +96,9 @@ 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:** 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 - **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,8 +62,9 @@ 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, click **Atualizar pedido local**, inspect the authoritative 6. Return to the Site and click its checkout action again. Inspect the
server total, then click **Criar pedido pago local**. No real payment occurs. 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 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_USER=TBD OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD PAYMENT_ADAPTER=TBD
FREIGHT_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_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_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} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake PAYMENT_ADAPTER: fake
FREIGHT_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="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>
@@ -2806,7 +2808,7 @@ $('bMais').addEventListener('click',()=>{
$('bPagar').addEventListener('click',()=>{ $('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout(); 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(); pintaEntrega();
</script> </script>

View File

@@ -49,8 +49,12 @@ 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):
throttle('operator:'+body.username, request) configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode()) 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()) 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')
@@ -60,10 +64,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(), body.username)) (hashlib.sha256(token.encode()).hexdigest(), email))
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=body.username) audit('operator_login_success', operator=email)
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('#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.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('[...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 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 site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment'); await waitFor(async()=> (await site.text()).includes('Pedido #'),'test 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,9 +88,17 @@ 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):
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) 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={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')} credentials={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode() encoded=base64.b64encode((credentials['email']+':'+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();username='test-'+uuid4().hex attacker=Client();email='test-'+uuid4().hex+'@example.test'
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401) for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429) attacker.call('/operator/login',{'email':email,'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',{'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) 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,11 +1,7 @@
/* 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 box = document.createElement('section'); const status = document.getElementById('checkoutStatus');
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da'; const actions = document.getElementById('checkoutActions');
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');
@@ -15,7 +11,10 @@
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 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; } 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;
}; };
@@ -23,7 +22,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;
@@ -43,13 +42,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 simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.'; $('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
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(); box.scrollIntoView(); return; } if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); 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.');
@@ -75,7 +74,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();
box.scrollIntoView({behavior:'smooth'}); status.scrollIntoView({behavior:'smooth',block:'nearest'});
} catch(error) { message(error.message); } } catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); } 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)+'.'); 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.');
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; 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 local #'+order.number+' pago e disponível no Kanban.'); message('Pedido #'+order.number+' criado 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 local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.'); message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();}); button('Novo pedido',()=>{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();});
@@ -109,17 +112,9 @@
} catch(error) { } catch(error) {
message(error.message); message(error.message);
actions.replaceChildren(); 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'); 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><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header> <header><h1>Kanban DTF</h1><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> <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>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> <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> <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',{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();}; $('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();