feat(oidc): show profile pic if present (#2006)

* feat(oidc): fetch profile picture

* small imprvements

* Add link to provider

* fix(rightnav): add cursor-pointer on hover

* adjust picture border and layout in RightNav and Account components

* cleanup

* oidc claims struct

* check if profile_picture exists

* simplify profile picture error handling

* adhere to autobrr log style

* fix: remove unused imports

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
soup 2025-04-13 17:45:30 +02:00 committed by GitHub
parent a8c4114d6d
commit 1c23b5df57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 147 additions and 48 deletions

View file

@ -13,6 +13,7 @@ type Primitive = string | number | boolean | symbol | undefined;
type ValidateResponse = {
username?: AuthInfo['username'];
auth_method?: AuthInfo['authMethod'];
profile_picture?: AuthInfo['profilePicture'];
}
interface HttpConfig {
@ -262,10 +263,10 @@ export const APIClient = {
{ body: req }),
getOIDCConfig: async () => {
try {
return await appClient.Get<{ enabled: boolean; authorizationUrl: string; state: string; disableBuiltInLogin: boolean }>("api/auth/oidc/config");
return await appClient.Get<{ enabled: boolean; authorizationUrl: string; state: string; disableBuiltInLogin: boolean; issuerUrl: string }>("api/auth/oidc/config");
} catch (error: unknown) {
if (error instanceof Error && error.message?.includes('404')) {
return { enabled: false, authorizationUrl: '', state: '', disableBuiltInLogin: false };
return { enabled: false, authorizationUrl: '', state: '', disableBuiltInLogin: false, issuerUrl: '' };
}
throw error;
}

View file

@ -19,7 +19,6 @@ import { AuthContext, SettingsContext } from "@utils/Context";
export const RightNav = (props: RightNavProps) => {
const [settings, setSettings] = SettingsContext.use();
const auth = AuthContext.get();
const toggleTheme = () => {
@ -35,7 +34,7 @@ export const RightNav = (props: RightNavProps) => {
<div className="mt-1 items-center">
<button
onClick={toggleTheme}
className="p-1 rounded-full focus:outline-hidden focus:none transition duration-100 ease-out transform hover:bg-gray-200 dark:hover:bg-gray-800 hover:scale-100"
className="p-1 rounded-full hover:cursor-pointer focus:outline-hidden focus:none transition duration-100 ease-out transform hover:bg-gray-200 dark:hover:bg-gray-800 hover:scale-100"
title={settings.darkTheme ? "Switch to light mode (currently dark mode)" : "Switch to dark mode (currently light mode)"}
>
{settings.darkTheme ? (
@ -56,25 +55,34 @@ export const RightNav = (props: RightNavProps) => {
"transition duration-200"
)}
>
<span className="hidden text-sm font-medium sm:block">
<span className="hidden hover:cursor-pointer text-sm font-medium sm:flex items-center">
<span className="sr-only">
Open user menu for{" "}
</span>
<span className="flex items-center">
{auth.username}
{auth.authMethod === 'oidc' ? (
<span className="flex items-center">{auth.username}</span>
{auth.authMethod === 'oidc' ? (
auth.profilePicture ? (
<div className="relative flex-shrink-0 ml-2 w-6 h-6 overflow-hidden rounded-full ring-1 ring-white dark:ring-gray-700">
<img
src={auth.profilePicture}
alt={`${auth.username}'s profile`}
className="object-cover w-full h-full transition-opacity duration-200"
onError={() => auth.profilePicture = undefined}
/>
</div>
) : (
<FontAwesomeIcon
icon={faOpenid}
className="inline ml-1 h-4 w-4 text-gray-500 dark:text-gray-500"
aria-hidden="true"
/>
) : (
<UserIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
)}
</span>
)
) : (
<UserIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
)}
</span>
</MenuButton>
<Transition

View file

@ -304,10 +304,19 @@ export const AuthRoute = createRoute({
if (!AuthContext.get().isLoggedIn) {
try {
const response = await APIClient.auth.validate();
// Also get OIDC config if needed
let issuerUrl;
if (response.auth_method === 'oidc') {
const oidcConfig = await APIClient.auth.getOIDCConfig();
issuerUrl = oidcConfig.issuerUrl;
}
AuthContext.set({
isLoggedIn: true,
username: response.username || 'unknown',
authMethod: response.auth_method
authMethod: response.auth_method,
profilePicture: response.profile_picture,
issuerUrl: issuerUrl
});
} catch (error) {
console.debug("Authentication validation failed:", error);

View file

@ -30,6 +30,7 @@ type LoginFormFields = {
type ValidateResponse = {
username?: AuthInfo['username'];
auth_method?: AuthInfo['authMethod'];
profile_picture?: AuthInfo['profilePicture'];
}
export const Login = () => {
@ -85,7 +86,8 @@ export const Login = () => {
setAuth({
isLoggedIn: true,
username: response.username || 'unknown',
authMethod: response.auth_method || (oidcConfig?.enabled ? 'oidc' : 'password')
authMethod: response.auth_method || (oidcConfig?.enabled ? 'oidc' : 'password'),
profilePicture: response.profile_picture,
});
router.invalidate();
}).catch((error) => {

View file

@ -160,6 +160,21 @@ function Credentials() {
}
function OIDCAccount() {
const auth = AuthContext.get();
// Helper function to format the issuer URL for display
const getFormattedIssuerName = () => {
if (!auth.issuerUrl) return "your identity provider";
try {
const url = new URL(auth.issuerUrl);
// Return domain name without 'www.'
return url.hostname.replace(/^www\./i, '');
} catch {
return "your identity provider";
}
};
return (
<Section
titleElement={
@ -172,6 +187,52 @@ function OIDCAccount() {
description="Your account credentials are managed by your OpenID Connect provider. To change your username, please visit your provider's settings page and log in again."
noLeftPadding
>
<div className="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 transition duration-150">
<div className="flex flex-col sm:flex-row items-center">
<div className="flex-shrink-0 relative">
{auth.profilePicture ? (
<img
src={auth.profilePicture}
alt={`${auth.username}'s profile picture`}
className="h-16 w-16 sm:h-20 sm:w-20 rounded-full object-cover border-1 border-gray-200 dark:border-gray-700 transition duration-200"
onError={() => auth.profilePicture = undefined}
/>
) : (
<div className="h-16 w-16 sm:h-20 sm:w-20 rounded-full flex items-center justify-center bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 transition duration-200">
<FontAwesomeIcon
icon={faOpenid}
className="h-16 w-16 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
</div>
)}
</div>
<div className="mt-4 sm:mt-0 sm:ml-6 text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">
{auth.username}
</h3>
<div className="mt-1 flex items-center text-sm text-gray-500 dark:text-gray-400">
<FontAwesomeIcon icon={faOpenid} className="mr-1.5 h-4 w-4 flex-shrink-0" />
<p>Authenticated via OpenID Connect</p>
</div>
{auth.issuerUrl && (
<div className="mt-2">
<a
href={auth.issuerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded-md text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-800 transition-colors duration-150"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{getFormattedIssuerName()}
</a>
</div>
)}
</div>
</div>
</div>
</Section>
);
}

View file

@ -24,13 +24,17 @@ export interface AuthInfo {
username: string;
isLoggedIn: boolean;
authMethod?: 'password' | 'oidc';
profilePicture?: string;
issuerUrl?: string;
}
// Default values
const AuthContextDefaults: AuthInfo = {
username: "",
isLoggedIn: false,
authMethod: undefined
authMethod: undefined,
profilePicture: undefined,
issuerUrl: undefined
};
const SettingsContextDefaults: SettingsType = {