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

This commit is contained in:
2026-03-18 16:35:21 -05:00
parent 1d59ff5de4
commit 57fa3bf7cf
27 changed files with 2019 additions and 224 deletions

View File

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

View File

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

View File

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

View File

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

View 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>
);
}