Add Keycloak Google SSO login UI
This commit is contained in:
parent
35fb6cf5b7
commit
406d1f5886
|
|
@ -1,23 +1,46 @@
|
||||||
// public/service-worker.js
|
// public/service-worker.js
|
||||||
self.addEventListener('install', event => {
|
const CACHE_NAME = 'sociowire-v2';
|
||||||
event.waitUntil(
|
const PRECACHE = [
|
||||||
caches.open('sociowire-v1').then(cache => {
|
|
||||||
return cache.addAll([
|
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/icons/icon-192x192.png',
|
'/icons/icon-192x192.png',
|
||||||
'/icons/icon-512x512.png',
|
'/icons/icon-512x512.png',
|
||||||
'/icons/apple-touch-icon.png',
|
'/icons/apple-touch-icon.png',
|
||||||
'/icons/og-image.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 => {
|
self.addEventListener('activate', event => {
|
||||||
event.respondWith(
|
event.waitUntil(
|
||||||
caches.match(event.request).then(response => {
|
caches.keys().then(keys =>
|
||||||
return response || fetch(event.request);
|
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))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Keycloak from "keycloak-js";
|
||||||
import {
|
import {
|
||||||
clearAuth,
|
clearAuth,
|
||||||
guessUsernameFromToken,
|
guessUsernameFromToken,
|
||||||
|
|
@ -11,6 +12,36 @@ import { parseAuthError } from "./authErrors";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
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() {
|
function apiBase() {
|
||||||
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
@ -373,8 +404,118 @@ export function AuthProvider({ children }) {
|
||||||
return { ok: true };
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (hasKeycloakCallbackParams()) {
|
||||||
|
completeKeycloakLogin();
|
||||||
|
} else {
|
||||||
restore();
|
restore();
|
||||||
|
}
|
||||||
const onStorage = (e) => {
|
const onStorage = (e) => {
|
||||||
if (e.key === "sociowire:auth") restore();
|
if (e.key === "sociowire:auth") restore();
|
||||||
};
|
};
|
||||||
|
|
@ -423,6 +564,8 @@ export function AuthProvider({ children }) {
|
||||||
lastInfo: lastInfo || "",
|
lastInfo: lastInfo || "",
|
||||||
needsEmailVerification: !!needsEmailVerification,
|
needsEmailVerification: !!needsEmailVerification,
|
||||||
resendVerificationEmail,
|
resendVerificationEmail,
|
||||||
|
loginWithProvider,
|
||||||
|
ssoEnabled: keycloakEnabled(),
|
||||||
|
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }
|
||||||
username,
|
username,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
loginWithProvider,
|
||||||
|
ssoEnabled,
|
||||||
needsEmailVerification,
|
needsEmailVerification,
|
||||||
lastError,
|
lastError,
|
||||||
lastInfo,
|
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.
|
⚠️ Your account requires email verification. Confirm your email, then try logging in again.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
) : (
|
) : (
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,63 @@ body[data-theme="light"] .auth-form input {
|
||||||
|
|
||||||
.btn-full { width:100%; margin-top:.4rem; }
|
.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 {
|
.auth-error {
|
||||||
margin-top:.25rem;
|
margin-top:.25rem;
|
||||||
font-size:.8rem;
|
font-size:.8rem;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue