feat: login_challenge and scope storing, mock api

This commit is contained in:
2025-07-26 14:49:48 +04:00
parent a1a9e0dc93
commit 9a97411bfd
20 changed files with 424 additions and 85 deletions

View File

@ -1,28 +1,63 @@
"use client";
import { FC, useEffect, useState } from "react";
import { redirect } from "next/navigation";
import { useSelector } from "react-redux";
import { Button, Text } from "@mantine/core";
import SERVICES from "@/constants/services";
import { ServiceCode } from "@/enums/ServiceCode";
import SCOPES from "@/constants/scopes";
import { Scopes } from "@/enums/Scopes";
import { notifications } from "@/lib/notifications";
import { RootState } from "@/lib/store";
import ServiceData from "@/types/ServiceData";
import { AuthService } from "@/mocks/authService";
const ConsentButton: FC = () => {
const serviceCode = useSelector(
(state: RootState) => state.targetService.serviceCode
);
const [serviceData, setServiceData] = useState<ServiceData>();
const auth = useSelector((state: RootState) => state.auth);
const [clientName, setClientName] = useState<string>(Scopes.UNDEFINED);
const [serviceRequiredAccess, setServiceRequiredAccess] =
useState<string>("");
const setAccessesForScope = () => {
const accesses: string[] = [];
(auth.scope ?? []).forEach((scopeItem: Scopes) => {
const access = SCOPES[scopeItem];
if (access) accesses.push(access);
});
setServiceRequiredAccess(accesses.join("\n"));
};
const requestConsent = () => {
if (!auth.loginChallenge || auth.scope.length === 0) return;
new AuthService()
.requestConsent(auth.loginChallenge)
.then(response => response.data)
.then(({ clientName }) => {
setClientName(clientName);
})
.catch(error => {
console.error(error);
notifications.error({ message: error.toString() });
});
};
useEffect(() => {
if (serviceCode === ServiceCode.UNDEFINED) {
redirect("services");
}
setServiceData(SERVICES[serviceCode]);
}, [serviceCode]);
setAccessesForScope();
requestConsent();
}, []);
const confirmAccess = () => {};
const confirmAccess = () => {
if (!auth.loginChallenge) return;
new AuthService()
.approveConsent(auth.loginChallenge)
.then(response => response.data)
.then(({ redirectUrl }) => {
window.location.href = redirectUrl;
})
.catch(error => {
console.error(error);
notifications.error({ message: error.toString() });
});
};
return (
<>
@ -34,8 +69,7 @@ const ConsentButton: FC = () => {
<Text
fz={"h4"}
ta="center">
Сервис {serviceData?.name} получит{" "}
{serviceData?.requiredAccesses}
Сервис {clientName} получит {serviceRequiredAccess}
</Text>
</>
);

19
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,19 @@
import LoginForm from "@/components/LoginForm/LoginForm";
import Logo from "@/components/Logo/Logo";
import PageItem from "@/components/PageBlock/PageItem";
import PageContainer from "@/components/PageContainer/PageContainer";
interface LoginPageProps {
searchParams: { login_challenge?: string };
}
export default function LoginPage({ searchParams }: LoginPageProps) {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Вход"} />
<LoginForm loginChallenge={searchParams.login_challenge} />
</PageItem>
</PageContainer>
);
}

View File

@ -1,15 +1,5 @@
import LoginForm from "@/components/LoginForm/LoginForm";
import Logo from "@/components/Logo/Logo";
import PageItem from "@/components/PageBlock/PageItem";
import PageContainer from "@/components/PageContainer/PageContainer";
import { redirect } from "next/navigation";
export default function MainPage() {
return (
<PageContainer center>
<PageItem fullScreenMobile>
<Logo title={"Вход"} />
<LoginForm />
</PageItem>
</PageContainer>
);
redirect("/login");
}

View File

