fix(auth): login Google funciona em apps Capacitor (redirect+polling) v1.6.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Problema: Google Sign-In popup (GSI) não funciona em WebView nativo do
Capacitor. FedCM bloqueia, popup não abre, ou retorna erro silenciosamente.

Solução: detectar Capacitor (window.Capacitor) ou WebView (UA com 'wv') e
usar OAuth redirect tradicional + polling em vez do popup GSI.

Backend (server/src/index.js):
- GET /api/auth/google/start — gera URL OAuth com state contendo
  session_id + flow:'login'. App chama isso e abre URL no browser externo.
- /api/google/callback adaptado — quando state.flow=='login', cria/loga
  user por email do Google, gera JWT, armazena em pendingGoogleSessions
  (Map em memória, TTL 10min) por session_id, mostra HTML "logado, volte
  pro app".
- GET /api/auth/google/poll?session=xxx — app faz polling 2s. Retorna
  204 se ainda esperando, 200 com tokens (one-shot, deleta após).

Frontend (app/diario-bordo.html):
- Detecta Capacitor/WebView, força fluxo redirect+polling
- Browser web: tenta GSI popup primeiro, fallback redirect se prompt
  for bloqueado (FedCM/popup blocker)
- window.open abre Custom Tabs no Android (ou nova aba no PC)
- Timeout de 4min (120 tries × 2s) pro polling

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 08:20:39 -03:00
parent b48afaa84f
commit 24f6df3da7
5 changed files with 200 additions and 17 deletions

View file

