From d555a832d9e927a2ce9bd11a616bd367e12007c2 Mon Sep 17 00:00:00 2001 From: Victor Hugo Date: Mon, 27 Jul 2026 18:42:25 -0400 Subject: [PATCH] feat: google oauth2 implementation --- .gitignore | 1 + package-lock.json | 36 ++-- src/api/account.ts | 2 + src/api/auth.ts | 254 ++++++++++++++++++++++++++ src/api/chat.ts | 15 +- src/api/config.ts | 20 +- src/components/ai/ChatThread.tsx | 6 + src/components/auth/SignInButtons.tsx | 80 ++++++++ src/components/editor/AiSidePanel.tsx | 112 ++++++++---- src/main.tsx | 45 +---- src/pages/AccountPage.tsx | 68 +++++-- src/store/appStore.ts | 34 +++- tests/chatStore.test.ts | 73 ++++++++ vite.config.js | 45 +++-- 14 files changed, 665 insertions(+), 126 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/components/auth/SignInButtons.tsx create mode 100644 tests/chatStore.test.ts diff --git a/.gitignore b/.gitignore index 9484f7d..66a72d9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr build test-results.json .claude +credentials.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 687c4f6..a67a52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5256,16 +5256,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -8407,9 +8407,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -8683,9 +8683,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -8702,7 +8702,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9007,9 +9007,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "dev": true, "license": "MIT", "dependencies": { @@ -9030,13 +9030,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" diff --git a/src/api/account.ts b/src/api/account.ts index 209b186..caef383 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -10,6 +10,8 @@ export interface AccountProfile { lastName: string | null; createdAt: string | null; roles: string[]; + /** Which identity provider owns this account's credentials. */ + provider: 'keycloak' | 'google'; } export interface AccountUsage { diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..5db21fa --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,254 @@ +import keycloak from './keycloak'; +import { useByokStore, useChatStore } from '../store/appStore'; + +/** + * One sign-in surface for the whole app, over two providers: + * + * - Keycloak — the existing realm login, tokens handled by keycloak-js + * - Google — the OAuth 2 authorization-code flow, run server-side by + * k12-llm-backend, which hands back a session token + * + * Components should call these helpers instead of touching `keycloak` + * directly, so both providers stay interchangeable everywhere. + */ + +const env = ((import.meta as unknown as { env?: Record }).env ?? + {}) as Record; + +const BACKEND_URL = env.VITE_BACKEND_URL ?? 'https://k12api.torta-server.duckdns.org'; + +export type AuthProvider = 'keycloak' | 'google'; + +const GOOGLE_TOKEN_KEY = 'google_token'; +const GOOGLE_EXPIRES_KEY = 'google_token_expires_at'; +const GOOGLE_NONCE_KEY = 'google_auth_nonce'; + +interface GoogleSession { + token: string; + expiresAt: number; +} + +/** Reads the stored Google session, discarding it once expired. */ +function readGoogleSession(): GoogleSession | null { + const token = localStorage.getItem(GOOGLE_TOKEN_KEY); + const expiresAt = Number(localStorage.getItem(GOOGLE_EXPIRES_KEY) ?? 0); + if (!token) return null; + if (!expiresAt || Date.now() >= expiresAt) { + clearGoogleSession(); + return null; + } + return { token, expiresAt }; +} + +function clearGoogleSession(): void { + localStorage.removeItem(GOOGLE_TOKEN_KEY); + localStorage.removeItem(GOOGLE_EXPIRES_KEY); +} + +/** Error text set by the last redirect back from Google, if it failed. */ +let googleAuthError: string | null = null; + +const AUTH_ERRORS: Record = { + access_denied: 'Google sign-in was cancelled.', + email_unverified: 'That Google account has no verified email address.', + exchange_failed: "Google sign-in failed — couldn't verify the account.", + invalid_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_code: 'Google did not return an authorization code. Please try again.', + nonce_mismatch: 'Google sign-in could not be matched to this browser. Please try again.', +}; + +export function getAuthError(): string | null { + return googleAuthError; +} + +/** + * Consumes the `#token=…` fragment the backend redirects back with. + * + * Runs before anything renders (see main.tsx) and always strips the fragment, + * so the session token never lingers in the address bar or in history. + */ +function captureRedirectResult(): void { + const hash = window.location.hash.replace(/^#/, ''); + if (!hash) return; + + const params = new URLSearchParams(hash); + const token = params.get('token'); + const error = params.get('auth_error'); + if (!token && !error) return; // some other fragment — leave it alone + + const expectedNonce = sessionStorage.getItem(GOOGLE_NONCE_KEY); + sessionStorage.removeItem(GOOGLE_NONCE_KEY); + + if (error) { + googleAuthError = AUTH_ERRORS[error] ?? 'Google sign-in failed. Please try again.'; + } else if (token) { + // The nonce proves this redirect answers the sign-in *this* tab started. + if (!expectedNonce || params.get('nonce') !== expectedNonce) { + googleAuthError = AUTH_ERRORS['nonce_mismatch']!; + } else { + const expiresIn = Number(params.get('expires_in') ?? 0); + localStorage.setItem(GOOGLE_TOKEN_KEY, token); + localStorage.setItem( + GOOGLE_EXPIRES_KEY, + String(Date.now() + (expiresIn > 0 ? expiresIn : 3600) * 1000) + ); + } + } + + params.delete('token'); + params.delete('nonce'); + params.delete('provider'); + params.delete('expires_in'); + params.delete('auth_error'); + const rest = params.toString(); + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + (rest ? `#${rest}` : '') + ); +} + +/** + * Boots authentication: picks up a Google redirect if there is one, otherwise + * restores the Keycloak session. Resolves once the app can safely render. + */ +export async function initAuth(): Promise { + captureRedirectResult(); + + // A live Google session wins — initialising Keycloak as well would only add + // a redirect round-trip for a user who is already signed in. + if (readGoogleSession()) return; + + const savedToken = localStorage.getItem('kc_token') ?? undefined; + const savedRefreshToken = localStorage.getItem('kc_refresh_token') ?? undefined; + + keycloak.onTokenExpired = () => { + keycloak + .updateToken(30) + .then(() => { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + }) + .catch(() => { + localStorage.removeItem('kc_token'); + localStorage.removeItem('kc_refresh_token'); + }); + }; + + try { + const authenticated = await keycloak.init({ + checkLoginIframe: false, + token: savedToken, + refreshToken: savedRefreshToken, + }); + if (authenticated) { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + } + } catch { + // Keycloak unreachable — the app still loads, just signed out. + } +} + +/** Which provider the current session came from, or null when signed out. */ +export function currentProvider(): AuthProvider | null { + if (readGoogleSession()) return 'google'; + return keycloak.authenticated ? 'keycloak' : null; +} + +/** Unverified read of a JWT's payload — fine client-side, where the token is ours. */ +function decodePayload(token: string): Record | null { + try { + const part = token.split('.')[1]; + if (!part) return null; + return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) as Record; + } catch { + return null; + } +} + +/** + * Stable per-account key, e.g. `google:1234567890`. + * + * Anything cached in localStorage that belongs to one account (chat sessions, + * most obviously) must be tagged with this, or the next person to sign in on + * the same browser inherits it. + */ +export function currentUserKey(): string | null { + const google = readGoogleSession(); + if (google) { + const sub = decodePayload(google.token)?.['sub']; + return typeof sub === 'string' ? `google:${sub}` : null; + } + const sub = keycloak.authenticated ? keycloak.tokenParsed?.sub : undefined; + return sub ? `keycloak:${sub}` : null; +} + +export function isAuthenticated(): boolean { + return currentProvider() !== null; +} + +/** A bearer token for the backend, refreshing the Keycloak one if needed. */ +export async function getToken(): Promise { + const google = readGoogleSession(); + if (google) return google.token; + + if (!keycloak.authenticated) return null; + await keycloak.updateToken(30).catch(() => {}); + return keycloak.token ?? null; +} + +export async function loginWithKeycloak(): Promise { + // initAuth() skips Keycloak entirely while a Google session is live, so a + // session that expires with the tab open can leave keycloak-js uninitialised + // — and login() throws in that state. + if (!keycloak.didInitialize) { + await keycloak.init({ checkLoginIframe: false }).catch(() => {}); + } + keycloak.login(); +} + +/** + * Starts the Google flow. The backend owns the redirect to Google (it holds + * the client secret), so we just hand it a nonce and where to come back to. + */ +export function loginWithGoogle(): void { + const nonce = crypto.randomUUID(); + sessionStorage.setItem(GOOGLE_NONCE_KEY, nonce); + + const returnTo = window.location.origin + window.location.pathname; + const params = new URLSearchParams({ returnTo, nonce }); + window.location.assign(`${BACKEND_URL}/api/auth/google/start?${params.toString()}`); +} + +export function logout(redirectUri = window.location.origin + '/v2/editor'): void { + // Cached chat sessions belong to the account that just left; leaving them in + // localStorage would show them to whoever signs in next on this browser. + useChatStore.getState().clearChats(); + // Same reasoning for the BYOK key — it's a personal credential, and the next + // person to sign in on this browser would otherwise spend against it. This + // leaves the panel on the school-provided model rather than re-onboarding. + useByokStore.getState().clearByok(); + + if (readGoogleSession()) { + // Local sign-out only: we never asked for offline access, so there is no + // Google-side session of ours to end, and signing the user out of Google + // itself would be far more than they asked for. + clearGoogleSession(); + window.location.assign(redirectUri); + return; + } + keycloak.logout({ redirectUri }); +} + +/** Which providers the backend is configured to offer. Falls back to Keycloak. */ +export async function fetchProviders(): Promise<{ keycloak: boolean; google: boolean }> { + try { + const res = await fetch(`${BACKEND_URL}/api/auth/providers`); + if (!res.ok) throw new Error(String(res.status)); + return (await res.json()) as { keycloak: boolean; google: boolean }; + } catch { + return { keycloak: true, google: false }; + } +} diff --git a/src/api/chat.ts b/src/api/chat.ts index 6f2b0bd..facee00 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -14,15 +14,26 @@ export interface SessionDetail extends SessionMeta { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string }>; } +/** Carries the status code so callers can tell "gone" from "try again later". */ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = 'HttpError'; + } +} + export async function listChats(): Promise { const res = await fetch(`${BACKEND_URL}/api/chats`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chats: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chats (${res.status})`, res.status); return res.json() as Promise; } export async function getChat(id: string): Promise { const res = await fetch(`${BACKEND_URL}/api/chats/${id}`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chat: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chat (${res.status})`, res.status); return res.json() as Promise; } diff --git a/src/api/config.ts b/src/api/config.ts index fff82b6..5738227 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,4 +1,4 @@ -import keycloak from './keycloak'; +import { getToken } from './auth'; /** * Shared configuration for every k12-llm-backend call. @@ -12,20 +12,26 @@ const env = ((import.meta as unknown as { env?: Record> { - await keycloak.updateToken(30).catch(() => {}); + const token = await getToken(); return { 'Content-Type': 'application/json', - Authorization: `Bearer ${keycloak.token ?? ''}`, + Authorization: `Bearer ${token ?? ''}`, }; } /** Like authHeaders, but throws when the user isn't signed in (chat requires auth). */ export async function requireAuthHeaders(): Promise> { - const headers = await authHeaders(); - if (!keycloak.token) { + const token = await getToken(); + if (!token) { throw new Error('Not authenticated — please refresh the page to log in.'); } - return headers; + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; } diff --git a/src/components/ai/ChatThread.tsx b/src/components/ai/ChatThread.tsx index 8f71d87..3564c00 100644 --- a/src/components/ai/ChatThread.tsx +++ b/src/components/ai/ChatThread.tsx @@ -29,6 +29,12 @@ export interface Chat { sessionId: string | null; /** Id of the last persisted message — sent as parentMessageId on the next turn. */ parentMessageId: string | null; + /** + * True once this chat's history is in `messages` — including when the answer + * was "no messages". Without it, an empty-but-real session is indistinguishable + * from one still loading, and the panel waits on it forever. + */ + historyLoaded: boolean; } interface ChatThreadProps { diff --git a/src/components/auth/SignInButtons.tsx b/src/components/auth/SignInButtons.tsx new file mode 100644 index 0000000..5613a7b --- /dev/null +++ b/src/components/auth/SignInButtons.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { LogIn } from 'lucide-react'; +import { fetchProviders, getAuthError, loginWithGoogle, loginWithKeycloak } from '../../api/auth'; + +/** + * The sign-in choices, shared by the AI panel and the account page so both + * always offer the same providers. + * + * The Google button only appears once the backend confirms it's configured — + * a deployment without Google credentials shows the Keycloak login alone. + */ + +/** Google's brand mark, per their sign-in branding guidelines. */ +function GoogleMark({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function SignInButtons({ compact = false }: { compact?: boolean }) { + const [googleAvailable, setGoogleAvailable] = useState(false); + const error = getAuthError(); + + useEffect(() => { + let active = true; + fetchProviders().then((p) => { + if (active) setGoogleAvailable(p.google); + }); + return () => { + active = false; + }; + }, []); + + const size = compact ? 'px-4 py-2 text-xs' : 'px-4 py-2.5 text-sm'; + + return ( +
+ {error && ( +

+ {error} +

+ )} + + + + {googleAvailable && ( + + )} +
+ ); +} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index d7b5ce5..a96127a 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,10 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { History, LogIn, Plus, UserCircle, X } from 'lucide-react'; +import { History, Plus, UserCircle, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; -import keycloak from '../../api/keycloak'; -import { listChats, getChat, deleteChatApi, renameChatApi, toSimpleMessages } from '../../api/chat'; +import { currentUserKey, isAuthenticated } from '../../api/auth'; +import { + listChats, + getChat, + deleteChatApi, + renameChatApi, + toSimpleMessages, + HttpError, +} from '../../api/chat'; import { type LlmPanel, type SimpleMessage, type TurnIds } from '../../api/llm'; import { useAiConsentStore, @@ -17,6 +24,7 @@ import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; import { ApiKeyGate } from '../ai/ApiKeyGate'; import { AiTermsModal } from '../ai/AiTermsModal'; +import { SignInButtons } from '../auth/SignInButtons'; import { TypingDots } from '../ai/MessageComponents'; interface AiSidePanelProps { @@ -35,6 +43,7 @@ const makeNewChat = (): Chat => ({ messages: [], sessionId: null, parentMessageId: null, + historyLoaded: true, // nothing to fetch — it exists only in this browser }); const titleFrom = (text: string): string => { @@ -42,12 +51,14 @@ const titleFrom = (text: string): string => { return clean.length > 40 ? `${clean.slice(0, 40)}…` : clean || 'New chat'; }; -const sessionToChat = (s: SessionMeta, messages: SimpleMessage[] = []): Chat => ({ +const sessionToChat = (s: SessionMeta, messages?: SimpleMessage[]): Chat => ({ id: s.id, title: s.title ?? 'New chat', - messages, + messages: messages ?? [], sessionId: s.id, parentMessageId: null, + // A cache hit is the history; anything else still needs fetching. + historyLoaded: messages !== undefined, }); export function AiSidePanel({ @@ -67,8 +78,10 @@ export function AiSidePanel({ updateSession, setMessages, getCachedMessages, + claimFor, } = useChatStore(); + const signedIn = isAuthenticated(); const configured = useByokStore((s) => s.configured); const termsAccepted = useAiConsentStore((s) => s.accepted); const acceptTerms = useAiConsentStore((s) => s.accept); @@ -78,13 +91,16 @@ export function AiSidePanel({ const [showHistory, setShowHistory] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); + const [loadError, setLoadError] = useState(null); + // Bumped by "Try again" to re-run the message load for the active chat. + const [reloadNonce, setReloadNonce] = useState(0); // Load session list from backend (skipped if sessions already in persisted // store). Always ends with at least one chat selected — a fresh local chat // is created when the user has no history, so the panel is ready to type // into without pressing "+" first. useEffect(() => { - if (!keycloak.authenticated) return; + if (!signedIn) return; const showChats = (chats: Chat[]) => { const withFallback = chats.length > 0 ? chats : [makeNewChat()]; @@ -92,8 +108,14 @@ export function AiSidePanel({ setActiveId(withFallback[0].id); }; - if (sessions.length > 0) { - showChats(sessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); + // The persisted store is one localStorage key shared by every account that + // has used this browser. Hand it to whoever is signed in now; if it was + // someone else's, it gets wiped and we fall through to a fresh fetch. + const switchedAccount = claimFor(currentUserKey()); + const cachedSessions = switchedAccount ? [] : sessions; + + if (cachedSessions.length > 0) { + showChats(cachedSessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); return; } listChats() @@ -116,24 +138,50 @@ export function AiSidePanel({ const cached = getCachedMessages(chat.sessionId); if (cached && chat.parentMessageId) { setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages: cached } : c)) + prev.map((c) => (c.id === activeId ? { ...c, messages: cached, historyLoaded: true } : c)) ); return; } + const sessionId = chat.sessionId; + setLoadError(null); setLoadingMessages(true); - getChat(chat.sessionId) + getChat(sessionId) .then((detail) => { const messages = toSimpleMessages(detail.messages); const parentMessageId = detail.messages.at(-1)?.id ?? null; - setMessages(chat.sessionId!, messages); + setMessages(sessionId, messages); setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages, parentMessageId } : c)) + prev.map((c) => + c.id === activeId ? { ...c, messages, parentMessageId, historyLoaded: true } : c + ) ); }) - .catch(() => {}) + .catch((e: unknown) => { + // 404 means the session isn't ours (or no longer exists) — a stale + // entry we should forget, not retry. Dropping it also stops the panel + // waiting on history that is never going to arrive. + if (e instanceof HttpError && (e.status === 404 || e.status === 403)) { + removeSession(sessionId); + setLocalChats((prev) => { + const next = prev.filter((c) => c.sessionId !== sessionId); + return next.length > 0 ? next : [makeNewChat()]; + }); + return; + } + setLoadError(e instanceof Error ? e.message : 'Failed to load this chat.'); + }) .finally(() => setLoadingMessages(false)); - }, [activeId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [activeId, reloadNonce]); // eslint-disable-line react-hooks/exhaustive-deps + + const retryLoad = useCallback(() => setReloadNonce((n) => n + 1), []); + + // Keep a valid selection when the active chat is dropped above. + useEffect(() => { + if (localChats.length > 0 && !localChats.some((c) => c.id === activeId)) { + setActiveId(localChats[0]!.id); + } + }, [localChats, activeId]); const activeChat = localChats.find((c) => c.id === activeId) ?? null; @@ -254,7 +302,7 @@ export function AiSidePanel({
{/* Chat controls appear only once the user has chosen a model. */} - {keycloak.authenticated && configured && ( + {signedIn && configured && ( <> )} - {keycloak.authenticated ? ( + {/* Signed out, the panel body below is the sign-in surface — it has + to be, now that there is more than one provider to choose from. */} + {signedIn && ( - ) : ( - )}
- {!keycloak.authenticated ? ( + {!signedIn ? (

Sign in to use the AI assistant.

- +
) : !configured ? ( // Onboarding gate: must pick a model before anything else is reachable. @@ -307,7 +347,17 @@ export function AiSidePanel({ onDelete={deleteChat} /> ) : activeChat ? ( - loadingMessages || (activeChat.sessionId !== null && activeChat.messages.length === 0) ? ( + loadError ? ( +
+

{loadError}

+ +
+ ) : loadingMessages || !activeChat.historyLoaded ? (
@@ -326,9 +376,7 @@ export function AiSidePanel({ ) : null} {/* Usage-tracking consent — required before using the AI; must accept or the panel closes. */} - {keycloak.authenticated && !termsAccepted && ( - - )} + {signedIn && !termsAccepted && } ); } diff --git a/src/main.tsx b/src/main.tsx index 80f930d..f4a14fd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,39 +2,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './index.css'; -import keycloak from './api/keycloak'; +import { initAuth } from './api/auth'; -// Restore saved tokens so users stay logged in across page refreshes. -const savedToken = localStorage.getItem('kc_token') ?? undefined; -const savedRefreshToken = localStorage.getItem('kc_refresh_token') ?? undefined; - -// Keep tokens fresh in localStorage whenever Keycloak refreshes them. -keycloak.onTokenExpired = () => { - keycloak - .updateToken(30) - .then(() => { - localStorage.setItem('kc_token', keycloak.token!); - localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); - }) - .catch(() => { - localStorage.removeItem('kc_token'); - localStorage.removeItem('kc_refresh_token'); - }); -}; - -keycloak - .init({ checkLoginIframe: false, token: savedToken, refreshToken: savedRefreshToken }) - .then((authenticated) => { - if (authenticated) { - localStorage.setItem('kc_token', keycloak.token!); - localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); - } - }) - .catch(() => {}) - .finally(() => { - createRoot(document.getElementById('root')!).render( - - - - ); - }); +// Restores a saved session (Keycloak tokens, or a Google session token) and +// picks up the redirect back from Google before the first render. +initAuth().finally(() => { + createRoot(document.getElementById('root')!).render( + + + + ); +}); diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 3b42131..fc190ca 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -15,7 +15,8 @@ import { User, X, } from 'lucide-react'; -import keycloak from '../api/keycloak'; +import { isAuthenticated, logout } from '../api/auth'; +import { SignInButtons } from '../components/auth/SignInButtons'; import { getAccount, getUsage, @@ -35,7 +36,8 @@ import { import { PROVIDER_OPTIONS, useByokDraft } from '../components/ai/byok'; /** - * Google-Account-style manager for the user's Keycloak account: + * Google-Account-style manager for the user's Praxly account (Keycloak or + * Google — whichever they signed in with): * personal info (email/name), security (password), and data & activity * (usage stats + chat history management). */ @@ -123,7 +125,7 @@ export default function AccountPage() { }, []); useEffect(() => { - if (!keycloak.authenticated) { + if (!isAuthenticated()) { setLoading(false); return; } @@ -137,15 +139,15 @@ export default function AccountPage() { ); }, []); - if (!keycloak.authenticated) { + if (!isAuthenticated()) { return (

Praxly Account

Sign in to manage your account.

- +
+ +
Back to editor @@ -174,7 +176,7 @@ export default function AccountPage() {