@ -5,8 +5,8 @@ import { redirect } from "next/navigation";
import { Button, Stack, Title } from "@mantine/core";
import styles from "@/app/services/components/ServicesList/ServicesList.module.css";
import TitleWithLines from "@/components/TitleWithLines/TitleWithLines";
import SERVICES from "@/constants/services";
import { ServiceCode } from "@/enums/ServiceCode";
import SCOPES from "@/constants/scopes";
import { Scopes } from "@/enums/Scopes";
import { setTargetService } from "@/lib/features/targetService/targetServiceSlice";
import { useAppDispatch } from "@/lib/store";
import ServiceData from "@/types/ServiceData";
@ -15,10 +15,10 @@ const ServicesList = () => {
const dispatch = useAppDispatch();
const services = useMemo(
() =>
Object.entries(SERVICES)
.filter(([key]) => key !== ServiceCode.UNDEFINED)
Object.entries(SCOPES)
.filter(([key]) => key !== Scopes.UNDEFINED)
.map(([, value]) => value),
[SERVICES]
[SCOPES]
);
const onServiceClick = (service: ServiceData) => {

View File

@ -2,10 +2,15 @@
import { FC } from "react";
import { redirect } from "next/navigation";
import { useSelector } from "react-redux";
import { Button, PinInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import ResendVerificationCode from "@/app/verify-phone/components/ResendVerificationCode/ResendVerificationCode";
import style from "@/app/verify-phone/components/VerifyPhoneForm/VerifyPhone.module.css";
import { setScope } from "@/lib/features/auth/authSlice";
import { notifications } from "@/lib/notifications";
import { RootState, useAppDispatch } from "@/lib/store";
import { AuthService } from "@/mocks/authService";
type VerifyNumberForm = {
code: string;
@ -20,11 +25,27 @@ const VerifyPhoneForm: FC = () => {
code: code => code.length !== 6 && "Введите весь код",
},
});
const authState = useSelector((state: RootState) => state.auth);
const dispatch = useAppDispatch();
const handleSubmit = (values: VerifyNumberForm) => {
console.log(values);
if (!authState.phoneNumber || !authState.loginChallenge) return;
redirect("/services");
new AuthService()
.approveLogin(
authState.phoneNumber,
values.code,
authState.loginChallenge
)
.then(response => response.data)
.then(({ redirectUrl, scope }) => {
dispatch(setScope(scope));
window.location.href = redirectUrl;
})
.catch(error => {
console.error(error);
notifications.error({ message: error.toString() });
});
};
const navigateToLogin = () => redirect("/");

View File

@ -1,21 +1,27 @@
"use client";
import { FC, useState } from "react";
import { redirect } from "next/navigation";
import { FC, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import PhoneInput from "@/components/PhoneInput/PhoneInput";
import { useAppDispatch } from "@/lib/store";
import { setLoginChallenge, setPhoneNumber } from "@/lib/features/auth/authSlice";
import { AuthService } from "@/mocks/authService";
import { notifications } from "@/lib/notifications";
type LoginForm = {
phoneNumber: string;
};
type Props = {
loginChallenge?: string;
isCreatingId?: boolean;
};
const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
const LoginForm: FC<Props> = ({ loginChallenge, isCreatingId = false }) => {
const [phoneMask, setPhoneMask] = useState<string>("");
const router = useRouter();
const form = useForm<LoginForm>({
initialValues: {
phoneNumber: "",
@ -26,17 +32,33 @@ const LoginForm: FC<Props> = ({ isCreatingId = false }) => {
"Введите корректный номер",
},
});
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setLoginChallenge(loginChallenge ?? null));
}, [loginChallenge]);
const handleSubmit = (values: LoginForm) => {
console.log(values);
console.log(phoneMask);
dispatch(setPhoneNumber(values.phoneNumber));
redirect("/verify-phone");
new AuthService().requestLogin(values.phoneNumber)
.then(response => response.data)
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
} else {
router.push("/verify-phone");
}
})
.catch(error => {
console.error(error);
notifications.error({ message: error.toString() });
})
};
const navigateToCreateId = () => redirect("/create-id");
const navigateToCreateId = () => router.push("/create-id");
const navigateToLogin = () => redirect("/");
const navigateToLogin = () => router.push("/");
return (
<form onSubmit={form.onSubmit(handleSubmit)}>

8
src/constants/scopes.ts Normal file
View File

@ -0,0 +1,8 @@
import { Scopes } from "@/enums/Scopes";
const SCOPES = {
[Scopes.UNDEFINED]: "",
[Scopes.OPENID]: "доступ к учетной записи и номеру телефона",
};
export default SCOPES;

View File

@ -1,20 +0,0 @@
import { ServiceCode } from "@/enums/ServiceCode";
import ServiceData from "@/types/ServiceData";
const SERVICES = {
[ServiceCode.UNDEFINED]: {
code: ServiceCode.UNDEFINED,
name: "undefined",
link: "",
requiredAccesses: "",
},
[ServiceCode.CRM]: {
code: ServiceCode.CRM,
name: "LogiDex CRM",
link: "https://skirbo.ru",
requiredAccesses:
"доступ к учетной записи и сделкам по фулфиллменту",
} as ServiceData,
};
export default SERVICES;

4
src/enums/Scopes.ts Normal file
View File

@ -0,0 +1,4 @@
export enum Scopes {
OPENID = "openid",
UNDEFINED = "",
}

View File

@ -1,4 +0,0 @@
export enum ServiceCode {
CRM = "crm",
UNDEFINED = "",
}

View File

@ -0,0 +1,34 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Scopes } from "@/enums/Scopes";
interface AuthState {
loginChallenge: string | null;
phoneNumber: string | null;
scope: Scopes[];
}
const initialState: AuthState = {
loginChallenge: null,
phoneNumber: null,
scope: [],
};
export const authSlice = createSlice({
name: "authentication",
initialState,
reducers: {
setLoginChallenge: (state, action: PayloadAction<string | null>) => {
state.loginChallenge = action.payload;
},
setPhoneNumber: (state, action: PayloadAction<string | null>) => {
state.phoneNumber = action.payload;
},
setScope: (state, action: PayloadAction<Scopes[]>) => {
state.scope = action.payload;
}
},
});
export const { setLoginChallenge, setPhoneNumber, setScope } = authSlice.actions;
export default authSlice.reducer;

