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
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:
parent
b48afaa84f
commit
24f6df3da7
5 changed files with 200 additions and 17 deletions
|
|
@ -3888,17 +3888,73 @@ function initGoogleSignIn(){
|
||||||
window._gsiInited=true;
|
window._gsiInited=true;
|
||||||
}catch(e){console.warn('[gsi] init failed',e)}
|
}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(){
|
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){
|
if(!window.google?.accounts?.id){
|
||||||
toast('Google Sign-In ainda carregando, aguarde 2s');
|
toast('Google Sign-In carregando, tentando alternativa...');
|
||||||
return;
|
return startGoogleRedirectFlow();
|
||||||
}
|
}
|
||||||
initGoogleSignIn();
|
initGoogleSignIn();
|
||||||
try{
|
try{
|
||||||
window.google.accounts.id.prompt(()=>{});
|
window.google.accounts.id.prompt((notif)=>{
|
||||||
// Fallback: render botão visível ao lado caso prompt seja bloqueado
|
// Se prompt foi bloqueado (FedCM, popup blocker etc), fallback pro redirect
|
||||||
|
if(notif.isNotDisplayed?.()||notif.isSkippedMoment?.()){
|
||||||
|
startGoogleRedirectFlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
}catch(e){
|
}catch(e){
|
||||||
console.warn('[gsi] prompt failed',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);
|
toast('Erro: '+e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ android {
|
||||||
applicationId "br.com.pontualtech.shivao"
|
applicationId "br.com.pontualtech.shivao"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 7
|
versionCode 8
|
||||||
versionName "1.6.0"
|
versionName "1.6.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "shivao-mobile",
|
"name": "shivao-mobile",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
|
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -3888,17 +3888,73 @@ function initGoogleSignIn(){
|
||||||
window._gsiInited=true;
|
window._gsiInited=true;
|
||||||
}catch(e){console.warn('[gsi] init failed',e)}
|
}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(){
|
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){
|
if(!window.google?.accounts?.id){
|
||||||
toast('Google Sign-In ainda carregando, aguarde 2s');
|
toast('Google Sign-In carregando, tentando alternativa...');
|
||||||
return;
|
return startGoogleRedirectFlow();
|
||||||
}
|
}
|
||||||
initGoogleSignIn();
|
initGoogleSignIn();
|
||||||
try{
|
try{
|
||||||
window.google.accounts.id.prompt(()=>{});
|
window.google.accounts.id.prompt((notif)=>{
|
||||||
// Fallback: render botão visível ao lado caso prompt seja bloqueado
|
// Se prompt foi bloqueado (FedCM, popup blocker etc), fallback pro redirect
|
||||||
|
if(notif.isNotDisplayed?.()||notif.isSkippedMoment?.()){
|
||||||
|
startGoogleRedirectFlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
}catch(e){
|
}catch(e){
|
||||||
console.warn('[gsi] prompt failed',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);
|
toast('Erro: '+e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,45 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
|
||||||
res.json(req.user);
|
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) => {
|
app.post('/api/auth/google', async (req, res) => {
|
||||||
const { credential } = req.body || {};
|
const { credential } = req.body || {};
|
||||||
if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' });
|
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
|
// 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));
|
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)
|
// 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;
|
let parsed;
|
||||||
try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); }
|
try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); }
|
||||||
catch (e) { return res.status(400).send('state inválido'); }
|
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 {
|
try {
|
||||||
const tokens = await gcal.exchangeCodeForTokens(code);
|
const tokens = await gcal.exchangeCodeForTokens(code);
|
||||||
const userInfo = await gcal.getUserInfo(tokens.access_token);
|
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, {
|
db.saveGoogleConnection(userId, {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
|
|
@ -640,7 +712,6 @@ app.get('/api/google/callback', async (req, res) => {
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
});
|
});
|
||||||
db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip);
|
db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip);
|
||||||
// Redireciona pra app com flag de sucesso
|
|
||||||
const returnTo = parsed.rt || '/';
|
const returnTo = parsed.rt || '/';
|
||||||
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
|
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<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>`);
|
</div></body></html>`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[google] callback failed', e.message);
|
console.warn('[google] callback failed', e.message);
|
||||||
res.status(500).send('Erro ao conectar Google: ' + e.message);
|
res.status(500).send('Erro: ' + e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue