From a534d53e614f3c513e4ac0754128cfa3199f72f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Apr 2026 22:35:13 +0300 Subject: [PATCH] feat: tighten email otp auth flow --- docs/operations/supabase-email-otp-auth.md | 87 ++++++++++++++++++++++ src/components/auth/OtpLoginForm.jsx | 70 +++++++++-------- src/components/auth/OtpLoginForm.test.jsx | 39 ++++++++++ src/context/AuthContext.jsx | 62 +++++++++++---- src/context/AuthContext.test.js | 42 ++++++++++- src/pages/LoginPage.jsx | 12 ++- 6 files changed, 262 insertions(+), 50 deletions(-) create mode 100644 docs/operations/supabase-email-otp-auth.md create mode 100644 src/components/auth/OtpLoginForm.test.jsx diff --git a/docs/operations/supabase-email-otp-auth.md b/docs/operations/supabase-email-otp-auth.md new file mode 100644 index 0000000..ea9c144 --- /dev/null +++ b/docs/operations/supabase-email-otp-auth.md @@ -0,0 +1,87 @@ +# Supabase Email OTP Auth + +This guide covers the whitelist-only email OTP login flow for the app. + +## What changes in this flow + +- Users sign in with a one-time code sent to email. +- The app only accepts accounts that already exist in `auth.users`. +- The app reads the app role from `public.users -> public.roles`. +- Self-signup should be disabled if you want whitelist-only access. + +## Supabase dashboard settings + +1. Open your Supabase project. +2. Go to `Authentication` and confirm email OTP / email login is enabled. +3. Check the email provider settings so OTP messages are deliverable. +4. Disable self-signup if the project should stay whitelist-only. +5. Make sure the two allowed emails exist in Supabase Auth before testing login. + +## Frontend environment + +The frontend only needs these variables in `.env.local`: + +```bash +VITE_SUPABASE_URL=... +VITE_SUPABASE_ANON_KEY=... +``` + +Do not put `SUPABASE_SERVICE_ROLE_KEY` in the frontend. If an Edge Function needs secrets, add them in the Supabase dashboard or via `supabase secrets`. + +## Seed the allowed users + +Create these Auth users first: + +- `skylanguage@yandex.ru` +- `mk7029953@yandex.ru` + +The `public.handle_new_user()` trigger will create the matching row in `public.users` when a new `auth.users` row is inserted. It already reads `raw_user_meta_data ->> 'role'`, so if you set metadata during user creation, the role can be applied automatically. + +If the user already exists in `auth.users`, bind the role in `public.users` with SQL: + +```sql +update public.users u +set role_id = r.id +from public.roles r +where u.email = 'skylanguage@yandex.ru' + and r.name = 'admin'; + +update public.users u +set role_id = r.id +from public.roles r +where u.email = 'mk7029953@yandex.ru' + and r.name = 'logistician'; +``` + +If a user exists in Auth but the profile row is missing in `public.users`, recreate it manually: + +```sql +insert into public.users (id, email, name, role_id, last_login) +select + au.id, + au.email, + coalesce(au.raw_user_meta_data ->> 'name', split_part(au.email, '@', 1)), + r.id, + timezone('utc', now()) +from auth.users au +join public.roles r on r.name = case + when au.email = 'skylanguage@yandex.ru' then 'admin' + when au.email = 'mk7029953@yandex.ru' then 'logistician' + else 'manager' +end +where au.email in ('skylanguage@yandex.ru', 'mk7029953@yandex.ru') +on conflict (id) do update +set email = excluded.email, + name = excluded.name, + role_id = excluded.role_id, + last_login = excluded.last_login; +``` + +## Sanity checks + +- Send an OTP to `skylanguage@yandex.ru`. +- Send an OTP to `mk7029953@yandex.ru`. +- Confirm `admin` reaches the admin area after sign-in. +- Confirm `logistician` reaches the logistics area after sign-in. +- Confirm an email outside the whitelist is rejected. +- Confirm the app shows an error if Auth has a session but `public.users` has no matching profile row. diff --git a/src/components/auth/OtpLoginForm.jsx b/src/components/auth/OtpLoginForm.jsx index b37b581..01f284b 100644 --- a/src/components/auth/OtpLoginForm.jsx +++ b/src/components/auth/OtpLoginForm.jsx @@ -25,51 +25,54 @@ export const OtpLoginForm = ({

Платформа доставки

-

Управление заказами и доставкой

+

Вход по email и коду

- Вход по электронной почте и одноразовому коду. Права и рабочая область определяются - ролью пользователя. + Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной + записью в системе, а не выбором роли.

setEmail(event.target.value)} - placeholder="Введите адрес электронной почты" + placeholder="Введите email" type="email" disabled={isDemoMode} /> - {isDemoMode ? ( -

- Для демонстрации используется единый адрес входа. -

- ) : null}
- {!isOtpSent && ( -
- - + {isDemoMode && !isOtpSent ? ( +
+

+ Демо-режим активен +

+
+ + +
+

+ Для демонстрации используется единый адрес входа и код 000000. +

- )} + ) : null} {isOtpSent && (
@@ -99,10 +102,17 @@ export const OtpLoginForm = ({ )}
-
+
{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} />
);