feat: tighten email otp auth flow
This commit is contained in:
parent
0448db3354
commit
a534d53e61
|
|
@ -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.
|
||||
|
|
@ -25,34 +25,33 @@ 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 && (
|
||||
{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">
|
||||
Роль для демо-режима
|
||||
|
|
@ -69,7 +68,11 @@ export const OtpLoginForm = ({
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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("роль для демо-режима");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue