+
{isDemoMode
- ? "Демо-режим активен: выберите роль и используйте код 000000."
- : "Подключена рабочая база данных: код отправляется на электронную почту пользователя."}
+ ? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000."
+ : "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
);
diff --git a/src/components/auth/OtpLoginForm.test.jsx b/src/components/auth/OtpLoginForm.test.jsx
new file mode 100644
index 0000000..8ae348b
--- /dev/null
+++ b/src/components/auth/OtpLoginForm.test.jsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { OtpLoginForm } from "./OtpLoginForm";
+
+const baseProps = {
+ email: "skylanguage@yandex.ru",
+ setEmail: () => {},
+ roleHint: "manager",
+ setRoleHint: () => {},
+ otp: "",
+ setOtp: () => {},
+ isOtpSent: false,
+ isLoading: false,
+ isDemoMode: false,
+ onRequestOtp: () => {},
+ onVerifyOtp: () => {},
+ error: "",
+};
+
+describe("OtpLoginForm", () => {
+ it("describes the real OTP flow without production role selection", () => {
+ const markup = renderToStaticMarkup(
).toLowerCase();
+
+ expect(markup).toContain("введите email");
+ expect(markup).toContain("код придет на почту");
+ expect(markup).toContain("доступ определяется учетной записью");
+ expect(markup).not.toContain("роль для демо-режима");
+ });
+
+ it("keeps the demo hint visible only in demo mode", () => {
+ const markup = renderToStaticMarkup(
+
,
+ ).toLowerCase();
+
+ expect(markup).toContain("демо-режим активен");
+ expect(markup).toContain("роль для демо-режима");
+ });
+});
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
index e81a17a..d1fdd89 100644
--- a/src/context/AuthContext.jsx
+++ b/src/context/AuthContext.jsx
@@ -6,6 +6,15 @@ import { safeSupabaseCall } from "../services/safeSupabaseCall";
const AuthContext = createContext(null);
const STORAGE_KEY = "construction-auth-demo-user";
export const DEMO_LOGIN_EMAIL = "demo@local";
+export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору.";
+export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
+
+export const buildOtpRequestPayload = (email) => ({
+ email,
+ options: {
+ shouldCreateUser: false,
+ },
+});
export const resolveDemoUser = (email, roleHint) => {
return (
@@ -18,6 +27,22 @@ export const resolveDemoUser = (email, roleHint) => {
export const resolveLoginEmail = (isDemoMode, email) =>
isDemoMode ? DEMO_LOGIN_EMAIL : email;
+export const mapProfileToAuthUser = (profile) => {
+ if (!profile) {
+ return null;
+ }
+
+ const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info;
+
+ return {
+ id: profile.id,
+ email: profile.email,
+ name: profile.name,
+ role: roleInfo?.name || "manager",
+ lastLogin: profile.last_login,
+ };
+};
+
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -26,6 +51,7 @@ export const AuthProvider = ({ children }) => {
const [pendingEmail, setPendingEmail] = useState("");
const [isOtpSent, setIsOtpSent] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const [authError, setAuthError] = useState("");
useEffect(() => {
if (!hasSupabaseConfig || !supabase) {
@@ -37,15 +63,16 @@ export const AuthProvider = ({ children }) => {
} = supabase.auth.onAuthStateChange(async (_event, session) => {
if (!session?.user) {
setUser(null);
+ setAuthError("");
return;
}
- const { data } = await safeSupabaseCall(async () => {
+ const { data, error } = await safeSupabaseCall(async () => {
const { data: profile, error } = await supabase
.from("users")
.select("id, email, name, last_login, role_info:roles(name)")
.eq("id", session.user.id)
- .single();
+ .maybeSingle();
if (error) {
throw error;
@@ -53,15 +80,20 @@ export const AuthProvider = ({ children }) => {
return profile;
}, "Ошибка загрузки профиля");
- if (data) {
- setUser({
- id: data.id,
- email: data.email,
- name: data.name,
- role: data.role_info?.name || "manager",
- lastLogin: data.last_login,
- });
+ if (error) {
+ setUser(null);
+ setAuthError(error?.message || PROFILE_LOAD_ERROR);
+ return;
}
+
+ if (!data) {
+ setUser(null);
+ setAuthError(MISSING_PROFILE_ERROR);
+ return;
+ }
+
+ setUser(mapProfileToAuthUser(data));
+ setAuthError("");
});
return () => subscription.unsubscribe();
@@ -79,14 +111,10 @@ export const AuthProvider = ({ children }) => {
const requestOtp = async ({ email, roleHint }) => {
setIsLoading(true);
setPendingEmail(email);
+ setAuthError("");
try {
if (hasSupabaseConfig && supabase) {
- const { error } = await supabase.auth.signInWithOtp({
- email,
- options: {
- shouldCreateUser: true,
- },
- });
+ const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email));
if (error) {
throw error;
@@ -143,6 +171,7 @@ export const AuthProvider = ({ children }) => {
setUser(null);
setPendingEmail("");
setIsOtpSent(false);
+ setAuthError("");
};
const value = {
@@ -150,6 +179,7 @@ export const AuthProvider = ({ children }) => {
pendingEmail,
isOtpSent,
isLoading,
+ authError,
isDemoMode: !hasSupabaseConfig,
requestOtp,
verifyOtp,
diff --git a/src/context/AuthContext.test.js b/src/context/AuthContext.test.js
index 0c11f81..45a2ba1 100644
--- a/src/context/AuthContext.test.js
+++ b/src/context/AuthContext.test.js
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
-import { DEMO_LOGIN_EMAIL, resolveDemoUser, resolveLoginEmail } from "./AuthContext";
+import {
+ DEMO_LOGIN_EMAIL,
+ buildOtpRequestPayload,
+ mapProfileToAuthUser,
+ resolveDemoUser,
+ resolveLoginEmail,
+} from "./AuthContext";
describe("resolveDemoUser", () => {
it("prioritizes the selected demo role over a matching email account", () => {
@@ -20,3 +26,37 @@ describe("resolveLoginEmail", () => {
expect(resolveLoginEmail(false, "user@company.ru")).toBe("user@company.ru");
});
});
+
+describe("buildOtpRequestPayload", () => {
+ it("disables automatic user creation during OTP sign-in", () => {
+ expect(buildOtpRequestPayload("user@company.ru")).toEqual({
+ email: "user@company.ru",
+ options: {
+ shouldCreateUser: false,
+ },
+ });
+ });
+});
+
+describe("mapProfileToAuthUser", () => {
+ it("returns null when the auth profile is missing", () => {
+ expect(mapProfileToAuthUser(null)).toBeNull();
+ });
+
+ it("keeps a conservative fallback role when the stored role is missing", () => {
+ expect(
+ mapProfileToAuthUser({
+ id: "user-id",
+ email: "user@company.ru",
+ name: "User",
+ last_login: "2026-04-09T00:00:00.000Z",
+ }),
+ ).toEqual({
+ id: "user-id",
+ email: "user@company.ru",
+ name: "User",
+ role: "manager",
+ lastLogin: "2026-04-09T00:00:00.000Z",
+ });
+ });
+});
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
index 7ba0b03..48571f0 100644
--- a/src/pages/LoginPage.jsx
+++ b/src/pages/LoginPage.jsx
@@ -4,7 +4,7 @@ import { OtpLoginForm } from "../components/auth/OtpLoginForm";
import { DEMO_LOGIN_EMAIL, resolveLoginEmail, useAuth } from "../context/AuthContext";
export const LoginPage = () => {
- const { user, isOtpSent, isLoading, isDemoMode, requestOtp, verifyOtp } = useAuth();
+ const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp } = useAuth();
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
const [roleHint, setRoleHint] = React.useState("manager");
const [otp, setOtp] = React.useState("");
@@ -20,8 +20,14 @@ export const LoginPage = () => {
return
;
}
+ const displayError = error || authError;
+
const handleRequestOtp = async () => {
- const response = await requestOtp({ email: resolveLoginEmail(isDemoMode, email), roleHint });
+ const response = await requestOtp(
+ isDemoMode
+ ? { email: resolveLoginEmail(isDemoMode, email), roleHint }
+ : { email: resolveLoginEmail(isDemoMode, email) },
+ );
if (!response.success) {
setError(response.error?.message || "Не удалось отправить код");
return;
@@ -52,7 +58,7 @@ export const LoginPage = () => {
isDemoMode={isDemoMode}
onRequestOtp={handleRequestOtp}
onVerifyOtp={handleVerifyOtp}
- error={error}
+ error={displayError}
/>