Skip to content

Commit 6f0ec36

Browse files
committed
feat: user management
1 parent 7cc3514 commit 6f0ec36

30 files changed

Lines changed: 1602 additions & 250 deletions

api/core/deps.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ def get_current_user(
4848
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
4949
db: Annotated[Session, Depends(get_db)],
5050
) -> User:
51-
"""Require a valid Bearer JWT and return the ``User`` row."""
51+
"""Require a valid Bearer JWT and return the ``User`` row.
52+
53+
Tokens belonging to non-approved users (banned, rejected, pending) are
54+
rejected so revoking access takes effect on every API call.
55+
"""
5256
if credentials is None:
5357
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
5458
try:
@@ -59,6 +63,8 @@ def get_current_user(
5963
user = db.get(User, user_id)
6064
if user is None:
6165
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
66+
if user.status != "approved":
67+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access revoked")
6268
return user
6369

6470

@@ -75,3 +81,12 @@ def get_current_user_optional(
7581
except (JWTError, ValueError):
7682
return None
7783
return db.get(User, user_id)
84+
85+
86+
def get_current_admin(
87+
user: Annotated[User, Depends(get_current_user)],
88+
) -> User:
89+
"""Require a logged-in user with the admin flag."""
90+
if not user.is_admin:
91+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
92+
return user

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from fastapi.responses import JSONResponse
1818

1919
from config import get_settings
20+
from routes import access_requests as access_requests_routes
2021
from routes import articles as articles_routes
2122
from routes import auth as auth_routes
2223
from routes import ebay_route
@@ -95,6 +96,7 @@ def health() -> dict[str, str]:
9596

9697
app.include_router(auth_routes.router, prefix="/auth")
9798
app.include_router(users_routes.router)
99+
app.include_router(access_requests_routes.router)
98100
app.include_router(articles_routes.router)
99101
app.include_router(settings_route.router)
100102
app.include_router(ebay_route.router)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Access requests, admin flag and password setup tokens.
2+
3+
ALTER TABLE users MODIFY COLUMN `password` VARCHAR(255) NULL;
4+
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin TINYINT(1) NOT NULL DEFAULT 0;
5+
ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'pending';
6+
ALTER TABLE users ADD COLUMN IF NOT EXISTS request_message TEXT NULL;
7+
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_setup_token VARCHAR(64) NULL;
8+
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_setup_expires_at DATETIME(6) NULL;
9+
10+
-- Existing rows (pre-migration) are real users created by the admin → mark approved.
11+
UPDATE users SET status = 'approved' WHERE status IS NULL OR status = '' OR status = 'pending';
12+
13+
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_password_setup_token ON users (password_setup_token);

api/models/user.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
import datetime as dt
66

7-
from sqlalchemy import DateTime, String, Text
7+
from sqlalchemy import Boolean, DateTime, String, Text
88
from sqlalchemy.orm import Mapped, mapped_column, relationship
99

1010
from models.base import Base
1111

12+
#: Application-wide allowed user statuses.
13+
USER_STATUSES = ("pending", "approved", "rejected", "banned")
14+
1215

1316
class User(Base):
1417
"""Application user with login and optional Vinted credentials (hashed)."""
@@ -17,7 +20,8 @@ class User(Base):
1720

1821
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
1922
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
20-
password: Mapped[str] = mapped_column("password", String(255))
23+
#: Bcrypt hash. ``None`` while the user has only requested access (no password yet).
24+
password: Mapped[str | None] = mapped_column("password", String(255), nullable=True)
2125
vinted_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
2226
vinted_password: Mapped[str | None] = mapped_column("vinted_password", String(255), nullable=True)
2327
ebay_refresh_token: Mapped[str | None] = mapped_column(Text(), nullable=True)
@@ -26,6 +30,23 @@ class User(Base):
2630
DateTime(timezone=True),
2731
nullable=True,
2832
)
33+
#: True only for the admin user (seeded from SEED_USER_EMAIL).
34+
is_admin: Mapped[bool] = mapped_column(Boolean(), default=False, server_default="0", nullable=False)
35+
#: pending | approved | rejected | banned
36+
status: Mapped[str] = mapped_column(
37+
String(16),
38+
default="pending",
39+
server_default="pending",
40+
nullable=False,
41+
)
42+
#: Message attached to the access request (filled from /request).
43+
request_message: Mapped[str | None] = mapped_column(Text(), nullable=True)
44+
#: Single-use token allowing the user to set/reset their password.
45+
password_setup_token: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True)
46+
password_setup_expires_at: Mapped[dt.datetime | None] = mapped_column(
47+
DateTime(timezone=True),
48+
nullable=True,
49+
)
2950
created_at: Mapped[dt.datetime] = mapped_column(
3051
DateTime(timezone=True),
3152
default=lambda: dt.datetime.now(dt.UTC),

api/routes/access_requests.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Public access requests + admin moderation + password setup links."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated
6+
7+
from fastapi import APIRouter, Depends, Request
8+
from sqlalchemy.orm import Session
9+
10+
from core.database import get_db
11+
from core.deps import get_current_admin
12+
from models.user import User
13+
from routes.users import _serialize_admin
14+
from schemas.access_requests import (
15+
AccessRequestCreate,
16+
AccessRequestSubmittedResponse,
17+
PasswordSetupCompleteRequest,
18+
PasswordSetupInfoResponse,
19+
PasswordSetupTokenResponse,
20+
)
21+
from schemas.users import AdminUserResponse
22+
from services import access_request_service
23+
from services.access_request_service import _as_utc as _as_utc_dt
24+
25+
26+
def _iso_utc(value) -> str:
27+
"""ISO 8601 in UTC (with explicit ``+00:00``) so JS clients parse correctly."""
28+
aware = _as_utc_dt(value)
29+
return aware.isoformat() if aware else ""
30+
31+
router = APIRouter()
32+
33+
# ----------------------------------------------------------------------- public
34+
35+
requests_router = APIRouter(prefix="/access-requests", tags=["access-requests"])
36+
37+
38+
@requests_router.post(
39+
"",
40+
response_model=AccessRequestSubmittedResponse,
41+
status_code=201,
42+
)
43+
def submit_access_request(
44+
body: AccessRequestCreate,
45+
db: Annotated[Session, Depends(get_db)],
46+
) -> AccessRequestSubmittedResponse:
47+
user = access_request_service.submit_access_request(
48+
db,
49+
email=body.email,
50+
message=body.message,
51+
)
52+
return AccessRequestSubmittedResponse(email=user.email)
53+
54+
55+
# ----------------------------------------------------------------------- admin
56+
57+
58+
@requests_router.post("/{user_id}/approve", response_model=AdminUserResponse)
59+
def approve(
60+
user_id: int,
61+
db: Annotated[Session, Depends(get_db)],
62+
_: Annotated[User, Depends(get_current_admin)],
63+
) -> AdminUserResponse:
64+
user = access_request_service.approve(db, user_id)
65+
return _serialize_admin(db, user)
66+
67+
68+
@requests_router.post("/{user_id}/reject", response_model=AdminUserResponse)
69+
def reject(
70+
user_id: int,
71+
db: Annotated[Session, Depends(get_db)],
72+
_: Annotated[User, Depends(get_current_admin)],
73+
) -> AdminUserResponse:
74+
user = access_request_service.reject(db, user_id)
75+
return _serialize_admin(db, user)
76+
77+
78+
@requests_router.post("/{user_id}/ban", response_model=AdminUserResponse)
79+
def ban(
80+
user_id: int,
81+
db: Annotated[Session, Depends(get_db)],
82+
_: Annotated[User, Depends(get_current_admin)],
83+
) -> AdminUserResponse:
84+
user = access_request_service.ban(db, user_id)
85+
return _serialize_admin(db, user)
86+
87+
88+
def _build_setup_url(request: Request, token: str) -> str:
89+
"""Try the ``Origin`` header first (browser caller), fallback to the API host."""
90+
origin = request.headers.get("origin") or request.headers.get("referer")
91+
if origin:
92+
# Strip any trailing path on referer, keep scheme + host.
93+
from urllib.parse import urlparse
94+
95+
parsed = urlparse(origin)
96+
if parsed.scheme and parsed.netloc:
97+
return f"{parsed.scheme}://{parsed.netloc}/setup-password/{token}"
98+
base = str(request.base_url).rstrip("/")
99+
return f"{base}/setup-password/{token}"
100+
101+
102+
@requests_router.post("/{user_id}/password-link", response_model=PasswordSetupTokenResponse)
103+
def issue_password_link(
104+
user_id: int,
105+
request: Request,
106+
db: Annotated[Session, Depends(get_db)],
107+
_: Annotated[User, Depends(get_current_admin)],
108+
) -> PasswordSetupTokenResponse:
109+
user, token = access_request_service.generate_password_setup_token(db, user_id)
110+
return PasswordSetupTokenResponse(
111+
token=token,
112+
expires_at=_iso_utc(user.password_setup_expires_at),
113+
setup_url=_build_setup_url(request, token),
114+
)
115+
116+
117+
# ----------------------------------------------------------------- password setup
118+
119+
password_setup_router = APIRouter(prefix="/password-setup", tags=["password-setup"])
120+
121+
122+
@password_setup_router.get("/{token}", response_model=PasswordSetupInfoResponse)
123+
def get_password_setup_info(
124+
token: str,
125+
db: Annotated[Session, Depends(get_db)],
126+
) -> PasswordSetupInfoResponse:
127+
user = access_request_service.get_user_by_setup_token(db, token)
128+
return PasswordSetupInfoResponse(
129+
email=user.email,
130+
expires_at=_iso_utc(user.password_setup_expires_at),
131+
)
132+
133+
134+
@password_setup_router.post("/{token}", response_model=PasswordSetupInfoResponse)
135+
def complete_password_setup(
136+
token: str,
137+
body: PasswordSetupCompleteRequest,
138+
db: Annotated[Session, Depends(get_db)],
139+
) -> PasswordSetupInfoResponse:
140+
user = access_request_service.consume_setup_token(db, token, password=body.password)
141+
return PasswordSetupInfoResponse(
142+
email=user.email,
143+
expires_at="",
144+
)
145+
146+
147+
router.include_router(requests_router)
148+
router.include_router(password_setup_router)

api/routes/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,12 @@ def login(body: LoginRequest, db: Annotated[Session, Depends(get_db)]) -> TokenR
2121
user = auth_service.authenticate_user(db, body.email, body.password)
2222
if user is None:
2323
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
24+
if user.status == "banned":
25+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account banned")
26+
if user.status == "rejected":
27+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access request rejected")
28+
if user.status != "approved":
29+
# 'pending' or any unknown intermediate state.
30+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access not granted yet")
2431
token = create_access_token(user.id)
2532
return TokenResponse(access_token=token)

0 commit comments

Comments
 (0)