login for RN app
This commit is contained in:
@@ -1,7 +1,38 @@
|
|||||||
// Single source of truth for all backend communication.
|
// 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 } = {}) {
|
async function http(path, { method = 'GET', body } = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
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, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
@@ -10,6 +41,18 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
body: body ? JSON.stringify(body) : undefined,
|
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) {
|
if (!res.ok) {
|
||||||
let message = res.statusText;
|
let message = res.statusText;
|
||||||
let data;
|
let data;
|
||||||
@@ -19,13 +62,21 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
message = (await res.text().catch(() => message)) || message;
|
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})`);
|
const err = new Error(message || `Request failed (${res.status})`);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
err.data = data;
|
err.data = data;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
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 = {
|
export const article = {
|
||||||
@@ -65,11 +116,24 @@ export const auth = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loginWithGoogle(idToken) {
|
async loginWithGoogle(idToken) {
|
||||||
|
logApi('loginWithGoogle', {
|
||||||
|
idTokenLength: idToken?.length ?? 0,
|
||||||
|
idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null,
|
||||||
|
});
|
||||||
const data = await http('/api/auth/login', {
|
const data = await http('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { idToken },
|
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() {
|
async logout() {
|
||||||
|
|||||||
@@ -35,10 +35,19 @@ export default function AuthProvider({ children }) {
|
|||||||
}, [enqueueSnackbar]);
|
}, [enqueueSnackbar]);
|
||||||
|
|
||||||
const loginWithGoogle = useCallback(async (credential) => {
|
const loginWithGoogle = useCallback(async (credential) => {
|
||||||
const { user: me } = await auth.loginWithGoogle(credential);
|
const { session } = await auth.loginWithGoogle(credential);
|
||||||
setUser(me);
|
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');
|
setStatus('authed');
|
||||||
}, []);
|
}, [enqueueSnackbar]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
|
|||||||
@@ -2,6 +2,32 @@ const GIS_SCRIPT = 'https://accounts.google.com/gsi/client';
|
|||||||
|
|
||||||
let scriptPromise;
|
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() {
|
function loadGoogleScript() {
|
||||||
if (window.google?.accounts?.id) return Promise.resolve();
|
if (window.google?.accounts?.id) return Promise.resolve();
|
||||||
if (scriptPromise) return scriptPromise;
|
if (scriptPromise) return scriptPromise;
|
||||||
@@ -12,7 +38,21 @@ function loadGoogleScript() {
|
|||||||
script.async = true;
|
script.async = true;
|
||||||
script.defer = true;
|
script.defer = true;
|
||||||
script.onload = () => resolve();
|
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);
|
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).
|
* 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 {string} clientId
|
||||||
* @param {{ onFedcmBlocked?: () => void }} [options]
|
* @param {{ onFedcmBlocked?: () => void }} [options]
|
||||||
*/
|
*/
|
||||||
@@ -84,6 +125,24 @@ export async function requestGoogleIdToken(clientId, { onFedcmBlocked } = {}) {
|
|||||||
throw new Error('Google client ID is not configured');
|
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();
|
await loadGoogleScript();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
FEDCM_BLOCKED_MESSAGE,
|
FEDCM_BLOCKED_MESSAGE,
|
||||||
GoogleSignInBlockedError,
|
GoogleSignInBlockedError,
|
||||||
GoogleSignInCancelledError,
|
GoogleSignInCancelledError,
|
||||||
|
logError,
|
||||||
requestGoogleIdToken,
|
requestGoogleIdToken,
|
||||||
} from '../auth/googleSignIn';
|
} from '../auth/googleSignIn';
|
||||||
|
|
||||||
@@ -36,11 +37,32 @@ export default function LoginPage() {
|
|||||||
await loginWithGoogle(idToken);
|
await loginWithGoogle(idToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GoogleSignInCancelledError) return;
|
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) {
|
if (err instanceof GoogleSignInBlockedError) {
|
||||||
enqueueSnackbar(err.message, { variant: 'warning', autoHideDuration: 12000 });
|
enqueueSnackbar(err.message, { variant: 'warning', autoHideDuration: 12000 });
|
||||||
return;
|
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 {
|
} finally {
|
||||||
setBusy(false);
|
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() {
|
componentDidMount() {
|
||||||
document.addEventListener('mousedown', preserveSearchFocus, true);
|
document.addEventListener('mousedown', preserveSearchFocus, true);
|
||||||
window.addEventListener('resize', this.handleWindowResize);
|
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() {
|
componentWillUnmount() {
|
||||||
@@ -192,14 +178,6 @@ class ArtikelSearchPage extends Component {
|
|||||||
const target = searchParams.get('target') ?? '';
|
const target = searchParams.get('target') ?? '';
|
||||||
const prevTarget = prev.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) {
|
if (target !== prevTarget && target !== this.state.input) {
|
||||||
this.setState({ input: target });
|
this.setState({ input: target });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { buildSearchUrl, queryFromParams, readArtikelQuery } from './artikelQuery';
|
||||||
|
|
||||||
export default function withSearchParams(Wrapped) {
|
export default function withSearchParams(Wrapped) {
|
||||||
function SearchParamsWrapper(props) {
|
function SearchParamsWrapper(props) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<Wrapped
|
<Wrapped
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:4208',
|
target: 'http://localhost:4208',
|
||||||
changeOrigin: true,
|
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