@ -3888,17 +3888,73 @@ function initGoogleSignIn(){
window._gsiInited=true;
}catch(e){console.warn('[gsi] init failed',e)}
}
function isInWebViewApp(){
// Detecta Capacitor ou WebView (Android wv) — onde GSI popup não funciona bem
if(window.Capacitor)return true;
const ua=navigator.userAgent;
if(/wv|; ?Version\//.test(ua)&&/Android/.test(ua))return true;
return false;
}
function welcomeGoogleClick(){
// Em apps Capacitor/WebView, GSI popup não funciona — usar redirect + polling
if(isInWebViewApp()){
return startGoogleRedirectFlow();
}
// Browser web: tenta GSI popup
if(!window.google?.accounts?.id){
toast('Google Sign-In ainda carregando, aguarde 2s');
return;
toast('Google Sign-In carregando, tentando alternativa...');
return startGoogleRedirectFlow();
}
initGoogleSignIn();
try{
window.google.accounts.id.prompt(()=>{});
// Fallback: render botão visível ao lado caso prompt seja bloqueado
window.google.accounts.id.prompt((notif)=>{
// Se prompt foi bloqueado (FedCM, popup blocker etc), fallback pro redirect
if(notif.isNotDisplayed?.()||notif.isSkippedMoment?.()){
startGoogleRedirectFlow();
}
});
}catch(e){
console.warn('[gsi] prompt failed',e);
startGoogleRedirectFlow();
}
}
let _googleAuthPolling=null;
async function startGoogleRedirectFlow(){
toast('Abrindo Google...');
try{
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
const r=await fetch(cloudUrl('/api/auth/google/start'));
if(!r.ok)throw new Error('HTTP '+r.status);
const{url,session}=await r.json();
if(!url||!session)throw new Error('servidor sem URL/session');
// Abre URL no browser EXTERNO (em Capacitor isso usa Custom Tabs)
if(window.open){window.open(url,'_blank','noopener')}else{location.href=url}
// Inicia polling
if(_googleAuthPolling)clearInterval(_googleAuthPolling);
let tries=0;
_googleAuthPolling=setInterval(async()=>{
tries++;
if(tries>120){clearInterval(_googleAuthPolling);toast('Tempo esgotado. Tente de novo.');return}
try{
const pr=await fetch(cloudUrl('/api/auth/google/poll?session='+encodeURIComponent(session)));
if(pr.status===204)return; // ainda esperando
if(!pr.ok)return;
const j=await pr.json();
if(j.accessToken&&j.refreshToken){
clearInterval(_googleAuthPolling);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}
}catch(e){console.warn('[gsi-poll]',e.message)}
},2000);
}catch(e){
console.warn('[gsi-redirect]',e);
toast('Erro: '+e.message);
}
}

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 7
versionName "1.6.0"
versionCode 8
versionName "1.6.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.6.0",
"version": "1.6.1",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

View file

@ -3888,17 +3888,73 @@ function initGoogleSignIn(){
window._gsiInited=true;
}catch(e){console.warn('[gsi] init failed',e)}
}
function isInWebViewApp(){
// Detecta Capacitor ou WebView (Android wv) — onde GSI popup não funciona bem
if(window.Capacitor)return true;
const ua=navigator.userAgent;
if(/wv|; ?Version\//.test(ua)&&/Android/.test(ua))return true;
return false;
}
function welcomeGoogleClick(){
// Em apps Capacitor/WebView, GSI popup não funciona — usar redirect + polling
if(isInWebViewApp()){
return startGoogleRedirectFlow();
}
// Browser web: tenta GSI popup
if(!window.google?.accounts?.id){
toast('Google Sign-In ainda carregando, aguarde 2s');
return;
toast('Google Sign-In carregando, tentando alternativa...');
return startGoogleRedirectFlow();
}
initGoogleSignIn();
try{
window.google.accounts.id.prompt(()=>{});
// Fallback: render botão visível ao lado caso prompt seja bloqueado
window.google.accounts.id.prompt((notif)=>{
// Se prompt foi bloqueado (FedCM, popup blocker etc), fallback pro redirect
if(notif.isNotDisplayed?.()||notif.isSkippedMoment?.()){
startGoogleRedirectFlow();
}
});
}catch(e){
console.warn('[gsi] prompt failed',e);
startGoogleRedirectFlow();
}
}
let _googleAuthPolling=null;
async function startGoogleRedirectFlow(){
toast('Abrindo Google...');
try{
state.cloud.url=DEFAULT_CLOUD_URL;
saveState();
const r=await fetch(cloudUrl('/api/auth/google/start'));
if(!r.ok)throw new Error('HTTP '+r.status);
const{url,session}=await r.json();
if(!url||!session)throw new Error('servidor sem URL/session');
// Abre URL no browser EXTERNO (em Capacitor isso usa Custom Tabs)
if(window.open){window.open(url,'_blank','noopener')}else{location.href=url}
// Inicia polling
if(_googleAuthPolling)clearInterval(_googleAuthPolling);
let tries=0;
_googleAuthPolling=setInterval(async()=>{
tries++;
if(tries>120){clearInterval(_googleAuthPolling);toast('Tempo esgotado. Tente de novo.');return}
try{
const pr=await fetch(cloudUrl('/api/auth/google/poll?session='+encodeURIComponent(session)));
if(pr.status===204)return; // ainda esperando
if(!pr.ok)return;
const j=await pr.json();
if(j.accessToken&&j.refreshToken){
clearInterval(_googleAuthPolling);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
toast('Bem-vindo, '+(j.user.name||j.user.email));
welcomeFinish();
if(typeof renderAuthBox==='function')renderAuthBox();
}
}catch(e){console.warn('[gsi-poll]',e.message)}
},2000);
}catch(e){
console.warn('[gsi-redirect]',e);
toast('Erro: '+e.message);
}
}

View file

@ -126,7 +126,45 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Login com Google (Sign-In) — recebe ID token, valida no Google, cria/loga user
// ===== Google Login via OAuth redirect (pra apps Capacitor onde GSI popup não funciona) =====
// In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min.
const pendingGoogleSessions = new Map();
setInterval(() => {
const now = Date.now();
for (const [k, v] of pendingGoogleSessions.entries()) {
if (now - v.createdAt > 10 * 60 * 1000) pendingGoogleSessions.delete(k);
}
}, 60000);
// App chama isso pra obter URL pro browser externo
app.get('/api/auth/google/start', (req, res) => {
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
const sessionId = req.query.session || ('s_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
// Reusa buildAuthUrl mas com flow=login e session encoded em state
// Truque: passa userId=0 (não-autenticado) + flow no returnTo
const stateRaw = Buffer.from(JSON.stringify({ uid: 0, flow: 'login', session: sessionId, n: Math.random().toString(36).slice(2) })).toString('base64url');
const u = new URL('https://accounts.google.com/o/oauth2/v2/auth');
u.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
u.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URI);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'openid email profile');
u.searchParams.set('access_type', 'online');
u.searchParams.set('prompt', 'select_account');
u.searchParams.set('state', stateRaw);
res.json({ url: u.toString(), session: sessionId });
});
// App polling: retorna 204 se ainda esperando, 200 com tokens se Google completou
app.get('/api/auth/google/poll', (req, res) => {
const session = req.query.session;
if (!session) return res.status(400).json({ error: 'session required' });
const data = pendingGoogleSessions.get(session);
if (!data) return res.status(204).send();
pendingGoogleSessions.delete(session); // one-shot
res.json(data.tokens);
});
// Login com Google (Sign-In via popup do GSI no web) — recebe ID token, valida no Google, cria/loga user
app.post('/api/auth/google', async (req, res) => {
const { credential } = req.body || {};
if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' });
@ -309,7 +347,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
});
// Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.6.0/Shivao-v1.6.0.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.6.1/Shivao-v1.6.1.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)
@ -628,11 +666,45 @@ app.get('/api/google/callback', async (req, res) => {
let parsed;
try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); }
catch (e) { return res.status(400).send('state inválido'); }
const userId = parsed.uid;
if (!userId) return res.status(400).send('state sem uid');
try {
const tokens = await gcal.exchangeCodeForTokens(code);
const userInfo = await gcal.getUserInfo(tokens.access_token);
// === Flow LOGIN (do app via /api/auth/google/start) ===
if (parsed.flow === 'login' && parsed.session) {
let user = db.findUserByEmail(userInfo.email);
if (!user) {
const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
const hash = await hashPassword(randomPwd);
const id = db.createUser(userInfo.email, hash, userInfo.name || userInfo.email.split('@')[0]);
db.audit(id, 'user_signup_google', 'user', String(id), { email: userInfo.email }, req.ip);
user = db.findUserById(id);
}
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
// Salva tokens em memória pra app coletar via /poll
pendingGoogleSessions.set(parsed.session, {
tokens: {
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
},
createdAt: Date.now(),
});
return res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Login OK</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a;margin:0 0 16px}p{margin:8px 0;line-height:1.5}.big{font-size:64px;margin-bottom:16px}</style></head>
<body><div><div class="big"></div><h1>Logado com sucesso</h1>
<p>Você está conectado como <strong>${userInfo.email}</strong></p>
<p style="opacity:.7">Volte pro app ele vai detectar o login automaticamente em alguns segundos.</p>
<script>setTimeout(()=>{try{window.close()}catch(e){}},3000)</script>
</div></body></html>`);
}
// === Flow CALENDAR (conectar Google Calendar pra user já logado) ===
const userId = parsed.uid;
if (!userId) return res.status(400).send('state sem uid');
db.saveGoogleConnection(userId, {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
@ -640,7 +712,6 @@ app.get('/api/google/callback', async (req, res) => {
email: userInfo.email,
});
db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip);
// Redireciona pra app com flag de sucesso
const returnTo = parsed.rt || '/';
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
@ -652,7 +723,7 @@ app.get('/api/google/callback', async (req, res) => {
</div></body></html>`);
} catch (e) {
console.warn('[google] callback failed', e.message);
res.status(500).send('Erro ao conectar Google: ' + e.message);
res.status(500).send('Erro: ' + e.message);
}
});