View File

@ -1,10 +1,12 @@
import { combineReducers } from "@reduxjs/toolkit";
import targetServiceReducer from "@/lib/features/targetService/targetServiceSlice";
import verificationReducer from "@/lib/features/verification/verificationSlice";
import authReducer from "@/lib/features/auth/authSlice";
const rootReducer = combineReducers({
targetService: targetServiceReducer,
verification: verificationReducer,
auth: authReducer,
});
export default rootReducer;

View File

@ -1,19 +1,19 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ServiceCode } from "@/enums/ServiceCode";
import { Scopes } from "@/enums/Scopes";
interface TargetServiceState {
serviceCode: ServiceCode;
serviceCode: Scopes;
}
const initialState: TargetServiceState = {
serviceCode: ServiceCode.UNDEFINED,
serviceCode: Scopes.UNDEFINED,
};
export const targetServiceSlice = createSlice({
name: "targetService",
initialState,
reducers: {
setTargetService: (state, action: PayloadAction<ServiceCode>) => {
setTargetService: (state, action: PayloadAction<Scopes>) => {
state.serviceCode = action.payload;
},
},

View File

@ -0,0 +1 @@
export * from "./notifications";

View File

@ -0,0 +1,46 @@
import { notifications } from "@mantine/notifications";
type CustomNotifications = {
notify: (...params: Parameters<typeof notifications.show>) => void;
success: (...params: Parameters<typeof notifications.show>) => void;
warn: (...params: Parameters<typeof notifications.show>) => void;
error: (...params: Parameters<typeof notifications.show>) => void;
guess: (
ok: boolean,
...params: Parameters<typeof notifications.show>
) => void;
} & typeof notifications;
const customNotifications: CustomNotifications = {
...notifications,
notify: params => {
return notifications.show({
...params,
color: "blue",
});
},
success: params => {
return notifications.show({
...params,
color: "green",
});
},
warn: params => {
return notifications.show({
...params,
color: "yellow",
});
},
error: params => {
return notifications.show({
...params,
color: "red",
});
},
guess: (ok: boolean, params) => {
if (ok) return customNotifications.success(params);
return customNotifications.error(params);
},
};
export { customNotifications as notifications };

View File

@ -7,7 +7,7 @@ import rootReducer from "@/lib/features/rootReducer";
const persistConfig = {
key: "root",
storage,
whitelist: ["targetService", "verification"],
whitelist: ["targetService", "verification", "auth"],
};
const persistedReducer = persistReducer(persistConfig, rootReducer);

75
src/mocks/authService.ts Normal file
View File

@ -0,0 +1,75 @@
import { AxiosResponse } from "axios";
import { Scopes } from "@/enums/Scopes";
type MockOkMessage = {
ok: boolean;
message: string;
}
type MockApproveLoginResponse = {
redirectUrl: string;
scope: Scopes[];
}
type MockRequestConsentResponse = {
clientName: string;
}
type MockApproveConsentResponse = {
redirectUrl: string;
}
export class AuthService {
async requestLogin(phoneNumber: string): Promise<AxiosResponse<MockOkMessage>> {
return Promise.resolve({
data: { ok: true, message: "Mock response" } as MockOkMessage,
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
});
}
async approveLogin(
phoneNumber: string,
code: string,
loginChallenge: string
): Promise<AxiosResponse<MockApproveLoginResponse>> {
return Promise.resolve({
data: {
redirectUrl:
"http://oauth2.logidex.ru/oauth2/auth?client_id=crm-client&login_verifier=c-4HxjyA0rUMMOYQG-kHStU5pxFeOdViKOyk-eT_qv7vNPHu3xdysdYj3Jwq2gh6vRSHdO6xA7NQgneUho5I_LyENNTFqRkjWwhAsy7Ad9DFhcAWVRCU1rI3ksy38-UTWlto7vKVVhGgRzvlk-coa273uLz-BEo64oUl9B_gcojLnLjO1Q1W6Hu8_nJxqyQBhLPcFOZ1vDVnNcQF5UbB9bld3Wr_1nn3r7sQMBQR54Vphcku6a37GYbPVMVGHo0nBGf6rps6Xg4L6IsD5hlsHzsw5OX3W0MDJ6o--VxHr_HveAH8K7R21Q59JtHa-26pO4KGjetGfgr8rPOTRtsZiKApjZ8qjM9pEgusPj39ysmqYo1wmJo1ZXz8wXh-t3UO0pGIZogmzEOLs5bPCCbXrjG7VaQ78jufSAG2pmhMJxR9AmWsaJms16lev1dBFn-IBdrr4LqYrsVQMqpZstF18ENSURAKejHc8l8m4xocy2-D-ZaeZX0k0vnuGsNTbxT79D1u_-ALm2n0LRwcsK5VCF8v5oO_aMFxcSO87QHU8wy8Wj3um3IBb0sCQXDCpsYIlwqczWNWmxaGXDsmqqUYvZdvWKEXMI5BbbVc44h4_sOaa6BKAaNSceC2GHqN94GWz8dmsX7xyfXMsHYR8_hUFsztN8OstrQkRddJ8een4mdW777W3PYe_U4UL2S2az4L7tC5DTifDCHTfYknb4baQ3UT7x4N8eCd2_Xlkl4gQKU5Mm8njZWucWsLjdW7NG8Q_aDQEl45VunmaQ8iKOTrn1BiNzRHnYdOm15C8nnxHyZ9pO8IKELxUGIKnwP8eF6-A-Rj_bLWWIBquLTRgBrR153gu5Srh2Xl8-LU4ffxM6ipO1nHJMPg3_5493yS1ua_ZWUuht_d4C4Y6j6xuFJHx-bKrCIffiGiSUnpKkepzGnTHCS02wNDVItheAUnlO18zbxsHBFM1tjQQrLIB3cxQRrK12NmIgOheiQMzkSwpQ2CmdRnVpJBGy8Nzp7X_YP-nVC9ctzFR1YrTEw-ZH0wVYjPu_vsijwUtqq3mABD8lw%3D&redirect_uri=http%3A%2F%2Fcrm.logidex.ru%2Fcallback&response_type=code&scope=openid&state=csrf_token",
scope: ["openid"] as Scopes[],
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
});
}
async requestConsent(consentChallenge: string): Promise<AxiosResponse<MockRequestConsentResponse>> {
return Promise.resolve({
data: {
clientName: "crm",
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
});
}
async approveConsent(consentChallenge: string): Promise<AxiosResponse<MockApproveConsentResponse>> {
return Promise.resolve({
data: {
redirectUrl:
"http://oauth2.logidex.ru/oauth2/auth?client_id=crm-client&consent_verifier=hU0HetHSHoqc4ZMvwsjMaFUsVckZ3B6ztXyQim4vwcptp4Dp9cIvSByiFHvThLkaIVl2f7uDxB8hUcUoG1-DvNDC3qcGCskLlNn0tDlNcxb41LZtS28D8iZJAUiZedqDdGCfhkuH4TioErId5m-8-y5Y-PYrosfcrqsVfK88vZ5kgViMIjROe68Vc_O5kxpPUymt5I_-oUeFMdrDnjpVcTipwTJIG-WutbtUBHp6tA3FXIfo-0ai-o8yr2Lv2bQiBSegYKA4GmfrQ25xn7_yQGLyGVBVsKPCNRQAyRvdeqFEVGm-3SUxvIJCeyCXaZrHxENSUbxo6xd1m_oVHqye8hXcZSWmFVOa4eo4Rw6OWsnN3AWl75XLt_maKcL_LZftkQERtJBgV2-8C1QYJXwoPS0uTFANq39s2778KIP0XbufiB3UW1QvmUdzKKH43K4MnB-F9ah26nzaw8HwEBTbDGclvkq7TFAozKddwnumgrqRkbElwC3eqr5LnpMfGR1vCVBP81sPjx26LoiKOpmuamfT-O37EsVHdooeP8ry2IjCx0KrUe5wI93XiUc4RIMn_MsO8zaifyNrzFfvVQ7VPNj3QasM4O4drDyGLict1fWiNZP_KVFeAnojOp258nPqDn76VSzROweummzSD-lC56zviX9pZjmGb0RpXb8eeB2Nc4uWCy3dYw4kxEFQSqwU7liqI9paZLv0Vl1PrvS4GL_vv3zh4YKpp8h1yT0IWDnEL75dmIeSBXB7eTkZy8ING1HdwvfH1TdYImCrmLTi93JWtSJvsZWklUBQsFQ900hYPYGK4WVdxRQOTsHrJhJwOex5so7mrnowHpXQuUU6eDi9p2Yj0_YN2XuPIs1I9iS2F2S6t1_kNRmJupzo3g09bY8AGNsSDeEwp2riqXQ_o3xgATaIUycbve2qcOIr89kHomYSCc0YiQnka3zBb5RRTlYhDuQlgeHuEnIBWU2oLYcnyP-zlbeSxRbYuYu4uxXtLHDT9E6tDxqXxSYvw4AzUw-EwQX8v0LamJnQLHCSwD7F8S0M5COSm_Pv56DhtBevnU7PqrpZ-FKOXm89A2HXx9XO8qQMG-tg3y5TZ5vVfTwJ6WkzZNkZSWAZGiwQX4wiQmjep4wlKgP3ZQ8VV0CVS6R9f1DMwY8tCvuJFumd_OORK7-q_bgg3Xp3Njc%3D&redirect_uri=http%3A%2F%2Fcrm.logidex.ru%2Fcallback&response_type=code&scope=openid&state=csrf_token",
},
status: 200,
statusText: "OK",
headers: {},
config: {} as any,
});
}
}

View File

@ -1,10 +0,0 @@
import { ServiceCode } from "@/enums/ServiceCode";
type ServiceData = {
code: ServiceCode;
name: string;
link: string;
requiredAccesses: string;
}
export default ServiceData;