added oidc adapter
Some checks failed
CI / Rustfmt (push) Failing after 56s
CI / Clippy (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 50s
CI / Cargo Audit & Deny (push) Successful in 2m2s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 41s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Failing after 13s
Publish Images And Chart / Publish init-user (push) Failing after 11s
CI / Web Advisory Checks (push) Successful in 1m38s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish sensor (push) Failing after 31s
Publish Images And Chart / Publish api (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Tests (push) Successful in 1h34m2s
Some checks failed
CI / Rustfmt (push) Failing after 56s
CI / Clippy (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 50s
CI / Cargo Audit & Deny (push) Successful in 2m2s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 41s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Failing after 13s
Publish Images And Chart / Publish init-user (push) Failing after 11s
CI / Web Advisory Checks (push) Successful in 1m38s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish sensor (push) Failing after 31s
Publish Images And Chart / Publish api (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Tests (push) Successful in 1h34m2s
This commit is contained in:
@@ -9,6 +9,7 @@ import MainLayout from "@/components/layout/MainLayout";
|
||||
|
||||
// Lazy-loaded page components for code splitting
|
||||
const LoginPage = lazy(() => import("@/pages/auth/LoginPage"));
|
||||
const OidcCallbackPage = lazy(() => import("@/pages/auth/OidcCallbackPage"));
|
||||
const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage"));
|
||||
const PacksPage = lazy(() => import("@/pages/packs/PacksPage"));
|
||||
const PackCreatePage = lazy(() => import("@/pages/packs/PackCreatePage"));
|
||||
@@ -68,6 +69,7 @@ function App() {
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/callback" element={<OidcCallbackPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Package,
|
||||
@@ -180,7 +180,6 @@ function NavLink({
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
// Initialize from localStorage
|
||||
@@ -206,7 +205,6 @@ export default function MainLayout() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { AuthService, ApiError } from "@/api";
|
||||
import type { UserInfo, LoginRequest } from "@/api";
|
||||
import type { UserInfo } from "@/api";
|
||||
import {
|
||||
startTokenRefreshMonitor,
|
||||
stopTokenRefreshMonitor,
|
||||
@@ -17,10 +17,14 @@ interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
login: (redirectTo?: string) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
getToken: () => string | null;
|
||||
completeLogin: (params: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -73,29 +77,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
try {
|
||||
const response = await AuthService.login({
|
||||
requestBody: credentials,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token, user: userInfo } = response.data;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
|
||||
// If user info is included in response, use it; otherwise load it
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
} else {
|
||||
await loadUser();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
if (error instanceof ApiError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const login = (redirectTo?: string) => {
|
||||
const redirectParam = redirectTo
|
||||
? `?redirect_to=${encodeURIComponent(redirectTo)}`
|
||||
: "";
|
||||
window.location.href = `/auth/oidc/login${redirectParam}`;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -103,6 +89,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
localStorage.removeItem("refresh_token");
|
||||
stopTokenRefreshMonitor();
|
||||
setUser(null);
|
||||
window.location.href = "/auth/logout";
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
@@ -113,6 +100,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
return localStorage.getItem("access_token");
|
||||
};
|
||||
|
||||
const completeLogin = async ({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
localStorage.setItem("access_token", accessToken);
|
||||
localStorage.setItem("refresh_token", refreshToken);
|
||||
await loadUser();
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
@@ -121,6 +120,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
logout,
|
||||
refreshUser,
|
||||
getToken,
|
||||
completeLogin,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ApiError, AuthService } from "@/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import apiClient from "@/lib/api-client";
|
||||
|
||||
interface LocationState {
|
||||
from?: {
|
||||
@@ -8,65 +10,142 @@ interface LocationState {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginError {
|
||||
response?: {
|
||||
status: number;
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
interface AuthSettingsResponse {
|
||||
authentication_enabled: boolean;
|
||||
local_password_enabled: boolean;
|
||||
local_password_visible_by_default: boolean;
|
||||
oidc_enabled: boolean;
|
||||
oidc_visible_by_default: boolean;
|
||||
oidc_provider_name: string | null;
|
||||
oidc_provider_label: string | null;
|
||||
oidc_provider_icon_url: string | null;
|
||||
self_registration_enabled: boolean;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login: authLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login: startOidcLogin, completeLogin } = useAuth();
|
||||
const [settings, setSettings] = useState<AuthSettingsResponse | null>(null);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [overrideError, setOverrideError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [credentials, setCredentials] = useState({ login: "", password: "" });
|
||||
|
||||
// Check for redirect path from session storage (set by axios interceptor on 401)
|
||||
const redirectPath = sessionStorage.getItem("redirect_after_login");
|
||||
const from =
|
||||
redirectPath || (location.state as LocationState)?.from?.pathname || "/";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
useEffect(() => {
|
||||
const loadAuthSettings = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<{ data: AuthSettingsResponse }>(
|
||||
"/auth/settings",
|
||||
);
|
||||
setSettings(response.data.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load auth settings:", error);
|
||||
setSettingsError("Unable to load authentication options.");
|
||||
} finally {
|
||||
setIsLoadingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAuthSettings();
|
||||
}, []);
|
||||
|
||||
const authOverride = new URLSearchParams(location.search)
|
||||
.get("auth")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const localEnabled = settings?.local_password_enabled ?? false;
|
||||
const oidcEnabled = settings?.oidc_enabled ?? false;
|
||||
const authEnabled = settings?.authentication_enabled ?? true;
|
||||
const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null;
|
||||
const providerLabel =
|
||||
settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO";
|
||||
|
||||
let showLocal = settings?.local_password_visible_by_default ?? false;
|
||||
let showOidc = settings?.oidc_visible_by_default ?? false;
|
||||
|
||||
if (authOverride === "direct") {
|
||||
if (localEnabled) {
|
||||
showLocal = true;
|
||||
showOidc = false;
|
||||
}
|
||||
} else if (authOverride && providerName && authOverride === providerName) {
|
||||
if (oidcEnabled) {
|
||||
showLocal = false;
|
||||
showOidc = true;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authOverride || !settings) {
|
||||
setOverrideError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authOverride === "direct") {
|
||||
setOverrideError(
|
||||
localEnabled
|
||||
? null
|
||||
: "Local login was requested, but it is not available on this server.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerName && authOverride === providerName) {
|
||||
setOverrideError(
|
||||
oidcEnabled
|
||||
? null
|
||||
: `${providerLabel} was requested, but it is not available on this server.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOverrideError(
|
||||
`Unknown authentication override '${authOverride}'. Falling back to the server defaults.`,
|
||||
);
|
||||
}, [authOverride, localEnabled, oidcEnabled, providerLabel, providerName, settings]);
|
||||
|
||||
const handleOidcLogin = () => {
|
||||
sessionStorage.setItem("redirect_after_login", from);
|
||||
startOidcLogin(from);
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoginError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await authLogin({ login, password });
|
||||
|
||||
// Clear the redirect path from session storage
|
||||
const response = await AuthService.login({
|
||||
requestBody: credentials,
|
||||
});
|
||||
await completeLogin({
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token,
|
||||
});
|
||||
sessionStorage.removeItem("redirect_after_login");
|
||||
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const loginErr = err as LoginError;
|
||||
console.error("Login error:", loginErr);
|
||||
if (loginErr.response) {
|
||||
console.error("Response status:", loginErr.response.status);
|
||||
console.error("Response data:", loginErr.response.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setLoginError(error.message);
|
||||
} else {
|
||||
setLoginError("Failed to sign in.");
|
||||
}
|
||||
const errorMessage =
|
||||
loginErr.response?.data?.message ||
|
||||
loginErr.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
setError(errorMessage);
|
||||
// Don't navigate on error - stay on login page
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="max-w-md w-full">
|
||||
<div>
|
||||
<h1 className="text-center text-4xl font-bold text-gray-900">
|
||||
Attune
|
||||
@@ -75,68 +154,135 @@ export default function LoginPage() {
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={handleSubmit}
|
||||
action="#"
|
||||
method="post"
|
||||
>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
<div className="mt-8 rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
|
||||
{isLoadingSettings ? (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900" />
|
||||
Loading authentication options...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{settingsError ? (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||
{settingsError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="login" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{overrideError ? (
|
||||
<div className="mb-4 rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
||||
{overrideError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!authEnabled ? (
|
||||
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Authentication is disabled in the current server
|
||||
configuration.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authEnabled && showLocal ? (
|
||||
<form className="space-y-4" onSubmit={handleLocalLogin}>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Login
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={credentials.login}
|
||||
onChange={(event) =>
|
||||
setCredentials((current) => ({
|
||||
...current,
|
||||
login: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={credentials.password}
|
||||
onChange={(event) =>
|
||||
setCredentials((current) => ({
|
||||
...current,
|
||||
password: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{loginError ? (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||
{loginError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{authEnabled && showLocal && showOidc ? (
|
||||
<div className="my-6 flex items-center gap-3 text-xs uppercase tracking-[0.24em] text-gray-400">
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
or
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authEnabled && showOidc ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Continue with your configured single sign-on provider.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOidcLogin}
|
||||
className="group relative flex w-full items-center justify-center gap-3 rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
{settings?.oidc_provider_icon_url ? (
|
||||
<img
|
||||
src={settings.oidc_provider_icon_url}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded-sm bg-white/10 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<span>Continue with {providerLabel}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!settingsError && authEnabled && !showLocal && !showOidc ? (
|
||||
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
||||
No login method is shown by default for this server. Use
|
||||
`?auth=direct`
|
||||
{providerName ? ` or ?auth=${providerName}` : ""} to choose
|
||||
a specific method.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
63
web/src/pages/auth/OidcCallbackPage.tsx
Normal file
63
web/src/pages/auth/OidcCallbackPage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function parseHashParams(hash: string): URLSearchParams {
|
||||
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
return new URLSearchParams(fragment);
|
||||
}
|
||||
|
||||
export default function OidcCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const { completeLogin } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const finalizeLogin = async () => {
|
||||
const params = parseHashParams(window.location.hash);
|
||||
const accessToken = params.get("access_token");
|
||||
const refreshToken = params.get("refresh_token");
|
||||
const redirectTo = params.get("redirect_to") || "/";
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError("Missing login tokens in OIDC callback response.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completeLogin({ accessToken, refreshToken });
|
||||
sessionStorage.removeItem("redirect_after_login");
|
||||
navigate(redirectTo, { replace: true });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to complete login.";
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
void finalizeLogin();
|
||||
}, [completeLogin, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Completing sign-in
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-gray-600">
|
||||
Attune is finalizing your authenticated session.
|
||||
</p>
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 flex items-center gap-3 text-sm text-gray-600">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900" />
|
||||
Redirecting...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user