From 3b55d4e51093ee2400cbc8362adfcda3b45583f2 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Sun, 5 Jan 2025 15:48:03 +0100 Subject: [PATCH] Experimental login This requires pulpcore >= 3.60 --- src/api/base.ts | 11 +-- src/api/pulp-login.ts | 8 +- src/api/pulp.ts | 12 +-- src/app-context.tsx | 26 +++--- src/app-routes.tsx | 19 +++-- src/components/delete-user-modal.tsx | 10 ++- src/containers/index.ts | 1 - src/containers/login/login.tsx | 89 -------------------- src/containers/settings/user-profile.tsx | 16 +--- src/containers/user-management/user-edit.tsx | 17 ---- src/layout.tsx | 20 +++-- src/menu.tsx | 8 +- src/routes/login.tsx | 72 ++++++++++++++++ src/routes/root.tsx | 22 +++-- src/user-context.tsx | 68 --------------- 15 files changed, 152 insertions(+), 247 deletions(-) delete mode 100644 src/containers/login/login.tsx create mode 100644 src/routes/login.tsx delete mode 100644 src/user-context.tsx diff --git a/src/api/base.ts b/src/api/base.ts index 4c1bf79d..d5612ef3 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -8,11 +8,12 @@ export class BaseAPI { constructor() { this.http = axios.create({ - // adapter + withCredentials ensures no popup on http basic auth fail - adapter: 'fetch', - withCredentials: false, - - // baseURL gets set in PulpAPI + // API_BASE_PATH gets set in pulp.ts. + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', + // This is what prevents Pulp from sending the "WWW-Authenticat: Basic *" header. + // In turn, firefox will not be asking for a password. + headers: { 'X-Requested-With': 'XMLHttpRequest' }, paramsSerializer: { serialize: (params) => ParamHelper.getQueryString(params), }, diff --git a/src/api/pulp-login.ts b/src/api/pulp-login.ts index 319fa878..3396642b 100644 --- a/src/api/pulp-login.ts +++ b/src/api/pulp-login.ts @@ -3,7 +3,9 @@ import { PulpAPI } from './pulp'; const base = new PulpAPI(); export const PulpLoginAPI = { - try: (username, password) => - // roles = any api that will always be there and requires auth - base.http.get(`roles/`, { auth: { username, password } }), + get: () => base.http.get('login/'), + // Here is the place to add more authentication methods for the login... + login: (username: string, password: string) => + base.http.post('login/', {}, { auth: { username, password } }), + logout: () => base.http.delete('login/'), }; diff --git a/src/api/pulp.ts b/src/api/pulp.ts index 05321eb5..f4b29f78 100644 --- a/src/api/pulp.ts +++ b/src/api/pulp.ts @@ -1,4 +1,3 @@ -import Cookies from 'js-cookie'; import { config } from 'src/ui-config'; import { BaseAPI } from './base'; @@ -10,16 +9,9 @@ export class PulpAPI extends BaseAPI { super(); this.http.interceptors.request.use((request) => { - if (!request.auth) { - request.auth = JSON.parse( - window.sessionStorage.credentials || - window.localStorage.credentials || - '{}', - ); - } - + // This is kind of delayed, because the settings promise may be evaluated later. + // In search for a better solution. request.baseURL = config.API_BASE_PATH; - request.headers['X-CSRFToken'] = Cookies.get('csrftoken'); return request; }); diff --git a/src/app-context.tsx b/src/app-context.tsx index ef77c289..3f851adf 100644 --- a/src/app-context.tsx +++ b/src/app-context.tsx @@ -1,7 +1,11 @@ import { type ReactNode, createContext, useContext, useState } from 'react'; import { type AlertType } from 'src/components'; -import { useUserContext } from './user-context'; +export interface IAccount { + username?: string; + pulp_href?: string; + prn?: string; +} export interface IAppContextType { alerts: AlertType[]; featureFlags; // deprecated @@ -9,15 +13,20 @@ export interface IAppContextType { queueAlert: (alert: AlertType) => void; setAlerts: (alerts: AlertType[]) => void; settings; // deprecated - user; // deprecated + account: IAccount; } export const AppContext = createContext(undefined); export const useAppContext = () => useContext(AppContext); -export const AppContextProvider = ({ children }: { children: ReactNode }) => { +export const AppContextProvider = ({ + account, + children, +}: { + account: IAccount; + children: ReactNode; +}) => { const [alerts, setAlerts] = useState([]); - const { credentials } = useUserContext(); // hub compat for now const featureFlags = { @@ -49,14 +58,7 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { queueAlert, setAlerts, settings, - // FIXME: hack - user: credentials - ? { - username: credentials.username, - groups: [], - model_permissions: {}, - } - : null, + account, }} > {children} diff --git a/src/app-routes.tsx b/src/app-routes.tsx index dd21576b..c3ce83a5 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -37,7 +37,6 @@ import { FileRepositoryList, GroupDetail, GroupList, - LoginPage, MultiSearch, MyImports, MyNamespaces, @@ -59,7 +58,7 @@ import { import { Paths, formatPath } from 'src/paths'; import { config } from 'src/ui-config'; import { loginURL } from 'src/utilities'; -import { useUserContext } from './user-context'; +import { useAppContext } from './app-context'; interface IRouteConfig { beta?: boolean; @@ -234,11 +233,6 @@ const routes: IRouteConfig[] = [ path: Paths.ansible.namespace.mine, beta: true, }, - { - component: LoginPage, - path: Paths.meta.login, - noAuth: true, - }, { component: CollectionDocs, path: Paths.ansible.collection.docs_page, @@ -322,10 +316,12 @@ const AuthHandler = ({ noAuth, path, }: IRouteConfig) => { - const { credentials } = useUserContext(); + const { + account: { username }, + } = useAppContext(); const { pathname } = useLocation(); - if (!credentials && !noAuth) { + if (!username && !noAuth) { // NOTE: also update LoginLink when changing this if (config.UI_EXTERNAL_LOGIN_URI) { window.location.replace(loginURL(pathname)); @@ -401,6 +397,11 @@ export const dataRoutes = [ index: true, loader: () => redirect(formatPath(Paths.core.status)), }, + { + path: 'login', + id: 'login', + lazy: () => import('src/routes/login').then((m) => convert(m)), + }, ...appRoutes(), // "No matching route" is not handled by the error boundary. { path: '*', element: }, diff --git a/src/components/delete-user-modal.tsx b/src/components/delete-user-modal.tsx index 1d274cea..f0a2c743 100644 --- a/src/components/delete-user-modal.tsx +++ b/src/components/delete-user-modal.tsx @@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { useState } from 'react'; import { UserAPI, type UserType } from 'src/api'; +import { useAppContext } from 'src/app-context'; import { DeleteModal } from 'src/components'; -import { useUserContext } from 'src/user-context'; import { jsxErrorMessage, mapErrorMessages } from 'src/utilities'; interface IProps { @@ -20,7 +20,9 @@ export const DeleteUserModal = ({ user, }: IProps) => { const [waiting, setWaiting] = useState(false); - const { credentials } = useUserContext(); + const { + account: { username }, + } = useAppContext(); if (!user || !isOpen) { return null; @@ -30,11 +32,11 @@ export const DeleteUserModal = ({ closeModal(false)} deleteAction={() => deleteUser()} - isDisabled={waiting || user.username === credentials.username} + isDisabled={waiting || user.username === username} spinner={waiting} title={t`Delete user?`} > - {user.username === credentials.username ? ( + {user.username === username ? ( t`Deleting yourself is not allowed.` ) : ( diff --git a/src/containers/index.ts b/src/containers/index.ts index c75db44e..936da9a4 100644 --- a/src/containers/index.ts +++ b/src/containers/index.ts @@ -28,7 +28,6 @@ export { default as FileRepositoryEdit } from './file-repository/edit'; export { default as FileRepositoryList } from './file-repository/list'; export { default as GroupDetail } from './group-management/group-detail'; export { default as GroupList } from './group-management/group-list'; -export { default as LoginPage } from './login/login'; export { default as MyImports } from './my-imports/my-imports'; export { default as NamespaceDetail } from './namespace-detail/namespace-detail'; export { default as MyNamespaces } from './namespace-list/my-namespaces'; diff --git a/src/containers/login/login.tsx b/src/containers/login/login.tsx deleted file mode 100644 index d54eddf9..00000000 --- a/src/containers/login/login.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { LoginPage } from '@patternfly/react-core'; -import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; -import { useEffect, useState } from 'react'; -import { Navigate } from 'react-router'; -import { PulpLoginAPI } from 'src/api'; -import { FormFieldHelper, LoginForm } from 'src/components'; -import { Paths, formatPath } from 'src/paths'; -import { useUserContext } from 'src/user-context'; -import { useQueryParams, withRouter } from 'src/utilities'; -import PulpLogo from 'static/images/pulp_logo.png'; - -function PulpLoginPage(_props) { - const { setCredentials, clearCredentials } = useUserContext(); - const { next } = useQueryParams(); - - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [redirect, setRedirect] = useState(''); - const [remember, setRemember] = useState(false); - - useEffect(() => { - clearCredentials(); - }, []); - - const onLoginClick = (e) => { - PulpLoginAPI.try(username, password) - .then(() => { - // verified, save - setCredentials(username, password, remember); - setRedirect( - next && next !== '/login/' ? next : formatPath(Paths.core.status), - ); - }) - .catch((result) => { - // didn't work - if (result.response.status.toString().startsWith('5')) { - setError(t`Server error. Please come back later.`); - } else { - setError( - result.response.data?.detail || t`Invalid login credentials.`, - ); - } - }); - - e.preventDefault(); - }; - - if (redirect) { - return ; - } - - return ( - - - {error ? ( - - {error} - - ) : null} - - {t`Pulp UI is currently using HTTP Basic Authentication. Your credentials will be stored in your browser's sessionStorage or localStorage, in plain text.`} - - - } - onChangePassword={(_e, value) => setPassword(value)} - onChangeUsername={(_e, value) => setUsername(value)} - onLoginButtonClick={onLoginClick} - passwordValue={password} - showHelperText - usernameValue={username} - rememberMeLabel='Keep credentials in localStorage.' - isRememberMeChecked={remember} - onChangeRememberMe={(_e, value) => setRemember(value)} - /> - - ); -} - -export default withRouter(PulpLoginPage); diff --git a/src/containers/settings/user-profile.tsx b/src/containers/settings/user-profile.tsx index 21cb3092..65eff0dd 100644 --- a/src/containers/settings/user-profile.tsx +++ b/src/containers/settings/user-profile.tsx @@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro'; import { Button } from '@patternfly/react-core'; import { useEffect, useState } from 'react'; import { UserAPI, type UserType } from 'src/api'; +import { useAppContext } from 'src/app-context'; import { AlertList, type AlertType, @@ -10,7 +11,6 @@ import { UserFormPage, closeAlert, } from 'src/components'; -import { useUserContext } from 'src/user-context'; import { type ErrorMessagesType, type RouteProps, @@ -27,10 +27,8 @@ function UserProfile(_props: RouteProps) { const [user, setUser] = useState(); const { - credentials: { username }, - updateUsername, - updatePassword, - } = useUserContext(); + account: { username }, + } = useAppContext(); const addAlert = (alert: AlertType) => { setAlerts([...alerts, alert]); @@ -65,14 +63,6 @@ function UserProfile(_props: RouteProps) { variant: 'success', title: t`Saved changes to user "${username}".`, }); - - // update saved credentials when password of logged user is changed - if (user.password) { - updatePassword(user.password); - } - if (username !== user.username) { - updateUsername(user.username); - } }) .catch((err) => setErrorMessages(mapErrorMessages(err))); diff --git a/src/containers/user-management/user-edit.tsx b/src/containers/user-management/user-edit.tsx index 878cf400..bf5fb238 100644 --- a/src/containers/user-management/user-edit.tsx +++ b/src/containers/user-management/user-edit.tsx @@ -9,7 +9,6 @@ import { UserFormPage, } from 'src/components'; import { Paths, formatPath } from 'src/paths'; -import { useUserContext } from 'src/user-context'; import { type ErrorMessagesType, type RouteProps, @@ -19,23 +18,15 @@ import { function UserEdit(props: RouteProps) { const [errorMessages, setErrorMessages] = useState({}); - const [initialState, setInitialState] = useState(); const [redirect, setRedirect] = useState(); const [unauthorized, setUnauthorized] = useState(false); const [user, setUser] = useState(); - const { - credentials: { username }, - updateUsername, - updatePassword, - } = useUserContext(); - const id = props.routeParams.user_id; useEffect(() => { UserAPI.get(id) .then(({ data: result }) => { const extendedResult = { ...result, password: '' }; - setInitialState({ ...extendedResult }); setUser(extendedResult); setUnauthorized(false); }) @@ -45,14 +36,6 @@ function UserEdit(props: RouteProps) { const saveUser = () => UserAPI.saveUser(user) .then(() => { - // update saved credentials when password of logged user is changed - if (initialState.username === username && user.password) { - updatePassword(user.password); - } - if (initialState.username === username && username !== user.username) { - updateUsername(user.username); - } - setRedirect(formatPath(Paths.core.user.list)); }) .catch((err) => setErrorMessages(mapErrorMessages(err))); diff --git a/src/layout.tsx b/src/layout.tsx index 84f97280..6691c335 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -17,7 +17,7 @@ import { import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; import QuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/question-circle-icon'; import { type ReactNode, useState } from 'react'; -import { Link } from 'react-router'; +import { Link, useFetcher } from 'react-router'; import { DarkmodeSwitcher, ExternalLink, @@ -27,9 +27,9 @@ import { SmallLogo, StatefulDropdown, } from 'src/components'; +import { useAppContext } from './app-context'; import { PulpMenu } from './menu'; import { Paths, formatPath } from './paths'; -import { useUserContext } from './user-context'; const DocsDropdown = ({ showAbout }: { showAbout: () => void }) => ( {t`My profile`} } />, - logout()}> + {t`Logout`} , ]} @@ -85,10 +85,14 @@ const UserDropdown = ({ ); export const Layout = ({ children }: { children: ReactNode }) => { + const fetcher = useFetcher(); + const { + account: { username }, + } = useAppContext(); const [aboutModalVisible, setAboutModalVisible] = useState(false); - const { credentials, clearCredentials } = useUserContext(); - const username = credentials?.username; + const logout = () => + fetcher.submit(null, { method: 'delete', action: '/login' }); const Header = ( @@ -116,10 +120,10 @@ export const Layout = ({ children }: { children: ReactNode }) => { setAboutModalVisible(true)} /> - {credentials ? ( - clearCredentials()} /> + {username ? ( + ) : null} - {!credentials ? : null} + {!username ? : null} ); diff --git a/src/menu.tsx b/src/menu.tsx index 614e615b..f5a3c059 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router'; import { ExternalLink, NavList } from 'src/components'; import { plugin_versions } from 'src/utilities'; +import { useAppContext } from './app-context'; import { Paths, formatPath } from './paths'; -import { useUserContext } from './user-context'; const menuItem = (name, options = {}) => ({ active: false, @@ -292,7 +292,9 @@ export const PulpMenu = () => { const location = useLocation(); const [menu, setMenu] = useState([]); - const { credentials } = useUserContext(); + const { + account: { username }, + } = useAppContext(); const plugins = usePlugins(); @@ -320,7 +322,7 @@ export const PulpMenu = () => { diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 00000000..eb9672e8 --- /dev/null +++ b/src/routes/login.tsx @@ -0,0 +1,72 @@ +import { t } from '@lingui/core/macro'; +import { LoginPage } from '@patternfly/react-core'; +import { useState } from 'react'; +import { + data, + replace, + useActionData, + useSubmit, +} from 'react-router'; +import { PulpLoginAPI } from 'src/api'; +import { LoginForm } from 'src/components'; +import PulpLogo from 'static/images/pulp_logo.png'; + +export default function Login() { + const actionData = useActionData(); + const helperText = actionData + ? t`Authentication failed` + ': ' + actionData.error + : ''; + const submit = useSubmit(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const login = (ev) => { + ev.preventDefault(); + submit({ username, password }, { method: 'post' }); + }; + + return ( + + setPassword(value)} + onChangeUsername={(_e, value) => setUsername(value)} + onLoginButtonClick={login} + passwordValue={password} + usernameValue={username} + isLoginButtonDisabled={!username || !password} + helperText={helperText} + showHelperText={!!helperText} + isValidUsername={!helperText} + isValidPassword={!helperText} + /> + + ); +} + +export const clientAction = async ({ request }) => { + const method = request.method.toLowerCase(); + const body = await request.formData(); + const username = body.get('username'); + const password = body.get('password'); + + const searchParams = new URL(request.url).searchParams; + const next = searchParams.get('next'); + + if (method == 'post') { + await PulpLoginAPI.login(username, password); + return replace(next || '/status/'); + } else if (method == 'delete') { + await PulpLoginAPI.logout(); + return replace('/status/'); + } else { + throw data('Method not allowed.', { status: 405 }); + } +}; diff --git a/src/routes/root.tsx b/src/routes/root.tsx index 417fad1a..c9517ca9 100644 --- a/src/routes/root.tsx +++ b/src/routes/root.tsx @@ -1,22 +1,34 @@ -import { Outlet, useNavigation } from 'react-router'; +import { Outlet, useLoaderData, useNavigation } from 'react-router'; +import { PulpLoginAPI } from 'src/api'; import { AppContextProvider } from 'src/app-context'; import { LoadingSpinner, UIVersion } from 'src/components'; import { Layout } from 'src/layout'; -import { UserContextProvider } from 'src/user-context'; export default function Root() { + const account = useLoaderData(); const navigation = useNavigation(); const isNavigating = Boolean(navigation.location); return ( - - + {isNavigating && } - ); } + +export const clientLoader = async () => { + try { + const result = await PulpLoginAPI.get(); + return result.data; + } catch (e) { + if (e.status == 401) { + return { username: null }; + } else { + throw e; + } + } +}; diff --git a/src/user-context.tsx b/src/user-context.tsx deleted file mode 100644 index 8f94850c..00000000 --- a/src/user-context.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - type ReactNode, - createContext, - useContext, - useEffect, - useState, -} from 'react'; - -interface IUserContextType { - credentials: { username: string; password: string; remember: boolean }; - setCredentials: ( - username: string, - password: string, - remember?: boolean, - ) => void; - clearCredentials: () => void; - updateUsername: (username: string) => void; - updatePassword: (password: string) => void; -} - -const UserContext = createContext(undefined); -export const useUserContext = () => useContext(UserContext); - -function cachedCredentials() { - if (!window.sessionStorage.credentials && !window.localStorage.credentials) { - return null; - } - - try { - return JSON.parse( - window.sessionStorage.credentials || window.localStorage.credentials, - ); - } catch (_e) { - return null; - } -} - -export const UserContextProvider = ({ children }: { children: ReactNode }) => { - const [credentials, setCredentials] = useState(cachedCredentials()); - - useEffect(() => { - window.sessionStorage.credentials = JSON.stringify(credentials); - if (credentials?.remember) { - window.localStorage.credentials = JSON.stringify(credentials); - } - if (!credentials) { - window.localStorage.removeItem('credentials'); - window.sessionStorage.removeItem('credentials'); - } - }, [credentials]); - - return ( - - setCredentials({ username, password, remember }), - clearCredentials: () => setCredentials(null), - updateUsername: (username) => - setCredentials((credentials) => ({ ...credentials, username })), - updatePassword: (password) => - setCredentials((credentials) => ({ ...credentials, password })), - }} - > - {children} - - ); -};