login for RN app

This commit is contained in:
seb
2026-07-19 23:42:17 +02:00
parent af9e072427
commit c5b2f06919
7 changed files with 199 additions and 29 deletions

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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}

View File

@@ -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,
});
});
},
},
},
},