Add Keycloak Google SSO login UI
This commit is contained in:
parent
35fb6cf5b7
commit
406d1f5886
|
|
@ -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())
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue