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 className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
|
||||||
Платформа доставки
|
Платформа доставки
|
||||||
</p>
|
</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)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Вход по электронной почте и одноразовому коду. Права и рабочая область определяются
|
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
||||||
ролью пользователя.
|
записью в системе, а не выбором роли.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="email">
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="email">
|
||||||
Электронная почта
|
Email
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
placeholder="Введите адрес электронной почты"
|
placeholder="Введите email"
|
||||||
type="email"
|
type="email"
|
||||||
disabled={isDemoMode}
|
disabled={isDemoMode}
|
||||||
/>
|
/>
|
||||||
{isDemoMode ? (
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Для демонстрации используется единый адрес входа.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
||||||
Роль для демо-режима
|
Роль для демо-режима
|
||||||
|
|
@ -69,7 +68,11 @@ export const OtpLoginForm = ({
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Для демонстрации используется единый адрес входа и код 000000.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isOtpSent && (
|
{isOtpSent && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -99,10 +102,17 @@ export const OtpLoginForm = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{isDemoMode
|
||||||
? "Демо-режим активен: выберите роль и используйте код 000000."
|
? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000."
|
||||||
: "Подключена рабочая база данных: код отправляется на электронную почту пользователя."}
|
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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 AuthContext = createContext(null);
|
||||||
const STORAGE_KEY = "construction-auth-demo-user";
|
const STORAGE_KEY = "construction-auth-demo-user";
|
||||||
export const DEMO_LOGIN_EMAIL = "demo@local";
|
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) => {
|
export const resolveDemoUser = (email, roleHint) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -18,6 +27,22 @@ export const resolveDemoUser = (email, roleHint) => {
|
||||||
export const resolveLoginEmail = (isDemoMode, email) =>
|
export const resolveLoginEmail = (isDemoMode, email) =>
|
||||||
isDemoMode ? DEMO_LOGIN_EMAIL : 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 }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(() => {
|
const [user, setUser] = useState(() => {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
@ -26,6 +51,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
const [pendingEmail, setPendingEmail] = useState("");
|
const [pendingEmail, setPendingEmail] = useState("");
|
||||||
const [isOtpSent, setIsOtpSent] = useState(false);
|
const [isOtpSent, setIsOtpSent] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSupabaseConfig || !supabase) {
|
if (!hasSupabaseConfig || !supabase) {
|
||||||
|
|
@ -37,15 +63,16 @@ export const AuthProvider = ({ children }) => {
|
||||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAuthError("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await safeSupabaseCall(async () => {
|
const { data, error } = await safeSupabaseCall(async () => {
|
||||||
const { data: profile, error } = await supabase
|
const { data: profile, error } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.select("id, email, name, last_login, role_info:roles(name)")
|
.select("id, email, name, last_login, role_info:roles(name)")
|
||||||
.eq("id", session.user.id)
|
.eq("id", session.user.id)
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -53,15 +80,20 @@ export const AuthProvider = ({ children }) => {
|
||||||
return profile;
|
return profile;
|
||||||
}, "Ошибка загрузки профиля");
|
}, "Ошибка загрузки профиля");
|
||||||
|
|
||||||
if (data) {
|
if (error) {
|
||||||
setUser({
|
setUser(null);
|
||||||
id: data.id,
|
setAuthError(error?.message || PROFILE_LOAD_ERROR);
|
||||||
email: data.email,
|
return;
|
||||||
name: data.name,
|
|
||||||
role: data.role_info?.name || "manager",
|
|
||||||
lastLogin: data.last_login,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
setUser(null);
|
||||||
|
setAuthError(MISSING_PROFILE_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(mapProfileToAuthUser(data));
|
||||||
|
setAuthError("");
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
|
|
@ -79,14 +111,10 @@ export const AuthProvider = ({ children }) => {
|
||||||
const requestOtp = async ({ email, roleHint }) => {
|
const requestOtp = async ({ email, roleHint }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setPendingEmail(email);
|
setPendingEmail(email);
|
||||||
|
setAuthError("");
|
||||||
try {
|
try {
|
||||||
if (hasSupabaseConfig && supabase) {
|
if (hasSupabaseConfig && supabase) {
|
||||||
const { error } = await supabase.auth.signInWithOtp({
|
const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email));
|
||||||
email,
|
|
||||||
options: {
|
|
||||||
shouldCreateUser: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -143,6 +171,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setPendingEmail("");
|
setPendingEmail("");
|
||||||
setIsOtpSent(false);
|
setIsOtpSent(false);
|
||||||
|
setAuthError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
|
@ -150,6 +179,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
isOtpSent,
|
isOtpSent,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
authError,
|
||||||
isDemoMode: !hasSupabaseConfig,
|
isDemoMode: !hasSupabaseConfig,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("resolveDemoUser", () => {
|
||||||
it("prioritizes the selected demo role over a matching email account", () => {
|
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");
|
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";
|
import { DEMO_LOGIN_EMAIL, resolveLoginEmail, useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
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 [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
||||||
const [roleHint, setRoleHint] = React.useState("manager");
|
const [roleHint, setRoleHint] = React.useState("manager");
|
||||||
const [otp, setOtp] = React.useState("");
|
const [otp, setOtp] = React.useState("");
|
||||||
|
|
@ -20,8 +20,14 @@ export const LoginPage = () => {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayError = error || authError;
|
||||||
|
|
||||||
const handleRequestOtp = async () => {
|
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) {
|
if (!response.success) {
|
||||||
setError(response.error?.message || "Не удалось отправить код");
|
setError(response.error?.message || "Не удалось отправить код");
|
||||||
return;
|
return;
|
||||||
|
|
@ -52,7 +58,7 @@ export const LoginPage = () => {
|
||||||
isDemoMode={isDemoMode}
|
isDemoMode={isDemoMode}
|
||||||
onRequestOtp={handleRequestOtp}
|
onRequestOtp={handleRequestOtp}
|
||||||
onVerifyOtp={handleVerifyOtp}
|
onVerifyOtp={handleVerifyOtp}
|
||||||
error={error}
|
error={displayError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue