feat: tighten email otp auth flow

This commit is contained in:
Codex 2026-04-09 22:35:13 +03:00
parent 0448db3354
commit a534d53e61
6 changed files with 262 additions and 50 deletions

View File

@ -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.

View File

@ -25,51 +25,54 @@ export const OtpLoginForm = ({
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
Платформа доставки
</p>
<h1 className="text-3xl font-semibold">Управление заказами и доставкой</h1>
<h1 className="text-3xl font-semibold">Вход по email и коду</h1>
<p className="text-sm text-[var(--color-text-muted)]">
Вход по электронной почте и одноразовому коду. Права и рабочая область определяются
ролью пользователя.
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
записью в системе, а не выбором роли.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="email">
Электронная почта
Email
</label>
<Input
id="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="Введите адрес электронной почты"
placeholder="Введите email"
type="email"
disabled={isDemoMode}
/>
{isDemoMode ? (
<p className="text-xs text-[var(--color-text-muted)]">
Для демонстрации используется единый адрес входа.
</p>
) : null}
</div>
{!isOtpSent && (
<div className="space-y-2">
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
Роль для демо-режима
</label>
<Select
id="roleHint"
value={roleHint}
onChange={(event) => setRoleHint(event.target.value)}
>
{Object.entries(ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
{isDemoMode && !isOtpSent ? (
<div className="space-y-2 rounded-3xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
Демо-режим активен
</p>
<div className="space-y-2">
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
Роль для демо-режима
</label>
<Select
id="roleHint"
value={roleHint}
onChange={(event) => setRoleHint(event.target.value)}
>
{Object.entries(ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Для демонстрации используется единый адрес входа и код 000000.
</p>
</div>
)}
) : null}
{isOtpSent && (
<div className="space-y-2">
@ -99,10 +102,17 @@ export const OtpLoginForm = ({
)}
</div>
<div className="mt-6 rounded-3xl bg-[var(--color-accent-soft)] p-4 text-sm text-[var(--color-text)]">
<div
className={[
"mt-6 rounded-3xl p-4 text-sm",
isDemoMode
? "border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text)]"
: "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
].join(" ")}
>
{isDemoMode
? "Демо-режим активен: выберите роль и используйте код 000000."
: "Подключена рабочая база данных: код отправляется на электронную почту пользователя."}
? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000."
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
</div>
</Panel>
);

View File

@ -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(<OtpLoginForm {...baseProps} />).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(
<OtpLoginForm {...baseProps} isDemoMode={true} />,
).toLowerCase();
expect(markup).toContain("демо-режим активен");
expect(markup).toContain("роль для демо-режима");
});
});

View File

@ -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,

View File

@ -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",
});
});
});

View File

@ -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 <Navigate to="/dashboard" replace />;
}
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}
/>
</div>
);