feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny refuses the scope. Renewal failures are stored: a refused refresh token marks the connection lost and is not sent again (the Kanban previously still said "conectado"), a transient failure shows as a warning until the next renewal, and a session grant with under 12 hours left is flagged. Tiny errors on the callback return to the Kanban instead of a 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -316,12 +316,21 @@ def tiny_test(user=Depends(operator)):
|
||||
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
||||
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
|
||||
error: str = Query('', max_length=128)):
|
||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||
single-use state an operator created is what authorises it."""
|
||||
single-use state an operator created is what authorises it. Tiny reports a
|
||||
refusal (the operator declined, or offline access is not allowed for this
|
||||
application) with `error` instead of a code."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
if error == 'invalid_scope':
|
||||
retry = tiny.TinyAuth().without_offline(state)
|
||||
audit('tiny_offline_refused')
|
||||
return RedirectResponse(retry, status_code=303)
|
||||
if error or not code:
|
||||
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
|
||||
who = tiny.TinyAuth().complete(code, state)
|
||||
except tiny.TinyError:
|
||||
audit('tiny_connect_failed')
|
||||
|
||||
@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
||||
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Renewal health, cleared by the next successful renewal or connection.
|
||||
-- refused_at: the provider rejected the refresh token, so only a new
|
||||
-- connection helps. offline: the grant is not bound to a login session.
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
|
||||
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||
-- callback, which arrives cross-site without the operator's cookie.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||
|
||||
98
app/tiny.py
98
app/tiny.py
@@ -42,6 +42,9 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
# How far back to look for an order a previous delivery may already have made.
|
||||
SEARCH_DAYS = 7
|
||||
STATE_MINUTES = 10
|
||||
# Renewal runs about every four hours against a one-day refresh token, so
|
||||
# less than this left means renewals have been failing for hours.
|
||||
EXPIRY_WARNING = timedelta(hours=12)
|
||||
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
|
||||
@@ -86,44 +89,78 @@ class TinyAuth:
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
self.clock = clock
|
||||
|
||||
def authorize_url(self, operator):
|
||||
def authorize_url(self, operator, offline=True):
|
||||
"""Start a connection. The state is single-use, short-lived, and only an
|
||||
authenticated operator can create one, which is what protects the
|
||||
callback: Tiny's redirect back is cross-site, so the operator's
|
||||
SameSite=Strict cookie does not travel with it."""
|
||||
SameSite=Strict cookie does not travel with it.
|
||||
|
||||
offline_access asks for a grant that is not bound to a login session,
|
||||
so the connection lasts as long as it keeps being renewed. Tiny's
|
||||
documented refresh token otherwise lasts one day."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
with self.connect() as c:
|
||||
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
||||
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
|
||||
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
|
||||
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'openid offline_access' if offline else 'openid',
|
||||
'state': state})
|
||||
|
||||
def _claim(self, c, state):
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
return row['operator']
|
||||
|
||||
def complete(self, code, state):
|
||||
"""Exchange the authorisation code; returns the operator who started it."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
operator = self._claim(c, state)
|
||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': self.redirect_uri})
|
||||
self._store(c, tokens, row['operator'])
|
||||
return row['operator']
|
||||
self._store(c, tokens, operator)
|
||||
return operator
|
||||
|
||||
def without_offline(self, state):
|
||||
"""Tiny refused the offline_access scope for this application: spend the
|
||||
operator's state and start again with a session-bound grant."""
|
||||
with self.connect() as c:
|
||||
operator = self._claim(c, state)
|
||||
return self.authorize_url(operator, offline=False)
|
||||
|
||||
def status(self):
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
||||
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
|
||||
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
if not row:
|
||||
return {'connected': False}
|
||||
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
||||
return {'connected': not expired, 'connected_by': row['connected_by'],
|
||||
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = row['refresh_expires_at']
|
||||
expired = bool(expires and expires <= now)
|
||||
if row['refused_at']:
|
||||
problem = 'refused'
|
||||
elif expired:
|
||||
problem = 'expired'
|
||||
elif row['refresh_failed_at']:
|
||||
problem = 'renewal-failing'
|
||||
elif expires and expires - now < EXPIRY_WARNING:
|
||||
problem = 'expiring'
|
||||
else:
|
||||
problem = None
|
||||
return {'connected': not expired and not row['refused_at'], 'problem': problem,
|
||||
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
|
||||
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
|
||||
'failed_at': row['refresh_failed_at']}
|
||||
|
||||
def access_token(self):
|
||||
"""A valid access token, refreshing (and rotating) under a row lock."""
|
||||
"""A valid access token, refreshing (and rotating) under a row lock.
|
||||
|
||||
A failed renewal is recorded after the lock is released, so the Kanban
|
||||
can show it. A refused refresh token is never tried again: only a new
|
||||
connection helps, and retrying it would only repeat the refusal."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||
FOR UPDATE''').fetchone()
|
||||
@@ -132,11 +169,25 @@ class TinyAuth:
|
||||
now = datetime.now(timezone.utc)
|
||||
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||
return row['access_token']
|
||||
if row['refused_at']:
|
||||
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
try:
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
except (TinyError, httpx.HTTPError, ValueError) as exc:
|
||||
failure = exc
|
||||
else:
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
# Only against the token that failed: another worker may have renewed since.
|
||||
with self.connect() as c:
|
||||
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
|
||||
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
|
||||
WHERE provider='tiny' AND refresh_token=%s''',
|
||||
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
|
||||
row['refresh_token']))
|
||||
raise failure
|
||||
|
||||
def _token(self, form):
|
||||
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||
@@ -152,18 +203,21 @@ class TinyAuth:
|
||||
def _store(self, c, tokens, operator, refreshed=False):
|
||||
now = datetime.now(timezone.utc)
|
||||
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
||||
# An offline grant reports refresh_expires_in 0: no fixed end.
|
||||
refresh_in = tokens.get('refresh_expires_in')
|
||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||
offline = 'offline_access' in str(tokens.get('scope') or '').split()
|
||||
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
|
||||
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
|
||||
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
|
||||
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
|
||||
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
|
||||
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||
operator, refreshed, refreshed))
|
||||
operator, offline, refreshed, refreshed))
|
||||
|
||||
|
||||
# Orders ------------------------------------------------------------------
|
||||
|
||||
@@ -278,6 +278,17 @@ From the report already sent. These are dated promises, not backlog.
|
||||
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
|
||||
guard by search first and only then by a second delivery. Not yet run
|
||||
against the client's account.
|
||||
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
|
||||
a 1-day refresh token; the worker renews about every 4 hours. The connection
|
||||
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
|
||||
application the callback retries once without it). Renewal failures are
|
||||
stored: a refused refresh token marks the connection lost and is not retried,
|
||||
a transient failure shows as a warning until the next success, and a
|
||||
session grant with under 12 hours left is flagged. Previously a refused
|
||||
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
|
||||
and whether a session grant has an undocumented maximum, is only known on
|
||||
the client's account. There is no alert channel yet (no e-mail; WhatsApp
|
||||
is fake): problems show on the Kanban only.
|
||||
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
must survive checkout before an output engine can reproduce the approved job).
|
||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||
|
||||
@@ -33,8 +33,12 @@ def run():
|
||||
|
||||
def exercise():
|
||||
issued = []
|
||||
server = {'mode': 'session', 'calls': 0}
|
||||
|
||||
def token_server(request):
|
||||
server['calls'] += 1
|
||||
if server['mode'] == 'down':
|
||||
return httpx.Response(503, text='unavailable')
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||
if form['grant_type'] == 'authorization_code':
|
||||
@@ -44,8 +48,13 @@ def exercise():
|
||||
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||
n = len(issued) + 1
|
||||
issued.append(f'refresh-{n}')
|
||||
if server['mode'] == 'offline':
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
|
||||
'scope': 'openid offline_access profile'})
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
|
||||
'scope': 'openid profile'})
|
||||
|
||||
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||
assert auth.status() == {'connected': False}
|
||||
@@ -59,6 +68,7 @@ def exercise():
|
||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
|
||||
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||
assert query['scope'] == 'openid offline_access'
|
||||
try:
|
||||
auth.complete('good-code', 'forged-state')
|
||||
raise AssertionError('a state no operator created must be refused')
|
||||
@@ -72,6 +82,7 @@ def exercise():
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
||||
assert status['problem'] is None and not status['offline'] and status['expires_at']
|
||||
assert auth.access_token() == 'access-1'
|
||||
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||
|
||||
@@ -85,6 +96,23 @@ def exercise():
|
||||
assert auth.access_token() == 'access-2'
|
||||
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
||||
|
||||
# Tiny unreachable: the failure is shown, the connection kept, and the next
|
||||
# renewal clears it.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
server['mode'] = 'down'
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('an unreachable token server must fail the renewal')
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
|
||||
server['mode'] = 'session'
|
||||
assert auth.access_token() == 'access-3'
|
||||
assert auth.status()['problem'] is None
|
||||
print('PASS: a failed renewal is shown and cleared by the next successful one')
|
||||
|
||||
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||
with connect() as c:
|
||||
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||
@@ -94,12 +122,63 @@ def exercise():
|
||||
raise AssertionError('a refused refresh must report the connection as lost')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert not status['connected'] and status['problem'] == 'refused'
|
||||
calls = server['calls']
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('a refused connection must stay refused')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||
assert not auth.status()['connected']
|
||||
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
||||
|
||||
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
|
||||
server['mode'] = 'offline'
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
auth.complete('good-code', state)
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['offline'] and status['expires_at'] is None
|
||||
assert status['problem'] is None
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
auth.access_token()
|
||||
assert auth.status()['offline'] and auth.status()['expires_at'] is None
|
||||
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
|
||||
|
||||
# Tiny refusing offline_access restarts the authorisation without it, once.
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
retry = parse_qs(urlparse(auth.without_offline(state)).query)
|
||||
assert retry['scope'] == ['openid'] and retry['state'][0] != state
|
||||
try:
|
||||
auth.without_offline(state)
|
||||
raise AssertionError('the refused state must be spent')
|
||||
except TinyError:
|
||||
pass
|
||||
# A session grant close to its end is flagged while renewals are not happening.
|
||||
server['mode'] = 'session'
|
||||
auth.complete('good-code', retry['state'][0])
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
|
||||
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
|
||||
|
||||
# Tiny's redirect back with an error instead of a code.
|
||||
from app.api.operator import tiny_callback
|
||||
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
|
||||
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
|
||||
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
|
||||
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
|
||||
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
|
||||
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
@@ -483,8 +483,21 @@ if(tinyResult)history.replaceState(null,'',location.pathname);
|
||||
function tinyHeader(){
|
||||
const t=board.tiny||{};const el=$('tiny');
|
||||
if(!t.configured){el.textContent='';return;}
|
||||
el.textContent=t.connected?'Tiny conectado':'Tiny não conectado';
|
||||
el.style.setProperty('--dot',t.connected?'var(--fin)':'var(--warn)');
|
||||
const trouble=!t.connected||t.problem;
|
||||
el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado';
|
||||
el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)');
|
||||
}
|
||||
const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.',
|
||||
expired:'A conexão com o Tiny expirou. Conecte de novo.',
|
||||
'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.',
|
||||
expiring:'A conexão expira em breve e não está sendo renovada.'};
|
||||
function tinyDetail(t){
|
||||
if(!t.connected_by)return 'Conecte com uma conta do Tiny.';
|
||||
const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at),
|
||||
t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'',
|
||||
'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean);
|
||||
const problem=TINY_PROBLEMS[t.problem];
|
||||
return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · ');
|
||||
}
|
||||
function integration(name,state,tone,text,actions=[]){
|
||||
const box=node('div',undefined,'integration');
|
||||
@@ -633,9 +646,8 @@ function renderIntegrations(){
|
||||
if(t.connected){
|
||||
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
|
||||
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
|
||||
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
|
||||
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
|
||||
'Conecte com uma conta do Tiny.',actions));
|
||||
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
|
||||
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
|
||||
}
|
||||
const mp=board.providers?.payment;
|
||||
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
|
||||
|
||||
Reference in New Issue
Block a user