2 Commits
Author SHA1 Message Date
whqreza 4e59f6e25c docs(readme): add server section with MinIO tunnel; refresh stale sections
New section 'Working with the deployed system (server)': public app and Casdoor panel URLs (aramesh / casdoor .bennu-api-bahrestan.ir) and step-by-step MinIO console access via an SSH tunnel (ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 -> http://localhost:9001), including the aramesh-minio credentials, the procurement-files bucket, why the console is loopback-only (port 9000 is reserved for the deploy-webhook receiver), the secret-handling warning and the tunnel teardown.

Also refreshed sections that went stale this session: the login form description no longer mentions the removed optional organization field; the restore section now documents the committed admin-only seed dumps in backups/علی محقق/ (the old restore.ps1/sh scripts and demo dumps were deleted) with the current login and the still-available demo seeders.
2026-09-15 16:57:28 +03:30
whqreza 593c149478 feat(auth): add password visibility toggle and remove the unused Organization field
Part 1 — show/hide password: an eye/eye-off button inside the login password input switches its type between password and text. It sits on the right (the input is LTR) with pr-10 clearance so dots never overlap the icon; type=button so a click cannot submit the form, aria-label Show/Hide password, aria-pressed + aria-controls, plus a Persian tooltip.

Part 2 — Organization field removed end-to-end after the audit proved it was a no-op: LoginPage input/state/payload spread (main + dev-mode admin login), api.ts payload type, backend LoginRequest.organization (declared but never forwarded to Casdoor — a bogus org value still logged in successfully), and the casdoor-auth.md flow diagram. The organization actually used remains CASDOOR_ORGANIZATION=inventory for /users/sync.

Verified: api + frontend images rebuilt and redeployed; login works with {username, password}; the served LoginPage chunk contains the toggle and zero org-field leftovers.
2026-09-15 16:29:54 +03:30
5 changed files with 119 additions and 46 deletions
+88 -14
View File
@@ -81,7 +81,7 @@ v1.3/
## 🔐 Login ## 🔐 Login
The whole system is behind a login page (`/login`). Login is **in-page**: The whole system is behind a login page (`/login`). Login is **in-page**:
the form (username / password / optional organization) is sent to the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor `POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. no browser redirect to Casdoor.
@@ -113,6 +113,71 @@ Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
---
## 🌐 Working with the deployed system (server)
The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
| What | Address |
|---|---|
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
The MinIO console is deliberately **loopback-only** on the server
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
```
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001**
3. Log in with:
| Username | Password |
|---|---|
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ These credentials are **full admin access to the file storage**. Treat
them as a production secret (this README is inside the repo, so anyone with
repo access can read them — rotate them on the server if they ever leak).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
--- ---
## Prerequisites (target machine) ## Prerequisites (target machine)
@@ -197,26 +262,35 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
--- ---
## Restoring the demo data (for the team) ## Restoring the committed seed data
The databases are **not** in git — Docker volumes hold the data. To let The databases are **not** in git — Docker volumes hold the data. The repo
everyone see the same system state, the repo ships exact database dumps in ships recovery dumps in `backups/علی محقق/`:
`backups/`. After a fresh boot:
| File | Contents |
|---|---|
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
On a running stack:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1 # Windows docker compose stop api casdoor
bash backups/restore.sh # Linux / Git Bash docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
docker compose start casdoor api
``` ```
This **replaces all data** in the `procurement` and `casdoor` databases with Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
the committed state — a clean, realistic demo: 3 projects × 2 warehouses, in usernames, so «ali mohaghegh» is not a valid login name).
4 suppliers, 12 materials across all 8 categories, and 4 quotes covering
every lifecycle state (offered / in-transit / partially received /
completed) with matching stock.
Two alternative ways to get a clean state: Want demo data instead? Two seeders are still available (they create
different users — see the Login table above):
1. **App seeder** (realistic demo data, keeps the Casdoor org as-is): 1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
```powershell ```powershell
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
``` ```
-1
View File
@@ -31,7 +31,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
@router.post("/login") @router.post("/login")
+1 -1
View File
@@ -35,7 +35,7 @@ to re-run).
## 2. Login flow (in-page, no redirect) ## 2. Login flow (in-page, no redirect)
``` ```
Browser ──► POST /api/v1/auth/login {username, password, organization?} Browser ──► POST /api/v1/auth/login {username, password}
│ │
▼ ▼
Backend ──► POST http://casdoor:8000/api/login/oauth/access_token Backend ──► POST http://casdoor:8000/api/login/oauth/access_token
-1
View File
@@ -1460,7 +1460,6 @@ export const apiV26 = {
login: async (body: { login: async (body: {
username: string; username: string;
password: string; password: string;
organization?: string;
}): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => { }): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => {
const res = await request<{ const res = await request<{
data: { data: {
+30 -29
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { ClipboardList, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react"; import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import type { Project } from "../../lib/types"; import type { Project } from "../../lib/types";
// ============================================================ // ============================================================
// ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت) // ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت)
// فرم اصلی: Username / Password / Organization (اختیاری) → // فرم اصلی: Username / Password →
// POST /api/v1/auth/login (بک‌اند با grant_type=password از // POST /api/v1/auth/login (بک‌اند با grant_type=password از
// Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند) // Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند)
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای: // حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای:
@@ -36,7 +36,7 @@ export function LoginPage() {
// ---- فرم اصلی (Casdoor) ---- // ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [organization, setOrganization] = useState(""); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// ---- پنل حالت توسعه (۳ مرحله) ---- // ---- پنل حالت توسعه (۳ مرحله) ----
@@ -71,7 +71,6 @@ export function LoginPage() {
const { access_token, user } = await apiV26.auth.login({ const { access_token, user } = await apiV26.auth.login({
username: username.trim(), username: username.trim(),
password, password,
...(organization.trim() ? { organization: organization.trim() } : {}),
}); });
if (!user.role) { if (!user.role) {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
@@ -101,7 +100,6 @@ export function LoginPage() {
const { user: adminUser } = await apiV26.auth.login({ const { user: adminUser } = await apiV26.auth.login({
username: devAdminUser.trim(), username: devAdminUser.trim(),
password: devAdminPass, password: devAdminPass,
...(organization.trim() ? { organization: organization.trim() } : {}),
}); });
if (adminUser.role !== "admin") { if (adminUser.role !== "admin") {
toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است"); toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است");
@@ -298,30 +296,33 @@ export function LoginPage() {
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password"> <label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password">
رمز عبور رمز عبور
</label> </label>
<input <div className="relative">
id="login-password" <input
type="password" id="login-password"
autoComplete="current-password" type={showPassword ? "text" : "password"}
dir="ltr" autoComplete="current-password"
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" dir="ltr"
placeholder="••••••" className="h-10 w-full rounded-lg border border-line bg-panel px-3 pr-10 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={password} placeholder="••••••"
onChange={(e) => setPassword(e.target.value)} value={password}
/> onChange={(e) => setPassword(e.target.value)}
</div> />
<div className="field"> <button
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-org"> type="button"
سازمان (اختیاری) onClick={() => setShowPassword((v) => !v)}
</label> aria-label={showPassword ? "Hide password" : "Show password"}
<input aria-pressed={showPassword}
id="login-org" aria-controls="login-password"
type="text" title={showPassword ? "پنهان کردن رمز" : "نمایش رمز"}
dir="ltr" className="absolute inset-y-0 right-0 flex cursor-pointer items-center rounded-lg px-3 text-muted transition-colors hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" >
placeholder="inventory" {showPassword ? (
value={organization} <EyeOff className="h-4 w-4" aria-hidden="true" />
onChange={(e) => setOrganization(e.target.value)} ) : (
/> <Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
</div> </div>
<button <button
type="submit" type="submit"