diff --git a/next.config.js b/next.config.js index 8eb7609..2fabef7 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,9 @@ const nextConfig = { eslint: { dirs: ["src", "playwright-tests"], }, + images: { + domains: ["www.google.com"], + }, }; module.exports = nextConfig; diff --git a/package-lock.json b/package-lock.json index f225f00..79c72cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mui/material": "6.1.6", "@mui/x-date-pickers": "7.22.2", "@react-oauth/google": "^0.12.0", + "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0", "@types/node": "20.14.10", @@ -41,6 +42,7 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.45.4", "react-i18next": "^15.0.0", + "react-redux": "^9.2.0", "react-virtuoso": "4.12.0", "typescript": "5.5.4", "yup": "^1.2.0" @@ -4844,6 +4846,30 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", + "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", + "license": "MIT", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@release-it/conventional-changelog": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-8.0.2.tgz", @@ -6463,6 +6489,12 @@ "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -8303,9 +8335,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001637", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001637.tgz", - "integrity": "sha512-1x0qRI1mD1o9e+7mBI7XtzFAP4XszbHaVWsMiGbSPLYekKTJF7K+FNk6AsXH4sUpc+qrsI3pVgf1Jdl/uGkuSQ==", + "version": "1.0.30001695", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", + "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", "funding": [ { "type": "opencollective", @@ -8319,7 +8351,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -12543,6 +12576,16 @@ "node": ">=0.8.0" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -15759,11 +15802,12 @@ "node": ">=12" } }, - "node_modules/proxy-agent/node_modules/proxy-from-env": { + "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/public-encrypt": { "version": "4.0.3", @@ -16114,6 +16158,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -16443,6 +16510,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.3.tgz", @@ -17038,6 +17120,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -18971,6 +19059,15 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf7": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", diff --git a/package.json b/package.json index 250b01d..8fe6dfc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@mui/material": "6.1.6", "@mui/x-date-pickers": "7.22.2", "@react-oauth/google": "^0.12.0", + "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0", "@types/node": "20.14.10", @@ -49,6 +50,7 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.45.4", "react-i18next": "^15.0.0", + "react-redux": "^9.2.0", "react-virtuoso": "4.12.0", "typescript": "5.5.4", "yup": "^1.2.0" diff --git a/playwright-tests/login/login.spec.ts b/playwright-tests/login/login.spec.ts new file mode 100644 index 0000000..132e9c7 --- /dev/null +++ b/playwright-tests/login/login.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from "@playwright/test"; + +test("should have login title", async ({ page }) => { + await page.goto("/en/login"); + const title = await page.title(); + expect(title).toBe("Login"); +}); diff --git a/public/images/arbisoft-logo.png b/public/images/arbisoft-logo.png new file mode 100644 index 0000000..cc42d89 Binary files /dev/null and b/public/images/arbisoft-logo.png differ diff --git a/src/app/[language]/layout.tsx b/src/app/[language]/layout.tsx index daf2935..3b1fdfb 100644 --- a/src/app/[language]/layout.tsx +++ b/src/app/[language]/layout.tsx @@ -3,7 +3,6 @@ import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; -import CssBaseline from "@mui/material/CssBaseline"; import { dir } from "i18next"; import "@/services/i18n/config"; import { languages } from "@/services/i18n/config"; @@ -16,6 +15,8 @@ import QueryClientProvider from "@/services/react-query/query-client-provider"; import queryClient from "@/services/react-query/query-client"; import ReactQueryDevtools from "@/services/react-query/react-query-devtools"; import InitColorSchemeScript from "@/components/theme/init-color-scheme-script"; +import { GoogleOAuthProvider } from "@react-oauth/google"; +import { Providers } from "@/redux/store/provider"; type Props = { params: { language: string }; @@ -40,19 +41,24 @@ export default function RootLayout({ children: React.ReactNode; params: { language: string }; }) { + const clientId = process.env.NEXT_PUBLIC_CLIENT_ID; + return ( - - - - - - {children} - - - + + + + + + + {children} + + + + + ); diff --git a/src/app/[language]/login/page.tsx b/src/app/[language]/login/page.tsx new file mode 100644 index 0000000..7acd1c0 --- /dev/null +++ b/src/app/[language]/login/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { getServerTranslation } from "@/services/i18n"; +import LoginPage from "@/features/LoginPage"; + +type Props = { + params: { language: string }; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { t } = await getServerTranslation(params.language, "login"); + + return { + title: t("title"), + }; +} + +export default LoginPage; diff --git a/src/app/[language]/page.tsx b/src/app/[language]/page.tsx index 7b77728..8c9e47c 100644 --- a/src/app/[language]/page.tsx +++ b/src/app/[language]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { getServerTranslation } from "@/services/i18n"; -import Typography from "@mui/material/Typography"; +import HomePage from "@/features/HomePage"; + type Props = { params: { language: string }; }; @@ -16,5 +17,5 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Home({ params }: Props) { const { t } = await getServerTranslation(params.language, "home"); - return {t("description")}; + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index f017e8b..475034e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,10 @@ /* Your global CSS here */ +body { + background: linear-gradient( + 145deg, + rgba(16, 24, 56, 1) 0%, + rgba(23, 54, 85, 1) 100% + ); + height: 100vh; + margin: 0; +} diff --git a/src/components/AlertModal/alertModal.tsx b/src/components/AlertModal/alertModal.tsx new file mode 100644 index 0000000..9c6a64d --- /dev/null +++ b/src/components/AlertModal/alertModal.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +import Snackbar from "@mui/material/Snackbar"; +import { AlertModalProps } from "./types"; +import { AlertContainer } from "./styled"; + +const AlertModal: FC = ({ + handleCloseAlertModal, + errorMessage = "Something wrong happened", + vertical = "top", + horizontal = "right", + severity = "error", +}) => { + return ( + + + {errorMessage} + + + ); +}; + +export default AlertModal; diff --git a/src/components/AlertModal/index.ts b/src/components/AlertModal/index.ts new file mode 100644 index 0000000..72ea9e3 --- /dev/null +++ b/src/components/AlertModal/index.ts @@ -0,0 +1,3 @@ +import AlertModal from "./alertModal"; + +export default AlertModal; diff --git a/src/components/AlertModal/styled.tsx b/src/components/AlertModal/styled.tsx new file mode 100644 index 0000000..30a376a --- /dev/null +++ b/src/components/AlertModal/styled.tsx @@ -0,0 +1,10 @@ +import Alert from "@mui/material/Alert"; +import { styled, css } from "@mui/material/styles"; + +export const AlertContainer = styled(Alert, { + name: "AlertContainer", +})( + () => css` + width: 100%; + ` +); diff --git a/src/components/AlertModal/types.ts b/src/components/AlertModal/types.ts new file mode 100644 index 0000000..daeb448 --- /dev/null +++ b/src/components/AlertModal/types.ts @@ -0,0 +1,7 @@ +export type AlertModalProps = { + handleCloseAlertModal: () => void; + errorMessage?: string; + vertical?: "bottom" | "top"; + horizontal?: "center" | "left" | "right"; + severity?: "success" | "info" | "warning" | "error"; +}; diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts new file mode 100644 index 0000000..a256edf --- /dev/null +++ b/src/endpoints/index.ts @@ -0,0 +1,3 @@ +import * as login from "./login"; + +export { login }; diff --git a/src/endpoints/login.ts b/src/endpoints/login.ts new file mode 100644 index 0000000..07a6000 --- /dev/null +++ b/src/endpoints/login.ts @@ -0,0 +1 @@ +export const login = "/users/login"; diff --git a/src/features/HomePage/homePage.tsx b/src/features/HomePage/homePage.tsx new file mode 100644 index 0000000..2080b28 --- /dev/null +++ b/src/features/HomePage/homePage.tsx @@ -0,0 +1,22 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import { useRouter } from "next/navigation"; +import { FC, useEffect } from "react"; +import useLanguage from "@/services/i18n/use-language"; + +const HomePage: FC<{ description: string }> = ({ description }) => { + const router = useRouter(); + const language = useLanguage(); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + router.push(`/${language}/login/`); + } + }, [language, router]); + + return {description}; +}; + +export default HomePage; diff --git a/src/features/HomePage/index.ts b/src/features/HomePage/index.ts new file mode 100644 index 0000000..ef76777 --- /dev/null +++ b/src/features/HomePage/index.ts @@ -0,0 +1,3 @@ +import HomePage from "./homePage"; + +export default HomePage; diff --git a/src/features/LoginPage/index.ts b/src/features/LoginPage/index.ts new file mode 100644 index 0000000..55779d3 --- /dev/null +++ b/src/features/LoginPage/index.ts @@ -0,0 +1,3 @@ +import LoginPage from "./loginPage"; + +export default LoginPage; diff --git a/src/features/LoginPage/loginPage.tsx b/src/features/LoginPage/loginPage.tsx new file mode 100644 index 0000000..bcebb49 --- /dev/null +++ b/src/features/LoginPage/loginPage.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useGoogleLogin, CredentialResponse } from "@react-oauth/google"; +import Box from "@mui/material/Box"; +import Image from "next/image"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import { useRouter } from "next/navigation"; +import useLanguage from "@/services/i18n/use-language"; +import { useEffect, useState } from "react"; +import { useProposalsMutation } from "@/redux/Login/loginSlice"; +import AlertModal from "@/components/AlertModal"; +import { + LoginButtonContainer, + LoginContainer, + LoginSubContainer, +} from "./styled"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; + +export default function LoginPage() { + const [error, setError] = useState(null); + + const router = useRouter(); + const language = useLanguage(); + + const [login] = useProposalsMutation(); + + const onSuccess = async (credentialResponse: CredentialResponse) => { + if ("access_token" in credentialResponse) { + const response = await login({ + auth_token: credentialResponse.access_token as string, + }); + const errorState = response?.error as FetchBaseQueryError; + + if (response.data) { + localStorage.setItem("access_token", response.data.access ?? ""); + localStorage.setItem("refresh_token", response.data.refresh ?? ""); + router.replace(`/${language}/`); + } else if (errorState) { + const errorMessage = errorState.data as string[]; + setError(errorMessage[0]); + } + } else { + setError("Google login failed: No credential received."); + return; + } + }; + + const onError = () => { + setError("Authentication Error: Google login failed. Please try again."); + }; + + const googleLoginHandler = useGoogleLogin({ + onSuccess: (response) => onSuccess(response as CredentialResponse), + onError: onError, + }); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (token) { + router.push(`/${language}/`); + } + }, [router]); + + return ( + + + arbisoft-logo + + + + + {error && ( + setError(null)} + errorMessage={error} + /> + )} + + ); +} diff --git a/src/features/LoginPage/styled.tsx b/src/features/LoginPage/styled.tsx new file mode 100644 index 0000000..5dc53d0 --- /dev/null +++ b/src/features/LoginPage/styled.tsx @@ -0,0 +1,53 @@ +import Box from "@mui/material/Box"; +import { styled, css } from "@mui/material/styles"; + +export const LoginContainer = styled(Box, { + name: "LoginContainer", +})( + () => css` + align-self: center; + height: 100%; + align-items: center; + display: flex; + justify-content: center; + ` +); + +export const LoginSubContainer = styled(Box, { + name: "LoginSubContainer", +})( + () => css` + width: 450px; + display: flex; + flex-direction: column; + align-items: center; + padding: 50px; + gap: 30px; + border: 1px solid black; + border-radius: 12px; + background-color: white; + ` +); + +export const LoginButtonContainer = styled(Box, { + name: "LoginButtonContainer", +})( + () => css` + width: 100%; + border: 1px solid #908e8e; + border-radius: 6px; + + .login-button { + padding: 0; + width: 100%; + } + + .button-content { + display: flex; + gap: 8px; + padding: 10px 30px; + align-items: center; + justify-content: center; + width: 100% + ` +); diff --git a/src/models/Auth/auth.tsx b/src/models/Auth/auth.tsx new file mode 100644 index 0000000..aed1e7d --- /dev/null +++ b/src/models/Auth/auth.tsx @@ -0,0 +1,12 @@ +export type LoginResponse = { + access: string; + refresh: string; + user_info: { + avatar: string; + first_name: string; + full_name: string; + last_name: string; + }; +}; + +export type LoginParams = { auth_token: string }; diff --git a/src/models/Auth/index.ts b/src/models/Auth/index.ts new file mode 100644 index 0000000..97ccf76 --- /dev/null +++ b/src/models/Auth/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/src/redux/Login/loginSlice.ts b/src/redux/Login/loginSlice.ts new file mode 100644 index 0000000..3932db7 --- /dev/null +++ b/src/redux/Login/loginSlice.ts @@ -0,0 +1,18 @@ +import { login } from "@/endpoints"; + +import { baseApi } from "../baseApi"; +import { LoginResponse, LoginParams } from "@/models/Auth"; + +export const proposalsApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + proposals: builder.mutation({ + query: ({ auth_token }) => ({ + url: login.login, + method: "POST", + body: { auth_token }, + }), + }), + }), +}); + +export const { useProposalsMutation } = proposalsApi; diff --git a/src/redux/baseApi.tsx b/src/redux/baseApi.tsx new file mode 100644 index 0000000..4e0bfac --- /dev/null +++ b/src/redux/baseApi.tsx @@ -0,0 +1,13 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +export const REDUCER_PATH = "api"; +export const SPOTIFY_REDUCER_PATH = "spotify_api"; + +export const tagTypes = [] as const; // TODO: will add tags here + +export const baseApi = createApi({ + reducerPath: REDUCER_PATH, + baseQuery: fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_BASE_URL }), + tagTypes, + endpoints: () => ({}), +}); diff --git a/src/redux/store/configureStore.tsx b/src/redux/store/configureStore.tsx new file mode 100644 index 0000000..f1509b9 --- /dev/null +++ b/src/redux/store/configureStore.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { combineReducers, configureStore } from "@reduxjs/toolkit"; + +import { baseApi } from "../baseApi"; + +const rootReducer = combineReducers({ + [baseApi.reducerPath]: baseApi.reducer, +}); + +const store = configureStore({ + devTools: process.env.NODE_ENV !== "production", + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat([baseApi.middleware]), + reducer: rootReducer, +}); + +export { store }; diff --git a/src/redux/store/provider.tsx b/src/redux/store/provider.tsx new file mode 100644 index 0000000..e1cd370 --- /dev/null +++ b/src/redux/store/provider.tsx @@ -0,0 +1,10 @@ +"use client"; + +import React from "react"; +import { Provider } from "react-redux"; + +import { store } from "./configureStore"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/services/i18n/locales/en/login.json b/src/services/i18n/locales/en/login.json new file mode 100644 index 0000000..d3ab0a3 --- /dev/null +++ b/src/services/i18n/locales/en/login.json @@ -0,0 +1,3 @@ +{ + "title": "Login" +}