diff --git a/app/diario-bordo.html b/app/diario-bordo.html index c0f9a3d..f73dc49 100644 --- a/app/diario-bordo.html +++ b/app/diario-bordo.html @@ -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); } } diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 3e93e45..64ac06c 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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. diff --git a/mobile/package.json b/mobile/package.json index 593e9ff..b3033e6 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/server/public/index.html b/server/public/index.html index c0f9a3d..f73dc49 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -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); } } diff --git a/server/src/index.js b/server/src/index.js index 1d9ca69..d59e1fe 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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(`
Você está conectado como ${userInfo.email}
+Volte pro app — ele vai detectar o login automaticamente em alguns segundos.
+ +