From 406d1f58861b564c3680ccd7d90a415fbbb75624 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Dec 2025 21:19:10 -0500 Subject: [PATCH] Add Keycloak Google SSO login UI --- public/service-worker.js | 53 +++++++---- src/auth/AuthContext.jsx | 145 ++++++++++++++++++++++++++++++- src/components/Layout/TopBar.jsx | 29 +++++++ src/styles/auth-modal.css | 57 ++++++++++++ 4 files changed, 268 insertions(+), 16 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index 2d11506..009776e 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,23 +1,46 @@ // public/service-worker.js +const CACHE_NAME = 'sociowire-v2'; +const PRECACHE = [ + '/', + '/index.html', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', + '/icons/apple-touch-icon.png', + '/icons/og-image.png' +]; + self.addEventListener('install', event => { + self.skipWaiting(); event.waitUntil( - caches.open('sociowire-v1').then(cache => { - return cache.addAll([ - '/', - '/index.html', - '/icons/icon-192x192.png', - '/icons/icon-512x512.png', - '/icons/apple-touch-icon.png', - '/icons/og-image.png' - ]); + caches.open(CACHE_NAME).then(cache => { + return cache.addAll(PRECACHE); }) ); }); -self.addEventListener('fetch', event => { - event.respondWith( - caches.match(event.request).then(response => { - return response || fetch(event.request); - }) +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.map(key => (key === CACHE_NAME ? null : caches.delete(key)))) + ).then(() => self.clients.claim()) ); -}); \ No newline at end of file +}); + +self.addEventListener('fetch', event => { + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request) + .then(response => { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put('/index.html', copy)); + return response; + }) + .catch(() => caches.match('/index.html')) + ); + return; + } + + event.respondWith( + caches.match(event.request).then(response => response || fetch(event.request)) + ); +}); diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index e2c1fd9..ede193c 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import Keycloak from "keycloak-js"; import { clearAuth, guessUsernameFromToken, @@ -11,6 +12,36 @@ import { parseAuthError } from "./authErrors"; const AuthCtx = createContext(null); +const KC_URL = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, ""); +const KC_REALM = import.meta?.env?.VITE_KC_REALM || "sociowire"; +const KC_CLIENT_ID = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend"; +const KC_SSO_ENABLED = (import.meta?.env?.VITE_KC_SSO || "true") !== "false"; + +let kcInstance = null; +function getKeycloak() { + if (!kcInstance) { + kcInstance = new Keycloak({ + url: KC_URL, + realm: KC_REALM, + clientId: KC_CLIENT_ID, + }); + } + return kcInstance; +} + +function keycloakEnabled() { + return !!(KC_SSO_ENABLED && KC_URL && KC_REALM && KC_CLIENT_ID); +} + +function hasKeycloakCallbackParams() { + try { + const params = new URLSearchParams(window.location.search); + return params.has("code") || params.has("error") || params.has("kc_error"); + } catch { + return false; + } +} + function apiBase() { return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); } @@ -373,8 +404,118 @@ export function AuthProvider({ children }) { return { ok: true }; } + async function completeKeycloakLogin() { + if (!keycloakEnabled()) return { ok: false, reason: "disabled" }; + + setStatus("loading"); + setError(""); + setLastInfo(""); + + let kc = null; + try { + kc = getKeycloak(); + } catch (err) { + setAnon("SSO failed to initialize. Please try again."); + return { ok: false, reason: "init_failed", detail: err?.message || "" }; + } + + const params = new URLSearchParams(window.location.search); + const errCode = params.get("error") || params.get("kc_error") || ""; + const errDesc = params.get("error_description") || params.get("kc_error_description") || ""; + + if (errCode) { + setAnon(`SSO failed: ${errCode}${errDesc ? " - " + errDesc : ""}`); + cleanupAuthUrl(); + return { ok: false, reason: "kc_error", detail: errDesc || errCode }; + } + + let ok = false; + try { + ok = await kc.init({ + onLoad: "check-sso", + pkceMethod: "S256", + checkLoginIframe: false, + }); + } catch (err) { + setAnon("SSO failed to finish. Please try again."); + cleanupAuthUrl(); + return { ok: false, reason: "kc_init_failed", detail: err?.message || "" }; + } + + if (!ok || !kc.authenticated || !kc.token) { + setAnon("SSO login failed. Please try again."); + cleanupAuthUrl(); + return { ok: false, reason: "not_authenticated" }; + } + + const t = { + access_token: kc.token, + refresh_token: kc.refreshToken || "", + id_token: kc.idToken || "", + token_type: "bearer", + obtained_at: Date.now(), + }; + + setAuthFromTokens(t); + scheduleAutoRefresh(); + + const w = await whoami(t.access_token); + if (!w.ok || !w.authenticated) { + setAnon("SSO login desynced. Please login again."); + cleanupAuthUrl(); + return { ok: false, reason: "whoami_failed" }; + } + + setUser(w.username ? { username: w.username } : null); + setStatus("auth"); + await refreshProfile(t.access_token); + + cleanupAuthUrl(); + return { ok: true }; + } + + function cleanupAuthUrl() { + try { + const clean = window.location.origin + window.location.pathname + window.location.hash; + window.history.replaceState({}, document.title, clean); + } catch {} + } + + function loginWithProvider(provider) { + if (!keycloakEnabled()) { + setAnon("SSO is not configured."); + return; + } + const redirectUri = window.location.origin + window.location.pathname; + try { + const kc = getKeycloak(); + kc.login({ + redirectUri, + idpHint: provider || "", + }); + } catch (err) { + try { + const base = KC_URL.replace(/\/+$/, ""); + const params = new URLSearchParams({ + client_id: KC_CLIENT_ID, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + }); + if (provider) params.set("kc_idp_hint", provider); + window.location.href = `${base}/realms/${KC_REALM}/protocol/openid-connect/auth?${params.toString()}`; + } catch { + setAnon("SSO failed to start. Please try again."); + } + } + } + useEffect(() => { - restore(); + if (hasKeycloakCallbackParams()) { + completeKeycloakLogin(); + } else { + restore(); + } const onStorage = (e) => { if (e.key === "sociowire:auth") restore(); }; @@ -423,6 +564,8 @@ export function AuthProvider({ children }) { lastInfo: lastInfo || "", needsEmailVerification: !!needsEmailVerification, resendVerificationEmail, + loginWithProvider, + ssoEnabled: keycloakEnabled(), login, logout, diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 50997bf..079ffb4 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -60,6 +60,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } username, login, logout, + loginWithProvider, + ssoEnabled, needsEmailVerification, lastError, lastInfo, @@ -434,6 +436,33 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } ⚠️ Your account requires email verification. Confirm your email, then try logging in again. ) : null} + + {ssoEnabled ? ( + <> +
or continue with
+
+ + +
+ + ) : null} + ) : (
diff --git a/src/styles/auth-modal.css b/src/styles/auth-modal.css index 1893644..631acef 100644 --- a/src/styles/auth-modal.css +++ b/src/styles/auth-modal.css @@ -82,6 +82,63 @@ body[data-theme="light"] .auth-form input { .btn-full { width:100%; margin-top:.4rem; } +.auth-divider{ + display:flex; + align-items:center; + justify-content:center; + gap:.6rem; + margin:.85rem 0 .65rem; + font-size:.7rem; + text-transform:uppercase; + letter-spacing:.08em; + color: rgba(226, 232, 240, 0.75); +} +.auth-divider::before, +.auth-divider::after{ + content:""; + flex:1; + height:1px; + background: rgba(148, 163, 184, 0.35); +} + +.auth-oauth{ + display:flex; + flex-direction:column; + gap:.55rem; +} +.btn-oauth{ + display:flex; + align-items:center; + justify-content:center; + gap:.5rem; + width:100%; + border-radius:999px; + padding:.6rem .85rem; + border: 1px solid rgba(148, 163, 184, 0.5); + background: rgba(15, 23, 42, 0.85); + color: #e5e7eb; + font-weight: 700; + font-size: .85rem; + cursor: pointer; +} +.btn-oauth:hover{ + border-color: rgba(56, 189, 248, 0.9); + box-shadow: 0 8px 20px rgba(14, 116, 144, 0.25); +} +.btn-oauth i{ font-size: .95rem; } + +.btn-oauth-google{ background: rgba(234, 88, 12, 0.15); } +.btn-oauth-facebook{ background: rgba(37, 99, 235, 0.2); } + +body[data-theme="light"] .auth-divider{ color: rgba(15, 23, 42, 0.6); } +body[data-theme="light"] .auth-divider::before, +body[data-theme="light"] .auth-divider::after{ background: rgba(15, 23, 42, 0.12); } +body[data-theme="light"] .btn-oauth{ + background: rgba(255, 255, 255, 0.9); + color: #0f172a; + border-color: rgba(148, 163, 184, 0.7); +} + .auth-error { margin-top:.25rem; font-size:.8rem;