diff --git a/assets/js/src/app/api/pimcore/index.ts b/assets/js/src/app/api/pimcore/index.ts index 311469498..b06d12464 100644 --- a/assets/js/src/app/api/pimcore/index.ts +++ b/assets/js/src/app/api/pimcore/index.ts @@ -1,6 +1,17 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import type { RootState } from '@Pimcore/components/login-form/store' export const api = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: '/' }), + baseQuery: fetchBaseQuery({ + baseUrl: '/', + prepareHeaders: (headers, { getState }) => { + // By default, if we have a token in the store, let's use that for authenticated requests + const token = (getState() as RootState).auth?.token + if (token !== null) { + headers.set('authorization', `Bearer ${token}`) + } + return headers + } + }), endpoints: () => ({}) }) diff --git a/assets/js/src/app/auth/authSlice.tsx b/assets/js/src/app/auth/authSlice.tsx new file mode 100644 index 000000000..6061db4f8 --- /dev/null +++ b/assets/js/src/app/auth/authSlice.tsx @@ -0,0 +1,37 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import { type User } from '@Pimcore/components/login-form/services/auth' +import { type RootState } from '@Pimcore/components/login-form/store' + +interface AuthState { + user: User | null + token: string | null +} + +const initialState: AuthState = { + user: null, + token: null + +} + +const slice = createSlice({ + name: 'auth', + initialState, + reducers: { + setCredentials: ( + state, + { + payload: { user, token } + }: PayloadAction<{ user: User, token: string }> + ) => { + state.user = user + state.token = token + } + } +}) + +export const { setCredentials } = slice.actions + +export const authReducer = slice.reducer + +export const selectCurrentUser = (state: RootState): User | null => state.auth.user diff --git a/assets/js/src/components/login-form/hooks/useAuth.ts b/assets/js/src/components/login-form/hooks/useAuth.ts new file mode 100644 index 000000000..87adc9298 --- /dev/null +++ b/assets/js/src/components/login-form/hooks/useAuth.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import {selectCurrentUser} from "@Pimcore/app/auth/authSlice"; + +export const useAuth = () => { + const user = useSelector(selectCurrentUser) + + return useMemo(() => ({ user }), [user]) +} diff --git a/assets/js/src/components/login-form/login-form-style.tsx b/assets/js/src/components/login-form/login-form-style.tsx new file mode 100644 index 000000000..010400bf8 --- /dev/null +++ b/assets/js/src/components/login-form/login-form-style.tsx @@ -0,0 +1,43 @@ +import { createStyles } from 'antd-style' + +export const useStyle = createStyles(({ token, css }) => { + return { + form: css` + form { + display: flex; + flex-direction: column; + gap: 8px; + font-family: Lato, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 22px; + + .flex-space { + display: flex; + justify-content: space-between; + align-items: center; + } + + .ant-btn-link { + color: ${token.colorPrimary}; + + &:hover { + color: ${token.colorPrimaryHover}; + } + } + } + + .login__additional-logins { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + .ant-btn { + width: 100%; + } + } + ` + } +}) diff --git a/assets/js/src/components/login-form/login-form.tsx b/assets/js/src/components/login-form/login-form.tsx new file mode 100644 index 000000000..3a854f240 --- /dev/null +++ b/assets/js/src/components/login-form/login-form.tsx @@ -0,0 +1,96 @@ +import { Button, Checkbox, Input } from 'antd' +import React from 'react' +import { EyeInvisibleOutlined, EyeTwoTone, UserOutlined } from '@ant-design/icons' +import { useStyle } from '@Pimcore/components/login-form/login-form-style' +import { type LoginRequest, useLoginMutation } from '@Pimcore/components/login-form/services/auth' +import { useDispatch } from 'react-redux' +import { setCredentials } from '@Pimcore/app/auth/authSlice' +import { useMessage } from '@Pimcore/components/message/useMessage' + +export interface IAdditionalLogins { + key: string + name: string + link: string +} + +interface ILoginFormProps { + additionalLogins?: IAdditionalLogins[] +} + +export const LoginForm = ({ additionalLogins }: ILoginFormProps): React.JSX.Element => { + const dispatch = useDispatch() + const { styles } = useStyle() + const [messageApi, contextHolder] = useMessage() + + const [formState, setFormState] = React.useState({ + username: '', + password: '' + }) + + const [login, { isLoading }] = useLoginMutation() + + const handleAuthentication = async (e: React.FormEvent): Promise => { + try { + e.preventDefault() + const user = await login(formState).unwrap() + dispatch(setCredentials(user)) + + console.log('worked', user) + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + messageApi.error({ + content: error.data.message + }) + } + } + + return ( + <> + {contextHolder} +
+
+ { setFormState({ ...formState, username: e.target.value }) } } + placeholder="Username" + prefix={ } + /> + (visible ? : ) } + onChange={ (e) => { setFormState({ ...formState, password: e.target.value }) } } + placeholder="Password" + /> +
+ + Remember me + + +
+ + + + + {Array.isArray(additionalLogins) && ( +
+

or

+ + {additionalLogins?.map((login) => ( + + ))} +
+ )} +
+ + ) +} diff --git a/assets/js/src/components/login-form/services/auth.ts b/assets/js/src/components/login-form/services/auth.ts new file mode 100644 index 000000000..aa6391b1d --- /dev/null +++ b/assets/js/src/components/login-form/services/auth.ts @@ -0,0 +1,37 @@ +import { api } from '@Pimcore/app/api/pimcore' + +export interface User { + username: string + // token: string +} + +export interface UserResponse { + token: string + lifetime: number + user: User +} + +export interface LoginRequest { + username: string + password: string +} + +export const authApi = api + .injectEndpoints({ + endpoints: (builder) => ({ + login: builder.mutation({ + query: (credentials) => ({ + url: 'studio/api/login', + method: 'POST', + body: credentials + }) + }), + protected: builder.mutation<{ message: string }, undefined>({ + query: () => 'protected' + }) + }), + overrideExisting: false + }) + +export { authApi as api } +export const { useLoginMutation, useProtectedMutation } = authApi diff --git a/assets/js/src/components/login-form/store/index.ts b/assets/js/src/components/login-form/store/index.ts new file mode 100644 index 000000000..4bde13b47 --- /dev/null +++ b/assets/js/src/components/login-form/store/index.ts @@ -0,0 +1,15 @@ +import { configureStore } from '@reduxjs/toolkit' +import { authReducer } from '@Pimcore/app/auth/authSlice'; +import {api} from "@Pimcore/components/login-form/services/auth"; + +export const store = configureStore({ + reducer: { + [api.reducerPath]: api.reducer, + auth: authReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(api.middleware), +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch diff --git a/assets/js/src/modules/app/app-view.tsx b/assets/js/src/modules/app/app-view.tsx index 1ad88843b..6df636cd6 100644 --- a/assets/js/src/modules/app/app-view.tsx +++ b/assets/js/src/modules/app/app-view.tsx @@ -1,9 +1,8 @@ import React, { StrictMode } from 'react' import { GlobalProvider } from './global-provider' -import { BaseLayoutView } from '@Pimcore/modules/app/base-layout/base-layout-view' import { App as AntApp } from 'antd' -import { TranslationsLoaderContainer } from '@Pimcore/modules/app/translations/translations-loader-container' -import { Background } from '@Pimcore/components/background/background' +import { router } from '@Pimcore/router/router' +import { RouterProvider } from 'react-router-dom' export const AppView = (): React.JSX.Element => { return ( @@ -11,10 +10,7 @@ export const AppView = (): React.JSX.Element => { - - - - + diff --git a/assets/js/src/modules/app/global-provider.tsx b/assets/js/src/modules/app/global-provider.tsx index d0713089b..d95dacd11 100644 --- a/assets/js/src/modules/app/global-provider.tsx +++ b/assets/js/src/modules/app/global-provider.tsx @@ -9,10 +9,12 @@ export interface GlobalProviderProps { export const GlobalProvider = ({ children }: GlobalProviderProps): React.JSX.Element => { return ( - - - {children} - - + <> + + + {children} + + + ) } diff --git a/assets/js/src/router/layouts/default.tsx b/assets/js/src/router/layouts/default.tsx new file mode 100644 index 000000000..dc6d8b446 --- /dev/null +++ b/assets/js/src/router/layouts/default.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Background } from '@Pimcore/components/background/background' +import { TranslationsLoaderContainer } from '@Pimcore/modules/app/translations/translations-loader-container' +import { BaseLayoutView } from '@Pimcore/modules/app/base-layout/base-layout-view' + +export default function DefaultLayout (): React.JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/assets/js/src/router/layouts/login.tsx b/assets/js/src/router/layouts/login.tsx new file mode 100644 index 000000000..bc48bd069 --- /dev/null +++ b/assets/js/src/router/layouts/login.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { createStyles } from 'antd-style' +import { type IAdditionalLogins, LoginForm } from '@Pimcore/components/login-form/login-form' + +const useStyle = createStyles(({ token, css }) => { + return { + loginPage: css` + display: flex; + align-items: center; + background: url(/bundles/pimcorestudioui/img/login-bg.png) lightgray 50% / cover no-repeat; + position: absolute; + inset: 0; + overflow: hidden; + `, + loginWidget: css` + display: flex; + flex-direction: column; + width: 503px; + height: 608px; + flex-shrink: 0; + border-radius: 8px; + background: linear-gradient(335deg, rgba(255, 255, 255, 0.86) 1.72%, rgba(57, 14, 97, 0.86) 158.36%); + padding: 83px 100px 0 100px; + margin-left: 80px; + + /* Component/Button/primaryShadow */ + box-shadow: 0px 2px 0px 0px rgba(114, 46, 209, 0.10); + + img { + margin-bottom: 50px + } + ` + } +}) + +export default function LoginLayout (): React.JSX.Element { + const { styles } = useStyle() + const additionalLogins: IAdditionalLogins[] = [ + { + key: 'google', + name: 'Log in with Google', + link: '/admin/login/google' + }, + { + key: 'github', + name: 'Log in with GitHub', + link: '/admin/login/github' + } + ] + + return ( +
+
+ { + +
+
+ ) +} diff --git a/assets/js/src/router/router.tsx b/assets/js/src/router/router.tsx new file mode 100644 index 000000000..3f0c236c8 --- /dev/null +++ b/assets/js/src/router/router.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { createBrowserRouter } from 'react-router-dom' +import DefaultLayout from '@Pimcore/router/layouts/default' +import LoginLayout from '@Pimcore/router/layouts/login' + +export const router = createBrowserRouter([ + { + path: '/admin/studio', + element: + }, + { + path: '/admin/studio/login', + element: + } +]) diff --git a/config/pimcore/routing.yaml b/config/pimcore/routing.yaml index 7dc08311c..bdcfdaad3 100644 --- a/config/pimcore/routing.yaml +++ b/config/pimcore/routing.yaml @@ -1,5 +1,5 @@ pimcore_studio_ui: resource: "@PimcoreStudioUiBundle/src/Controller/" type: annotation - prefix: / + prefix: /admin/studio diff --git a/public/img/login-bg.png b/public/img/login-bg.png new file mode 100644 index 000000000..30a2e15e2 Binary files /dev/null and b/public/img/login-bg.png differ diff --git a/public/img/logo.png b/public/img/logo.png new file mode 100644 index 000000000..36fec5db2 Binary files /dev/null and b/public/img/logo.png differ diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index cf40295f8..4c48c0373 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -20,10 +20,18 @@ final class DefaultController extends FrontendController { /** - * @Route("/admin/studio") + * @Route("/") */ public function indexAction(): Response { return $this->render('@PimcoreStudioUi/default/index.html.twig'); } + + /** + * @Route("/{any}", requirements={"any"=".+"}) + */ + public function catchAllAction(): Response + { + return $this->render('@PimcoreStudioUi/default/index.html.twig'); + } }