login for RN app
This commit is contained in:
@@ -1,7 +1,38 @@
|
||||
// Single source of truth for all backend communication.
|
||||
|
||||
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
||||
function logApi(tag, details) {
|
||||
let text;
|
||||
try {
|
||||
text = JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
text = String(details);
|
||||
}
|
||||
console.log(`[api] ${tag} ${text}`);
|
||||
}
|
||||
|
||||
function readableHeaders(headers) {
|
||||
const out = {};
|
||||
headers.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function http(path, { method = 'GET', body } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const url = new URL(path, window.location.origin).href;
|
||||
|
||||
logApi('request', {
|
||||
method,
|
||||
path,
|
||||
url,
|
||||
origin: window.location.origin,
|
||||
credentials: 'include',
|
||||
// HttpOnly cookies are invisible here; this only shows JS-readable cookies.
|
||||
documentCookie: document.cookie || '(empty)',
|
||||
hasBody: Boolean(body),
|
||||
});
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
@@ -10,6 +41,18 @@ async function http(path, { method = 'GET', body } = {}) {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = readableHeaders(res.headers);
|
||||
// Browsers hide Set-Cookie from JS; if it's missing here that proves nothing.
|
||||
logApi('response', {
|
||||
method,
|
||||
path,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
ok: res.ok,
|
||||
responseHeaders,
|
||||
documentCookieAfter: document.cookie || '(empty)',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
@@ -19,13 +62,21 @@ async function http(path, { method = 'GET', body } = {}) {
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
logApi('error-body', { method, path, status: res.status, message, data });
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
const json = await res.json();
|
||||
logApi('ok-body', {
|
||||
method,
|
||||
path,
|
||||
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
|
||||
hasUser: Boolean(json?.user),
|
||||
});
|
||||
return json;
|
||||
}
|
||||
|
||||
export const article = {
|
||||
@@ -65,11 +116,24 @@ export const auth = {
|
||||
},
|
||||
|
||||
async loginWithGoogle(idToken) {
|
||||
logApi('loginWithGoogle', {
|
||||
idTokenLength: idToken?.length ?? 0,
|
||||
idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null,
|
||||
});
|
||||
const data = await http('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { idToken },
|
||||
});
|
||||
return { user: data.user };
|
||||
// Confirm whether the session cookie actually works (not just login JSON).
|
||||
const session = await auth.me();
|
||||
logApi('session-after-login', {
|
||||
loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null,
|
||||
meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null,
|
||||
meForbidden: session.forbidden ?? false,
|
||||
meMessage: session.message ?? null,
|
||||
sessionOk: Boolean(session.user),
|
||||
});
|
||||
return { user: data.user, session };
|
||||
},
|
||||
|
||||
async logout() {
|
||||
|
||||
@@ -35,10 +35,19 @@ export default function AuthProvider({ children }) {
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
const loginWithGoogle = useCallback(async (credential) => {
|
||||
const { user: me } = await auth.loginWithGoogle(credential);
|
||||
setUser(me);
|
||||
const { session } = await auth.loginWithGoogle(credential);
|
||||
if (!session?.user) {
|
||||
setUser(null);
|
||||
setStatus('anon');
|
||||
enqueueSnackbar(
|
||||
`Login ok but session cookie not accepted (/api/auth/me still unauthorized)`,
|
||||
{ variant: 'error', autoHideDuration: 12000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUser(session.user);
|
||||
setStatus('authed');
|
||||
}, []);
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await auth.logout();
|
||||
|
||||
@@ -2,6 +2,32 @@ const GIS_SCRIPT = 'https://accounts.google.com/gsi/client';
|
||||
|
||||
let scriptPromise;
|
||||
|
||||
/** WebView consoles print objects as [object Object]; always log a string. */
|
||||
export function logError(tag, details) {
|
||||
let text;
|
||||
try {
|
||||
text = JSON.stringify(details, errorReplacer, 2);
|
||||
} catch {
|
||||
text = String(details);
|
||||
}
|
||||
console.error(`${tag} ${text}`);
|
||||
}
|
||||
|
||||
function errorReplacer(_key, value) {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
code: value.code,
|
||||
status: value.status,
|
||||
stack: value.stack,
|
||||
cause: value.cause,
|
||||
...Object.fromEntries(Object.entries(value)),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadGoogleScript() {
|
||||
if (window.google?.accounts?.id) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
@@ -12,7 +38,21 @@ function loadGoogleScript() {
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Google Sign-In'));
|
||||
script.onerror = () => {
|
||||
scriptPromise = null;
|
||||
// <script onerror> does not expose a message; fetch to get a real Error.
|
||||
fetch(GIS_SCRIPT).then(
|
||||
(res) =>
|
||||
reject(
|
||||
new Error(
|
||||
res.ok
|
||||
? `Failed to execute ${GIS_SCRIPT}`
|
||||
: `Failed to load ${GIS_SCRIPT}: HTTP ${res.status} ${res.statusText}`,
|
||||
),
|
||||
),
|
||||
reject,
|
||||
);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
@@ -76,6 +116,7 @@ function clickRenderedGoogleButton(clientId, onCredential) {
|
||||
|
||||
/**
|
||||
* Prompt for a Google ID token (One Tap, falling back to the sign-in popup).
|
||||
* In Dash Native (`window.__dashHost`), uses the host bridge with the same web OAuth client ID.
|
||||
* @param {string} clientId
|
||||
* @param {{ onFedcmBlocked?: () => void }} [options]
|
||||
*/
|
||||
@@ -84,6 +125,24 @@ export async function requestGoogleIdToken(clientId, { onFedcmBlocked } = {}) {
|
||||
throw new Error('Google client ID is not configured');
|
||||
}
|
||||
|
||||
const host = window.__dashHost;
|
||||
if (
|
||||
host?.isDashNative === true &&
|
||||
host.googleSignIn === true &&
|
||||
typeof host.requestGoogleIdToken === 'function'
|
||||
) {
|
||||
try {
|
||||
return await host.requestGoogleIdToken(clientId);
|
||||
} catch (err) {
|
||||
logError('[googleSignIn] native host requestGoogleIdToken failed', {
|
||||
clientId,
|
||||
origin: window.location.origin,
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await loadGoogleScript();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FEDCM_BLOCKED_MESSAGE,
|
||||
GoogleSignInBlockedError,
|
||||
GoogleSignInCancelledError,
|
||||
logError,
|
||||
requestGoogleIdToken,
|
||||
} from '../auth/googleSignIn';
|
||||
|
||||
@@ -36,11 +37,32 @@ export default function LoginPage() {
|
||||
await loginWithGoogle(idToken);
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleSignInCancelledError) return;
|
||||
logError('[login] Google sign-in failed', {
|
||||
message: err?.message,
|
||||
name: err?.name,
|
||||
code: err?.code,
|
||||
status: err?.status,
|
||||
cause: err?.cause,
|
||||
stack: err?.stack,
|
||||
clientId: googleClientId,
|
||||
origin: window.location.origin,
|
||||
href: window.location.href,
|
||||
dashHost: window.__dashHost
|
||||
? {
|
||||
isDashNative: window.__dashHost.isDashNative,
|
||||
googleSignIn: window.__dashHost.googleSignIn,
|
||||
}
|
||||
: null,
|
||||
error: err,
|
||||
});
|
||||
if (err instanceof GoogleSignInBlockedError) {
|
||||
enqueueSnackbar(err.message, { variant: 'warning', autoHideDuration: 12000 });
|
||||
return;
|
||||
}
|
||||
enqueueSnackbar(`Login failed: ${err.message}`, { variant: 'error' });
|
||||
const detail = [err?.message || String(err), err?.code, err?.status]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
enqueueSnackbar(`Login failed: ${detail}`, { variant: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -161,23 +161,9 @@ class ArtikelSearchPage extends Component {
|
||||
});
|
||||
};
|
||||
|
||||
restoreStoredQuery = (stored) => {
|
||||
this.props.navigate(
|
||||
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
|
||||
const query = queryFromParams(this.props.searchParams);
|
||||
const stored = readArtikelQuery();
|
||||
|
||||
if (query.target.trim().length < 3 && stored?.target?.trim().length >= 3) {
|
||||
this.restoreStoredQuery(stored);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -192,14 +178,6 @@ class ArtikelSearchPage extends Component {
|
||||
const target = searchParams.get('target') ?? '';
|
||||
const prevTarget = prev.get('target') ?? '';
|
||||
|
||||
if (target.trim().length < 3 && prevTarget.trim().length >= 3) {
|
||||
const stored = readArtikelQuery();
|
||||
if (stored?.target?.trim().length >= 3) {
|
||||
this.restoreStoredQuery(stored);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (target !== prevTarget && target !== this.state.input) {
|
||||
this.setState({ input: target });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { buildSearchUrl, queryFromParams, readArtikelQuery } from './artikelQuery';
|
||||
|
||||
export default function withSearchParams(Wrapped) {
|
||||
function SearchParamsWrapper(props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const query = queryFromParams(searchParams);
|
||||
if (query.target.trim().length >= 3) return;
|
||||
|
||||
const stored = readArtikelQuery();
|
||||
if (!stored?.target || stored.target.trim().length < 3) return;
|
||||
|
||||
navigate(
|
||||
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
return (
|
||||
<Wrapped
|
||||
{...props}
|
||||
|
||||
@@ -10,6 +10,28 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:4208',
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: '',
|
||||
configure(proxy) {
|
||||
proxy.on('proxyRes', (proxyRes, req) => {
|
||||
const raw = proxyRes.headers['set-cookie'];
|
||||
if (!raw) {
|
||||
console.log('[vite-proxy]', req.method, req.url, {
|
||||
status: proxyRes.statusCode,
|
||||
setCookie: '(none)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Backend sets Secure; WebView on http://LAN-IP will refuse it.
|
||||
const rewritten = (Array.isArray(raw) ? raw : [raw]).map((c) =>
|
||||
c.replace(/;\s*Secure/gi, ''),
|
||||
);
|
||||
proxyRes.headers['set-cookie'] = rewritten;
|
||||
console.log('[vite-proxy]', req.method, req.url, {
|
||||
status: proxyRes.statusCode,
|
||||
setCookie: rewritten,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user