Merge pull request 'Dev' (#18) from dev into main
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -20,6 +20,13 @@ workers. Python >=3.13,<4.0, Poetry for dependency management.
|
|||||||
- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE`
|
- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE`
|
||||||
(bool) — `False` locally/tests so cookies work over plain HTTP, `True` in
|
(bool) — `False` locally/tests so cookies work over plain HTTP, `True` in
|
||||||
prod.
|
prod.
|
||||||
|
- **CORS**: `CORSMiddleware` in `main.py` with `allow_origins=[env_settings.
|
||||||
|
FRONT_URL]` and `allow_credentials=True` (needed for the httpOnly
|
||||||
|
`refresh_token` cookie — with credentials, `["*"]` is rejected by browsers).
|
||||||
|
`FRONT_URL` is a required `Env` field with no default, so the backend fails
|
||||||
|
to start without it in `configs/.env` (e.g. `http://localhost:5173`). It is
|
||||||
|
added last, after the logging middlewares, so it wraps them and CORS
|
||||||
|
headers also appear on error responses (401/429).
|
||||||
|
|
||||||
## Redis (`src/cache/`)
|
## Redis (`src/cache/`)
|
||||||
|
|
||||||
@@ -27,12 +34,15 @@ workers. Python >=3.13,<4.0, Poetry for dependency management.
|
|||||||
`redis.Redis` directly (inherits all commands, no manual wrapping needed).
|
`redis.Redis` directly (inherits all commands, no manual wrapping needed).
|
||||||
- Three uses: permissions is-cache was considered and rejected (no real DB
|
- Three uses: permissions is-cache was considered and rejected (no real DB
|
||||||
savings — `get_user_by_id` already eager-loads everything via `selectin`
|
savings — `get_user_by_id` already eager-loads everything via `selectin`
|
||||||
in one call); rate limiting on login (`RateLimit.rate_limit(ip)` —
|
in one call); rate limiting (`RateLimit.rate_limit(ip, action, limit=5,
|
||||||
`INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke
|
window=60)` — `INCR` + `EXPIRE ... NX` on first attempt, key
|
||||||
blacklist (see above).
|
`rate_limit:{action}:{ip}`, so each action has its own counter); access-token
|
||||||
- Rate limit is only triggered inside `except HTTPException` on `/protected/token`
|
revoke blacklist (see above).
|
||||||
— i.e. only on failed logins, not successful ones (otherwise legitimate
|
- Two rate-limited actions: `"login"` (5/60s) and `"reset"` (3/600s).
|
||||||
repeated logins would trip it).
|
The login limit is only triggered inside `except HTTPException` on
|
||||||
|
`/protected/token` — i.e. only on failed logins, not successful ones
|
||||||
|
(otherwise legitimate repeated logins would trip it). `/protected/reset`
|
||||||
|
counts every call.
|
||||||
|
|
||||||
## RabbitMQ (`src/messaging/`)
|
## RabbitMQ (`src/messaging/`)
|
||||||
|
|
||||||
@@ -96,10 +106,19 @@ workers. Python >=3.13,<4.0, Poetry for dependency management.
|
|||||||
reads both `email` and `temp_password` from the message body and uses the
|
reads both `email` and `temp_password` from the message body and uses the
|
||||||
same transient/permanent classification as `WelcomeEmailConsumer`.
|
same transient/permanent classification as `WelcomeEmailConsumer`.
|
||||||
`EmailProducer.send_reset_email(email, temp_password)` takes the password
|
`EmailProducer.send_reset_email(email, temp_password)` takes the password
|
||||||
as a second argument now. **Still missing**: nothing in the app actually
|
as a second argument.
|
||||||
calls `send_reset_email` yet — there's no password-reset route that
|
- **Reset route**: `POST /protected/reset?email=...` in `auth_routes.py` —
|
||||||
generates a `temp_password` and publishes it. Don't assume the
|
`email` is a plain `str` param, so FastAPI reads it from the **query
|
||||||
reset-password feature is reachable end-to-end until that route exists.
|
string**, not the JSON body. The route rate-limits (3/600s per IP), calls
|
||||||
|
`CrudService.reset_password(email)` (generates a 16-char temp password via
|
||||||
|
`HashService.generate_temp_password()` — which is `async` — hashes and
|
||||||
|
saves it through `update_user`), then publishes `{email, temp_password}` to
|
||||||
|
`email.reset`. A 404 (unknown email) is swallowed so the response is always
|
||||||
|
`{"detail": "New password was sent"}` — no user enumeration. Known rough
|
||||||
|
edge: the DB password is changed *before* the email is sent, so a message
|
||||||
|
that ends up in the DLQ leaves the user with a changed password and no
|
||||||
|
email; and anyone who knows an email can reset that account's password.
|
||||||
|
A token-link flow would avoid both.
|
||||||
|
|
||||||
## Logging (`src/logging/`)
|
## Logging (`src/logging/`)
|
||||||
|
|
||||||
@@ -218,6 +237,53 @@ workers. Python >=3.13,<4.0, Poetry for dependency management.
|
|||||||
--force` if it persists across rebuilds; a one-off warning on first
|
--force` if it persists across rebuilds; a one-off warning on first
|
||||||
build isn't necessarily a problem.
|
build isn't necessarily a problem.
|
||||||
|
|
||||||
|
## Frontend (`frontend/`)
|
||||||
|
|
||||||
|
- React 19 + TypeScript + Vite + `react-router-dom` 7, package manager is
|
||||||
|
**yarn** (`yarn.lock`). Run with `yarn dev` (Vite, default
|
||||||
|
`http://localhost:5173`). The user writes/edits frontend code themselves —
|
||||||
|
explain rather than edit unless asked (see memory).
|
||||||
|
- Layout: `src/features/auth/components/` (`AuthComponent`, `ResetComponent`,
|
||||||
|
each with its own `.module.css`), `src/pages/` (`LoginPage` at `/`, `ForgotPasswordPage`
|
||||||
|
at `/forgot-password` — thin wrappers), `src/layouts/AuthLayout` (shared
|
||||||
|
split-screen shell with the marketing block; takes `children` for the form
|
||||||
|
on the right), `src/api/` (`token.ts`, `LoginAPI/Auth.ts`,
|
||||||
|
`ResetAPI/Reset.ts`), `src/types/UserTypes.ts` (mirrors the Pydantic
|
||||||
|
`UserOut`/`UserCreate`). `index.css` = global reset/font, `App.css` = `#root`
|
||||||
|
frame.
|
||||||
|
- **Env**: Vite reads `.env*` from `frontend/configs/` (`envDir: "./configs"`
|
||||||
|
in `vite.config.ts`), not from `frontend/`. Only `VITE_`-prefixed variables
|
||||||
|
reach the browser code, via `import.meta.env`. Backend address is
|
||||||
|
`VITE_BACKEND_URL`, exported as `API_URL` from `src/api/token.ts`. Restart
|
||||||
|
the dev server after editing `.env`. There is **no** Vite `server.proxy`
|
||||||
|
(removed on purpose) — the browser calls the backend directly, so backend
|
||||||
|
CORS (see Auth) must allow the frontend origin.
|
||||||
|
- **Access token** lives in a module variable in `src/api/token.ts`
|
||||||
|
(`setAccessToken`/`getAccessToken`), not in `localStorage`; it is lost on
|
||||||
|
page reload (a `/protected/refresh` call on startup is not implemented
|
||||||
|
yet). `authFetch(path, init)` adds `Authorization: Bearer` and
|
||||||
|
`credentials: "include"`. Login (`POST /protected/token`) must be sent as
|
||||||
|
**form-data** (`URLSearchParams` with `username` = email and `password`),
|
||||||
|
because the route uses `OAuth2PasswordRequestForm`, and returns
|
||||||
|
`{access_token, token_type}`; the refresh token arrives as an httpOnly cookie.
|
||||||
|
- Forms are controlled inputs (`useState`) with hand-written validation:
|
||||||
|
per-field `errors` state, checked on blur and on submit; `<form noValidate>`
|
||||||
|
so browser bubbles don't preempt it. Invalid field gets `.inputError`, text
|
||||||
|
goes in an absolutely positioned `.errorText` under the field. Backend
|
||||||
|
errors are logged with `console.error` and shown as a short generic message.
|
||||||
|
- **CSS Modules gotchas**: only *class* selectors are hashed. Bare element
|
||||||
|
selectors (`form {}`, `input {}`) and `#id` selectors stay global and leak
|
||||||
|
between components (one module's `form { gap }` restyled the other form).
|
||||||
|
Scope them under the module's own class (`.wrapper_1 form {}`) — the
|
||||||
|
hashed ancestor makes the rule local. Classes with hyphens are accessed as
|
||||||
|
`styles["email-icon"]`. Class names shared between two modules are
|
||||||
|
independent copies (duplicate the rules, don't rely on sharing).
|
||||||
|
- React 19 types: `FormEvent` is deprecated; use `SubmitEvent<HTMLFormElement>`.
|
||||||
|
`<p>` must not contain `<div>` (hydration warning).
|
||||||
|
- Login currently redirects to `http://localhost:8000/docs` after success
|
||||||
|
(placeholder — no post-login page yet); `ResetComponent` copies that
|
||||||
|
placeholder redirect too.
|
||||||
|
|
||||||
## Testing (`tests/unit`, `tests/integrated`, `tests/e2e`)
|
## Testing (`tests/unit`, `tests/integrated`, `tests/e2e`)
|
||||||
|
|
||||||
- **Recurring root cause of "different event loop" / `MissingGreenlet`-style
|
- **Recurring root cause of "different event loop" / `MissingGreenlet`-style
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ SMTP_SERVER="change_me"
|
|||||||
EMAIL_LOGIN="change_me"
|
EMAIL_LOGIN="change_me"
|
||||||
EMAIL_PASSWORD="change_me"
|
EMAIL_PASSWORD="change_me"
|
||||||
|
|
||||||
|
FRONT_URL="change_me"
|
||||||
PROD_MODE=bool
|
PROD_MODE=bool
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
npmMinimalAgeGate: 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_BACKEND_URL="change_me"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"eslint": "^10.10.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.6",
|
||||||
|
"globals": "^17.12.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.69.0",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5);
|
||||||
|
border-radius: 40px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import './App.css'
|
||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { API_URL, setAccessToken } from "../token";
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const body = new URLSearchParams({ username: email, password });
|
||||||
|
const res = await fetch(`${API_URL}/protected/token`, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Неверный email или пароль");
|
||||||
|
const data = await res.json();
|
||||||
|
setAccessToken(data.access_token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
export async function requestPasswordReset(email: string): Promise<void> {
|
||||||
|
const res = await fetch(`http://localhost:8000/protected/reset?${new URLSearchParams({ email })}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(typeof body?.detail === "string" ? body.detail : "Не удалось отправить запрос");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const API_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
|
let accessToken: string | null = null;
|
||||||
|
|
||||||
|
export function setAccessToken(token: string | null) {
|
||||||
|
accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authFetch(path: string, init: RequestInit = {}) {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
return fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" });
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 753 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 749 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
@@ -0,0 +1,157 @@
|
|||||||
|
.wrapper_1 form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: whitesmoke;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10%;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.Title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 45px;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
|
||||||
|
}
|
||||||
|
.Description{
|
||||||
|
font-size: medium;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 30px;
|
||||||
|
|
||||||
|
}
|
||||||
|
.Fields {
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
}
|
||||||
|
.wrapper_1 {
|
||||||
|
background-color: whitesmoke;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
.wrapper_1 input {
|
||||||
|
line-height: 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(68, 68, 68, 0.3);
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer{
|
||||||
|
display: flex;
|
||||||
|
padding-top: 10%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.submitbtn {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 10%;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.submit {
|
||||||
|
background-color: #087A55;
|
||||||
|
height: 50px;
|
||||||
|
width: 100%;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: larger;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.submit-arrow {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.email-icon, .pass-icon {
|
||||||
|
height: 25px;
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.email_field, .pass_field {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.email_field input, .pass_field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px 12px 45px;
|
||||||
|
transition: padding-left 0.2s ease;
|
||||||
|
}
|
||||||
|
.pass_field input {
|
||||||
|
padding-right: 45px;
|
||||||
|
}
|
||||||
|
.toggle-password {
|
||||||
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-password svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.email_field input:focus,
|
||||||
|
.email_field input:not(:placeholder-shown) {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
.email_field input:focus + .email-icon,
|
||||||
|
.email_field input:not(:placeholder-shown) + .email-icon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass_field input:focus,
|
||||||
|
.pass_field input:not(:placeholder-shown) {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
.pass_field input:focus + .pass-icon,
|
||||||
|
.pass_field input:not(:placeholder-shown) + .pass-icon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.forgot_link {
|
||||||
|
color: #087A55;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me label {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
accent-color: #087A55;
|
||||||
|
|
||||||
|
}
|
||||||
|
.remember-me label input[type="checkbox"] {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper_1 .inputError {
|
||||||
|
border-color: #d93025;
|
||||||
|
background-color: #fdf2f1;
|
||||||
|
}
|
||||||
|
.errorText {
|
||||||
|
color: #d93025;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-top: 6px;
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, type SubmitEvent, type ReactElement } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import styles from "./AuthComponent.module.css";
|
||||||
|
import { login } from "../../../api/LoginAPI/Auth";
|
||||||
|
|
||||||
|
export function Auth():ReactElement {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
|
||||||
|
|
||||||
|
const validateEmail = (value: string) => {
|
||||||
|
if (!value) return "Введите email";
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const validatePassword = (value: string) =>
|
||||||
|
value ? undefined : "Введите пароль";
|
||||||
|
|
||||||
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = { email: validateEmail(email), password: validatePassword(password) };
|
||||||
|
setErrors(next);
|
||||||
|
if (next.email || next.password) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
window.location.href = "http://localhost:8000/docs";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setErrors({ password: "Неверный email или пароль" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.wrapper_1}>
|
||||||
|
<form noValidate onSubmit={handleSubmit}>
|
||||||
|
<h1 className={styles.Title}> Добро Пожаловать <br /> в The DisExcel Project </h1>
|
||||||
|
<p className={styles.Description}>Войдите в систему, чтобы продолжить работу с вашими процессами</p>
|
||||||
|
<label className={styles.Fields} htmlFor="email">Email</label>
|
||||||
|
<div className={styles.email_field}>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))}
|
||||||
|
placeholder="Email"
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
className={errors.email ? styles.inputError : undefined}
|
||||||
|
/>
|
||||||
|
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
|
||||||
|
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<label className={styles.Fields} htmlFor="password">Password</label>
|
||||||
|
<div className={styles.pass_field}>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onBlur={() => setErrors((p) => ({ ...p, password: validatePassword(password) }))}
|
||||||
|
placeholder="Password"
|
||||||
|
aria-invalid={!!errors.password}
|
||||||
|
className={errors.password ? styles.inputError : undefined}
|
||||||
|
/>
|
||||||
|
<svg className={styles["pass-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
|
||||||
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles["toggle-password"]}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3 3l18 18" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
<path d="M10.6 5.2A10.6 10.6 0 0 1 12 5c5 0 9 4 10 7-.4 1.1-1.2 2.4-2.3 3.6M6.4 6.4C4.4 7.8 2.9 9.7 2 12c1 3 5 7 10 7 1.4 0 2.7-.3 3.9-.8" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M9.9 10a3 3 0 0 0 4.2 4.2" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2 12c1-3 5-7 10-7s9 4 10 7c-1 3-5 7-10 7s-9-4-10-7Z" stroke="#72879A" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="#72879A" strokeWidth="1.8"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{errors.password && <p className={styles.errorText}>{errors.password}</p>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<div className={styles["remember-me"]}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Запомнить меня
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Link to="/forgot-password" className={styles.forgot_link}>Забыли пароль?</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.submitbtn}>
|
||||||
|
<button className={styles.submit} type="submit">
|
||||||
|
Войти
|
||||||
|
<svg className={styles["submit-arrow"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 12h14" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="m13 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
.wrapper_1 form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: whitesmoke;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.wrapper_1 input {
|
||||||
|
line-height: 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(68, 68, 68, 0.3);
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
.Title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 45px;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
.Description {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.Fields {
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.wrapper_1 {
|
||||||
|
background-color: whitesmoke;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
.email_field {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.email_field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px 12px 45px;
|
||||||
|
transition: padding-left 0.2s ease;
|
||||||
|
}
|
||||||
|
.email_field input:focus,
|
||||||
|
.email_field input:not(:placeholder-shown) {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
.email_field input:focus + .email-icon,
|
||||||
|
.email_field input:not(:placeholder-shown) + .email-icon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.email-icon {
|
||||||
|
height: 25px;
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.submitbtn {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 10%;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.submit {
|
||||||
|
background-color: #087A55;
|
||||||
|
height: 50px;
|
||||||
|
width: 100%;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: larger;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.submit-arrow {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.login_link {
|
||||||
|
color: #087A55;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper_1 .inputError {
|
||||||
|
border-color: #d93025;
|
||||||
|
background-color: #fdf2f1;
|
||||||
|
}
|
||||||
|
.errorText {
|
||||||
|
color: #d93025;
|
||||||
|
font-size: 14px;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import styles from "./ResetComponent.module.css";
|
||||||
|
import { useState, type ReactElement, type SubmitEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { requestPasswordReset } from "../../../api/ResetAPI/Reset";
|
||||||
|
|
||||||
|
export function Reset():ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [errors, setErrors] = useState<{ email?: string;}>({});
|
||||||
|
|
||||||
|
const validateEmail = (value: string) => {
|
||||||
|
if (!value) return "Введите email";
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = {
|
||||||
|
email: validateEmail(email),
|
||||||
|
};
|
||||||
|
setErrors(next);
|
||||||
|
if (next.email) return;
|
||||||
|
try {
|
||||||
|
await requestPasswordReset(email);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setErrors({ email: "Ошибка, попробуйте позже" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.wrapper_1}>
|
||||||
|
<form noValidate onSubmit={handleSubmit}>
|
||||||
|
<h1 className={styles.Title}> Восстановление пароля </h1>
|
||||||
|
<p className={styles.Description}>Введите Email, и мы отправим на него новый пароль</p>
|
||||||
|
<label className={styles.Fields} htmlFor="email">Email</label>
|
||||||
|
<div className={styles.email_field}>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))}
|
||||||
|
placeholder="Email"
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
className={errors.email ? styles.inputError : undefined}
|
||||||
|
/>
|
||||||
|
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
|
||||||
|
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.submitbtn}>
|
||||||
|
<button className={styles.submit} type="submit">
|
||||||
|
Отправить новый пароль
|
||||||
|
<svg className={styles["submit-arrow"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 12h14" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="m13 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Link to="/" className={styles.login_link}>Вернуться ко входу</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
*{padding: 0;margin: 0;border: 0;}
|
||||||
|
*,*:before,*:after{-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;}
|
||||||
|
:focus,:active{outline: none;}
|
||||||
|
a:focus,a:active{outline: none;}
|
||||||
|
nav,footer,header,aside{display: block;}
|
||||||
|
html,body{
|
||||||
|
height:100%;width:100%;
|
||||||
|
padding: 30px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
font-size:100%;line-height:1;-ms-text-size-adjust:100%;-moz-text-size-adjust:100%;-webkit-text-size-adjust:100%;}
|
||||||
|
input,button,textarea{font-family:inherit;}
|
||||||
|
input::-ms-clear{display: none;}
|
||||||
|
button{cursor: pointer;}
|
||||||
|
button::-moz-focus-inner{padding:0;border:0;}
|
||||||
|
a,a:visited{text-decoration: none;}
|
||||||
|
a:hover{text-decoration: none;}
|
||||||
|
ul li{list-style: none;}
|
||||||
|
img{vertical-align: top;}
|
||||||
|
h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight: inherit;}
|
||||||
|
a{color:inherit;}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: auto;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.left_side {
|
||||||
|
width: 60%;
|
||||||
|
background-image: url("../assets/bg_login.png");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-right: 1px solid rgba(68, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 40px 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.img_logo {
|
||||||
|
width: 133px;
|
||||||
|
height: 82px;
|
||||||
|
}
|
||||||
|
.header_top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header_title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.header_heading {
|
||||||
|
font-size: 28px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.header_bottom {
|
||||||
|
font-size: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.8);
|
||||||
|
line-height: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
.main_item_img {
|
||||||
|
width: 75%;
|
||||||
|
height: 70%;
|
||||||
|
}
|
||||||
|
.main_items {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.main_item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.main_item_title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.main_item_p {
|
||||||
|
padding-top: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 25px;
|
||||||
|
}
|
||||||
|
.section_item > svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.section_items {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding: 7% 40px 0px 0px;
|
||||||
|
}
|
||||||
|
.section_item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 25%;
|
||||||
|
line-height: 25px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.section_line {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import "./AuthLayout.css";
|
||||||
|
import logo from "../assets/LOGO.png";
|
||||||
|
import img_1 from "../assets/paper.png";
|
||||||
|
import img_2 from "../assets/PC.png";
|
||||||
|
import img_3 from "../assets/graphics.png";
|
||||||
|
|
||||||
|
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="left_side">
|
||||||
|
<header className="header">
|
||||||
|
<div className="container">
|
||||||
|
<div className="header_top">
|
||||||
|
<img className="img_logo" src={logo} alt="LOGO" />
|
||||||
|
<div className="header_text">
|
||||||
|
<div className="header_title">
|
||||||
|
The DisExcel
|
||||||
|
</div>
|
||||||
|
<div className="header_heading">
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="header_bottom">
|
||||||
|
<p>
|
||||||
|
Избавляем от ручной работы. <br />
|
||||||
|
Переводим процессы из бумаги в Excel, <br />
|
||||||
|
а из Excel - в 1С и отчеты.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="main">
|
||||||
|
<div className="contain">
|
||||||
|
<div className="main_items">
|
||||||
|
{/* 1 */}
|
||||||
|
<div className="main_item">
|
||||||
|
<img className="main_item_img" src={img_1} alt="img" />
|
||||||
|
<div className="main_item_title">Бумага</div>
|
||||||
|
<div className="main_item_p">
|
||||||
|
Ручная работа, ошибки, потери
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="main_i"></div>
|
||||||
|
{/* 2 */}
|
||||||
|
<div className="main_item">
|
||||||
|
<img className="main_item_img" src={img_2} alt="img" />
|
||||||
|
<div className="main_item_title">Excel</div>
|
||||||
|
<div className="main_item_p">
|
||||||
|
Структура, формулы, автоматизация
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="main_i"></div>
|
||||||
|
{/* 3 */}
|
||||||
|
<div className="main_item">
|
||||||
|
<img className="main_item_img" src={img_3} alt="img" />
|
||||||
|
<div className="main_item_title">1С / Отчеты</div>
|
||||||
|
<div className="main_item_p">
|
||||||
|
Готовые процессы, аналитика, контроль
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<section className="section">
|
||||||
|
<div className="section_items">
|
||||||
|
{/* 1 */}
|
||||||
|
<div className="section_item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<circle cx="24" cy="24" r="16" stroke="#087A55" strokeWidth="3"/>
|
||||||
|
<path d="M24 14v11l7 4" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Экономим <br /> время
|
||||||
|
</div>
|
||||||
|
<div className="section_line"></div>
|
||||||
|
{/* 2 */}
|
||||||
|
<div className="section_item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path d="M24 6 38 11v11c0 9-5.8 15.2-14 20-8.2-4.8-14-11-14-20V11z" stroke="#087A55" strokeWidth="3" strokeLinejoin="round"/>
|
||||||
|
<path d="m17 24 5 5 10-11" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Снижаем <br /> ошибки
|
||||||
|
</div>
|
||||||
|
<div className="section_line"></div>
|
||||||
|
{/* 3 */}
|
||||||
|
<div className="section_item">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke="#087A55" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.03 7.03 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.43h-3.84a.5.5 0 0 0-.5.43l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.66 8.85a.5.5 0 0 0 .12.63l2.03 1.58c-.05.3-.08.63-.08.94s.02.64.07.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32a.5.5 0 0 0 .6.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.43h3.84a.5.5 0 0 0 .5-.43l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96a.5.5 0 0 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63l-2.03-1.58Z"/>
|
||||||
|
<circle cx="12" cy="12" r="3.5" stroke="#087A55" strokeWidth="1.6"/>
|
||||||
|
</svg>
|
||||||
|
Автоматизируем <br /> процессы
|
||||||
|
</div>
|
||||||
|
<div className="section_line"></div>
|
||||||
|
{/* 4 */}
|
||||||
|
<div className="section_item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<path d="M24 6 38 11v11c0 9-5.8 15.2-14 20-8.2-4.8-14-11-14-20V11z" stroke="#087A55" strokeWidth="3" strokeLinejoin="round"/>
|
||||||
|
<path d="m17 24 5 5 10-11" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Делаем <br /> бизнес эффективнее
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Reset } from "../features/auth/components/ResetComponent";
|
||||||
|
import { AuthLayout } from "../layouts/AuthLayout";
|
||||||
|
|
||||||
|
export function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<Reset />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Auth } from "../features/auth/components/AuthComponent";
|
||||||
|
import { AuthLayout } from "../layouts/AuthLayout";
|
||||||
|
|
||||||
|
export function LoginPage(){
|
||||||
|
return(
|
||||||
|
<AuthLayout>
|
||||||
|
<Auth />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
interface PermissionsOut {
|
||||||
|
id: number
|
||||||
|
permission: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsGroupsOut {
|
||||||
|
id: number
|
||||||
|
group: string
|
||||||
|
permissions: PermissionsOut[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOut {
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
middle_name: string
|
||||||
|
email: string
|
||||||
|
|
||||||
|
direct_permissions: PermissionsOut[]
|
||||||
|
group: PermissionsGroupsOut[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserIn {
|
||||||
|
email: string
|
||||||
|
plain_password:string
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
envDir:"./configs",
|
||||||
|
})
|
||||||
+1285
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from src.cache.redis_client import redis_client
|
from src.cache.redis_client import redis_client
|
||||||
from src.database.users.crud import Seed
|
from src.database.users.crud import Seed
|
||||||
@@ -10,6 +11,7 @@ from src.logging.http_logger import LoggingMiddleware, ProcessingTimeMiddleware
|
|||||||
from src.logging.logger import LogWriter
|
from src.logging.logger import LogWriter
|
||||||
from src.messaging.rabbitmq_client import rabbitmq_client
|
from src.messaging.rabbitmq_client import rabbitmq_client
|
||||||
from src.messaging.topology_setup import apply_topology
|
from src.messaging.topology_setup import apply_topology
|
||||||
|
from src.models.configs_read.env import env_settings
|
||||||
from src.models.rabbitmq_models.email import email_topology
|
from src.models.rabbitmq_models.email import email_topology
|
||||||
from src.web.protected_routes.auth_routes import router as protected_router
|
from src.web.protected_routes.auth_routes import router as protected_router
|
||||||
from src.web.protected_routes.protected_user_action_routes import (
|
from src.web.protected_routes.protected_user_action_routes import (
|
||||||
@@ -35,6 +37,13 @@ app.add_middleware(LoggingMiddleware)
|
|||||||
app.add_middleware(ProcessingTimeMiddleware)
|
app.add_middleware(ProcessingTimeMiddleware)
|
||||||
app.include_router(router=protected_router)
|
app.include_router(router=protected_router)
|
||||||
app.include_router(router=protected_user_action_routes)
|
app.include_router(router=protected_user_action_routes)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[env_settings.FRONT_URL],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("")
|
@app.get("")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ A FastAPI project combining Excel and digital data ("The Great Excel project tha
|
|||||||
- Redis — caching, rate limiting, token revocation
|
- Redis — caching, rate limiting, token revocation
|
||||||
- RabbitMQ (aio-pika) — background email workers
|
- RabbitMQ (aio-pika) — background email workers
|
||||||
- Jinja2 — HTML email templates
|
- Jinja2 — HTML email templates
|
||||||
|
- Frontend: React 19, TypeScript, Vite, react-router-dom, CSS Modules (yarn)
|
||||||
- Docker, Ansible — deployment
|
- Docker, Ansible — deployment
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -30,6 +31,8 @@ src/
|
|||||||
├── reports/ # reports
|
├── reports/ # reports
|
||||||
├── service/ # business logic (auth, users_crud, email sending)
|
├── service/ # business logic (auth, users_crud, email sending)
|
||||||
└── web/ # routes (protected_routes)
|
└── web/ # routes (protected_routes)
|
||||||
|
|
||||||
|
frontend/ # React app (login / password-reset pages), see "Frontend"
|
||||||
```
|
```
|
||||||
|
|
||||||
Layers are connected top to bottom: `web → service → database → models`.
|
Layers are connected top to bottom: `web → service → database → models`.
|
||||||
@@ -60,6 +63,31 @@ python daemon_run.py --all # all daemons enabled in configs/daemons.js
|
|||||||
- Refresh token is stored in the DB as a SHA256 hash, with rotation and revocation support
|
- Refresh token is stored in the DB as a SHA256 hash, with rotation and revocation support
|
||||||
- Passwords are hashed with bcrypt
|
- Passwords are hashed with bcrypt
|
||||||
- RBAC: direct user permissions + permissions via groups, checked through `require_permissions()`
|
- RBAC: direct user permissions + permissions via groups, checked through `require_permissions()`
|
||||||
|
- Login: `POST /protected/token` (OAuth2 form-data: `username` = email, `password`); returns the access token and sets the refresh token as an httpOnly cookie. Failed logins are rate-limited (5 per minute per IP)
|
||||||
|
- Password reset: `POST /protected/reset?email=...` generates a temporary password, saves it and queues an email with it (3 requests per 10 minutes per IP). The response is the same whether or not the email exists
|
||||||
|
- CORS: the allowed frontend origin is taken from the `FRONT_URL` variable in `configs/.env` (required, e.g. `http://localhost:5173`)
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
React + Vite app in `frontend/` (login and password-reset pages).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
yarn install
|
||||||
|
yarn dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables for the frontend live in `frontend/configs/.env`
|
||||||
|
(Vite is configured to read them from there). Only variables prefixed with
|
||||||
|
`VITE_` are exposed to the browser code:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VITE_BACKEND_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend calls the backend directly (no dev proxy), so `FRONT_URL` in the
|
||||||
|
backend's `configs/.env` must match the address the frontend runs on.
|
||||||
|
Restart `yarn dev` after changing either file.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
Vendored
+7
-7
@@ -6,19 +6,19 @@ class RateLimit:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.errors=Errors()
|
self.errors=Errors()
|
||||||
|
|
||||||
async def rate_limit(self, ip:str)->None:
|
async def rate_limit(self, ip:str, action:str, limit:int=5, window:int=60)->None:
|
||||||
|
|
||||||
key=f"action attempt {ip}"
|
key=f"rate_limit:{action}:{ip}"
|
||||||
attempts = await redis_client.incrby(key)
|
attempts = await redis_client.incrby(key)
|
||||||
|
|
||||||
if attempts == 1:
|
if attempts == 1:
|
||||||
await redis_client.expire(key, 60)
|
await redis_client.expire(key, window, nx=True)
|
||||||
|
|
||||||
if attempts>5:
|
if attempts>limit:
|
||||||
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
|
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=window)
|
||||||
|
|
||||||
async def check_rate_limit(self, client_ip:str) -> None:
|
async def check_rate_limit(self, client_ip:str, action:str) -> None:
|
||||||
await self.rate_limit(client_ip)
|
await self.rate_limit(client_ip, action)
|
||||||
|
|
||||||
|
|
||||||
rate_limiter=RateLimit()
|
rate_limiter=RateLimit()
|
||||||
@@ -73,9 +73,10 @@ class ResetEmailConsumer:
|
|||||||
message_id_ctx.set(str(uuid4()))
|
message_id_ctx.set(str(uuid4()))
|
||||||
async with message.process(ignore_processed=True):
|
async with message.process(ignore_processed=True):
|
||||||
data = json.loads(message.body)
|
data = json.loads(message.body)
|
||||||
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
|
daemon_logger.info(f"Обрабатываю reset для {data.get('email')}:, метка: {message.routing_key}")
|
||||||
try:
|
try:
|
||||||
await self.daemon.send_email(data.get("email"), data.get("temp_password"))
|
await self.daemon.send_email(data.get("email"), data.get("temp_password"))
|
||||||
|
|
||||||
except (
|
except (
|
||||||
smtplib.SMTPServerDisconnected,
|
smtplib.SMTPServerDisconnected,
|
||||||
smtplib.SMTPConnectError,
|
smtplib.SMTPConnectError,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Env(Base):
|
|||||||
EMAIL_LOGIN:str
|
EMAIL_LOGIN:str
|
||||||
EMAIL_PASSWORD:str
|
EMAIL_PASSWORD:str
|
||||||
|
|
||||||
|
FRONT_URL:str
|
||||||
PROD_MODE:bool
|
PROD_MODE:bool
|
||||||
|
|
||||||
model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
|
model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
|
||||||
|
|||||||
@@ -205,8 +205,6 @@ class CurrentUserService:
|
|||||||
else:
|
else:
|
||||||
raise self.error.not_found_error(detail="Refresh Token Not Found")
|
raise self.error.not_found_error(detail="Refresh Token Not Found")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
|
async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
|
||||||
'''revoke all the old refresh tokens'''
|
'''revoke all the old refresh tokens'''
|
||||||
user = await self._check(form_data_email, form_data_password)
|
user = await self._check(form_data_email, form_data_password)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -22,6 +24,16 @@ class HashService:
|
|||||||
|
|
||||||
def token_to_hash(self, token:str)->str:
|
def token_to_hash(self, token:str)->str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
async def generate_temp_password(self, length:int=16)->str:
|
||||||
|
SPECIAL = "$@#%!^&*()-_+=[]{}"
|
||||||
|
groups = [string.ascii_uppercase, string.ascii_lowercase, string.digits, SPECIAL]
|
||||||
|
chars = [secrets.choice(g) for g in groups] # по одному из каждой группы
|
||||||
|
pool = "".join(groups)
|
||||||
|
chars += [secrets.choice(pool) for _ in range(length - len(chars))]
|
||||||
|
secrets.SystemRandom().shuffle(chars) # чтобы спецсимвол не всегда стоял первым
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'''jwt'''
|
'''jwt'''
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ class CrudService:
|
|||||||
if not user_entity:
|
if not user_entity:
|
||||||
raise self.errors.not_found_error(detail="User not found")
|
raise self.errors.not_found_error(detail="User not found")
|
||||||
return UserOut.model_validate(user_entity)
|
return UserOut.model_validate(user_entity)
|
||||||
|
|
||||||
|
async def reset_password(self, email:str)->str:
|
||||||
|
new_password=await self.hash_service.generate_temp_password()
|
||||||
|
await self.update_user(email, UserUpdate(plain_password=new_password)) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
return new_password
|
||||||
|
|
||||||
async def crud_service()->CrudService:
|
async def crud_service()->CrudService:
|
||||||
return CrudService()
|
return CrudService()
|
||||||
@@ -2,9 +2,11 @@ from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
|||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from src.cache.rate_limit import rate_limiter
|
from src.cache.rate_limit import rate_limiter
|
||||||
|
from src.messaging.producers.producers import email_producer
|
||||||
from src.models.configs_read.env import env_settings
|
from src.models.configs_read.env import env_settings
|
||||||
from src.models.pydantic_models.model import UserOut
|
from src.models.pydantic_models.model import UserOut
|
||||||
from src.service.auth.auth import CurrentUserService, auth_service
|
from src.service.auth.auth import CurrentUserService, auth_service
|
||||||
|
from src.service.users_crud.users_crud import CrudService, crud_service
|
||||||
|
|
||||||
router=APIRouter(prefix="/protected")
|
router=APIRouter(prefix="/protected")
|
||||||
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
|
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
|
||||||
@@ -31,7 +33,7 @@ async def get_access_token(request: Request,
|
|||||||
try:
|
try:
|
||||||
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
|
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await rate_limiter.rate_limit(client_ip)
|
await rate_limiter.rate_limit(client_ip, "login", 5, 60)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -74,6 +76,22 @@ async def logout(response:Response,
|
|||||||
response.delete_cookie("refresh_token")
|
response.delete_cookie("refresh_token")
|
||||||
return await auth.logout(refresh_token, access_token)
|
return await auth.logout(refresh_token, access_token)
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
async def reset(request: Request,
|
||||||
|
email:str,
|
||||||
|
crud:CrudService=Depends(crud_service)
|
||||||
|
):
|
||||||
|
|
||||||
|
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
|
||||||
|
await rate_limiter.rate_limit(client_ip, "reset", 3, 600)
|
||||||
|
try:
|
||||||
|
temp_password=await crud.reset_password(email)
|
||||||
|
await email_producer.send_reset_email(email, temp_password)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code!=404:
|
||||||
|
raise
|
||||||
|
return {"detail":"New password was sent"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
|
async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user