diff --git a/.env.example b/.env.example index 1b37f5a..88e9981 100644 --- a/.env.example +++ b/.env.example @@ -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_EMAIL=operator@example.test +OPERATOR_USER=operator OPERATOR_PASSWORD=local-operator-only APP_ENV=local PAYMENT_ADAPTER=fake diff --git a/CONTEXT.md b/CONTEXT.md index b4a81da..0912d9f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -96,9 +96,6 @@ 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:** 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 VPS capacity may be used if it safely meets or exceeds this baseline. - **Backups:** PostgreSQL backups go to R2. Application secrets are never diff --git a/LOCAL_SETUP.md b/LOCAL_SETUP.md index e059f5f..74e0142 100644 --- a/LOCAL_SETUP.md +++ b/LOCAL_SETUP.md @@ -62,9 +62,8 @@ 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 and click its checkout action again. Inspect the - authoritative server total, then click **Criar pedido de teste**. No real - payment occurs. +6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative + server total, then click **Criar pedido pago local**. 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, diff --git a/deploy/portainer.env.example b/deploy/portainer.env.example index fb20fe4..ed9e542 100644 --- a/deploy/portainer.env.example +++ b/deploy/portainer.env.example @@ -24,7 +24,7 @@ POSTGRES_DB=dtf POSTGRES_USER=dtf_admin APP_DB_USER=dtf_app POSTGRES_VOLUME=TBD -OPERATOR_EMAIL=TBD +OPERATOR_USER=TBD PAYMENT_ADAPTER=TBD FREIGHT_ADAPTER=TBD diff --git a/docker-compose.yml b/docker-compose.yml index 0f06e31..bb4d54c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ 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_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL} + OPERATOR_USER: operator OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} PAYMENT_ADAPTER: fake FREIGHT_ADAPTER: fake diff --git a/dtf-site.html b/dtf-site.html index fda705a..0bea17d 100644 --- a/dtf-site.html +++ b/dtf-site.html @@ -1217,8 +1217,6 @@ footer a:hover{color:var(--laranja2)} -
-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.
@@ -2808,7 +2806,7 @@ $('bMais').addEventListener('click',()=>{ $('bPagar').addEventListener('click',()=>{ 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(); diff --git a/local/app.py b/local/app.py index 227c2d9..3dacc5f 100644 --- a/local/app.py +++ b/local/app.py @@ -49,9 +49,8 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS) @app.post('/api/operator/login') def operator_login(body: OperatorLogin, request: Request, response: Response): - email = body.email - throttle('operator:'+email, request) - valid_user = secrets.compare_digest(email.encode(), os.environ['OPERATOR_EMAIL'].strip().lower().encode()) + throttle('operator:'+body.username, request) + valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].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') @@ -61,10 +60,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(), email)) + (hashlib.sha256(token.encode()).hexdigest(), body.username)) response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE, samesite='strict', path='/api/operator', max_age=28800) - audit('operator_login_success', operator=email) + audit('operator_login_success', operator=body.username) return {'ok': True} @app.post('/api/operator/logout') diff --git a/local/browser_test.mjs b/local/browser_test.mjs index 22e1f09..0638166 100644 --- a/local/browser_test.mjs +++ b/local/browser_test.mjs @@ -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('#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.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('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 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 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 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`); diff --git a/local/models.py b/local/models.py index c950766..70dbace 100644 --- a/local/models.py +++ b/local/models.py @@ -88,17 +88,9 @@ class Login(StrictModel): password: str = Field(min_length=1, max_length=128) 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) - @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) diff --git a/local/security_test.py b/local/security_test.py index cec1920..fe7936d 100644 --- a/local/security_test.py +++ b/local/security_test.py @@ -28,8 +28,8 @@ def run(): print('PASS: CSP, frame protection, Host and cross-origin rejection') operator=Client() - 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() + 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() 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();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) + 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) print('PASS: operator login throttling (only synthetic account bucket exhausted)') if __name__=='__main__':run() diff --git a/local/smoke_test.py b/local/smoke_test.py index fbc4eef..d5a4e01 100644 --- a/local/smoke_test.py +++ b/local/smoke_test.py @@ -30,7 +30,7 @@ class Client: if operator: if self.operator_client is None: 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) request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers) try: diff --git a/local/static/checkout.js b/local/static/checkout.js index a9cacf4..81e4907 100644 --- a/local/static/checkout.js +++ b/local/static/checkout.js @@ -1,7 +1,11 @@ /* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */ (() => { - const status = document.getElementById('checkoutStatus'); - const actions = document.getElementById('checkoutActions'); + 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 = 'Ambiente local · pagamento simulado '; + document.body.prepend(box); + const status = document.getElementById('local-status'); + const actions = document.getElementById('local-actions'); let busy = false; let draftId = localStorage.getItem('dtf-quote'); let requestKey = localStorage.getItem('dtf-request-key'); @@ -11,10 +15,7 @@ credentials: 'same-origin', headers: {'Content-Type':'application/json'}, ...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)}) }); - 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.'); } + const data = await response.json(); 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; }; @@ -22,7 +23,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; @@ -42,13 +43,13 @@ if (entrega.cep !== cep || entrega.tipo !== 'frete') return; entrega.valor = result.total_cents/100; entrega.cotado = true; - $('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.'; + $('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.'; pintaEntrega(); } catch(error) { $('cepMsg').textContent = error.message; } }; window.dtfCheckout = async () => { 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; const cart = [...pedido,...(itemAtual?[itemAtual]:[])]; if (!cart.length) return message('Adicione um item ao pedido.'); @@ -74,7 +75,7 @@ const quote = await api('/quotes',{request_key:requestKey,...content}); draftId=quote.id; localStorage.setItem('dtf-quote',draftId); await refresh(); - status.scrollIntoView({behavior:'smooth',block:'nearest'}); + box.scrollIntoView({behavior:'smooth'}); } catch(error) { message(error.message); } 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)+'.'); } 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.'); - 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=>{ + button('Criar pedido pago local',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 #'+order.number+' criado e disponível no Kanban.'); + message('Pedido local #'+order.number+' pago e disponível no Kanban.'); await refresh(); } catch(error) { message(error.message); event.target.disabled=false; } }); } else if (quote.status==='paid') { - 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();}); + 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();}); } 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();}); @@ -112,10 +109,17 @@ } catch(error) { message(error.message); 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. + 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(); diff --git a/local/static/kanban.html b/local/static/kanban.html index c1ed60c..f7a11cf 100644 --- a/local/static/kanban.html +++ b/local/static/kanban.html @@ -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)} -