Add login page with Microsoft SSO button
Dark heatmap-inspired design with animated concentric rings, OliVAS wordmark, and a clean Microsoft sign-in button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5e7b7a1fab
commit
1f83ed8c82
2 changed files with 282 additions and 17 deletions
|
|
@ -1,22 +1,13 @@
|
|||
import { useEffect } from "react";
|
||||
import { useIsAuthenticated, useMsal } from "@azure/msal-react";
|
||||
import { InteractionStatus } from "@azure/msal-browser";
|
||||
import { loginRequest } from "./msalConfig";
|
||||
import LoadingSpinner from "../components/common/LoadingSpinner";
|
||||
import Login from "../pages/Login";
|
||||
|
||||
export default function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { inProgress, instance } = useMsal();
|
||||
const { inProgress } = useMsal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && inProgress === InteractionStatus.None) {
|
||||
instance.loginRedirect(loginRequest).catch((e) => {
|
||||
console.error("[Auth] Login redirect failed:", e);
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, inProgress, instance]);
|
||||
|
||||
if (inProgress !== InteractionStatus.None) {
|
||||
if (inProgress === InteractionStatus.HandleRedirect) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner size="lg" message="Authenticating..." />
|
||||
|
|
@ -25,11 +16,7 @@ export default function RequireAuth({ children }: { children: React.ReactNode })
|
|||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner size="lg" message="Redirecting to login..." />
|
||||
</div>
|
||||
);
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
|
|
|||
278
frontend/src/pages/Login.tsx
Normal file
278
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { useState } from "react";
|
||||
import { useMsal } from "@azure/msal-react";
|
||||
import { InteractionStatus } from "@azure/msal-browser";
|
||||
import { loginRequest } from "../auth/msalConfig";
|
||||
|
||||
export default function Login() {
|
||||
const { instance, inProgress } = useMsal();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isLoading = inProgress !== InteractionStatus.None;
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} catch (e) {
|
||||
setError("Sign-in failed. Please try again.");
|
||||
console.error("[Auth] Login redirect failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
{/* Ambient heatmap glow */}
|
||||
<div style={styles.glow} />
|
||||
|
||||
{/* Concentric rings — focal point motif */}
|
||||
<div style={styles.rings} aria-hidden="true">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} style={{ ...styles.ring, ...ringVariant(i) }} />
|
||||
))}
|
||||
<div style={styles.ringDot} />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div style={styles.card}>
|
||||
{/* Wordmark */}
|
||||
<div style={styles.wordmark}>
|
||||
<span style={styles.wordmarkOl}>Ol</span>
|
||||
<span style={styles.wordmarkIvas}>i</span>
|
||||
<span style={styles.wordmarkVas}>VAS</span>
|
||||
</div>
|
||||
<p style={styles.tagline}>Visual Attention Analysis</p>
|
||||
|
||||
<div style={styles.divider} />
|
||||
|
||||
{/* Sign in button */}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
...styles.msButton,
|
||||
...(isLoading ? styles.msButtonDisabled : {}),
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
!isLoading &&
|
||||
Object.assign((e.target as HTMLElement).style, styles.msButtonHover)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
!isLoading &&
|
||||
Object.assign(
|
||||
(e.target as HTMLElement).style,
|
||||
styles.msButton,
|
||||
isLoading ? styles.msButtonDisabled : {},
|
||||
)
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
<span>Redirecting…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicrosoftLogo />
|
||||
<span>Sign in with Microsoft</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && <p style={styles.error}>{error}</p>}
|
||||
|
||||
<p style={styles.hint}>
|
||||
Access is restricted to Oliver agency accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{keyframes}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────────────────────── */
|
||||
|
||||
function MicrosoftLogo() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 21 21" fill="none">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ animation: "spin 0.8s linear infinite" }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="#666" strokeWidth="3" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="#ffc407" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ring variants ──────────────────────────────────────────────── */
|
||||
|
||||
function ringVariant(i: number): React.CSSProperties {
|
||||
const size = 80 + i * 72;
|
||||
const delay = i * 0.4;
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: 0.12 - i * 0.018,
|
||||
animationDelay: `${delay}s`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
root: {
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#0d0e10",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
glow: {
|
||||
position: "absolute",
|
||||
width: 600,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(255,196,7,0.08) 0%, transparent 70%)",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
rings: {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
ring: {
|
||||
position: "absolute",
|
||||
borderRadius: "50%",
|
||||
border: "1px solid #ffc407",
|
||||
animation: "pulse 4s ease-in-out infinite",
|
||||
},
|
||||
ringDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#ffc407",
|
||||
opacity: 0.6,
|
||||
position: "absolute",
|
||||
},
|
||||
card: {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "52px 48px 40px",
|
||||
borderRadius: 20,
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow: "0 32px 64px rgba(0,0,0,0.4)",
|
||||
minWidth: 340,
|
||||
},
|
||||
wordmark: {
|
||||
fontSize: 38,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.03em",
|
||||
lineHeight: 1,
|
||||
marginBottom: 8,
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
},
|
||||
wordmarkOl: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
wordmarkIvas: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
wordmarkVas: {
|
||||
color: "#ffc407",
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
margin: 0,
|
||||
fontWeight: 500,
|
||||
},
|
||||
divider: {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
margin: "32px 0",
|
||||
},
|
||||
msButton: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "11px 22px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
fontFamily: "inherit",
|
||||
letterSpacing: "0.01em",
|
||||
},
|
||||
msButtonHover: {
|
||||
background: "rgba(255,255,255,0.09)",
|
||||
borderColor: "rgba(255,196,7,0.3)",
|
||||
color: "#ffffff",
|
||||
},
|
||||
msButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
error: {
|
||||
marginTop: 16,
|
||||
fontSize: 13,
|
||||
color: "#f87171",
|
||||
textAlign: "center",
|
||||
},
|
||||
hint: {
|
||||
marginTop: 20,
|
||||
fontSize: 11.5,
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
const keyframes = `
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: inherit; }
|
||||
50% { transform: scale(1.04); opacity: 0.05; }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
Loading…
Add table
Reference in a new issue