Add Keycloak Google SSO login UI

This commit is contained in:
Your Name 2025-12-25 21:19:10 -05:00
parent 35fb6cf5b7
commit 406d1f5886
4 changed files with 268 additions and 16 deletions

View File

@ -1,23 +1,46 @@
// public/service-worker.js
self.addEventListener('install', event => {
event.waitUntil(
caches.open('sociowire-v1').then(cache => {
return cache.addAll([
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(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())
);
});
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))
);
});

View File

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

View File

@ -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.
</div>
) : null}
{ssoEnabled ? (
<>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button
type="button"
className="btn-oauth btn-oauth-google"
disabled={loading}
onClick={() => loginWithProvider && loginWithProvider("google")}
>
<i className="fa-brands fa-google"></i>
<span>Continue with Google</span>
</button>
<button
type="button"
className="btn-oauth btn-oauth-facebook"
disabled={true}
title="Facebook SSO coming soon"
>
<i className="fa-brands fa-facebook-f"></i>
<span>Facebook (soon)</span>
</button>
</div>
</>
) : null}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>

View File

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