Auth: Add basic JWT authorization

This commit is contained in:
t0xa 2026-10-23 21:08:43 +03:00
parent 001fdb5a73
commit 55a7168fcc
44 changed files with 1197 additions and 74 deletions

View file

@ -10,3 +10,9 @@ PYTHONPATH=/app
# Development settings # Development settings
DEBUG=true DEBUG=true
# Auth
# Signs access tokens. Generate: python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_SECRET=change-me-to-a-long-random-string
# Set to true in production (HTTPS only), the web client's refresh cookie is then Secure
COOKIE_SECURE=false

View file

@ -73,13 +73,19 @@ docker compose exec fastapi-app mypy app/
- `Approach` - single set with weight and reps - `Approach` - single set with weight and reps
**API Structure** (backend, JSON only): **API Structure** (backend, JSON only):
- `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/` - `/api/v1/auth/` (login, refresh, logout, me), `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
**Frontend** (`frontend/`): **Frontend** (`frontend/`):
- `index.html`, `exercises.html`, `parse.html`, `users.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal) - `index.html`, `exercises.html`, `parse.html`, `users.html`, `login.html` - pages; `js/auth.js` (tokens, refresh, logout) and `js/page.js` (every page imports it first: requires a login, fills the sidebar, exports `user`); `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal)
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code - `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS) - `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
### Authentication
Access token: JWT (HS256, 15 min, `JWT_SECRET` in `.env`), sent as `Authorization: Bearer`. Refresh token: random opaque string, 30 days, only its hash is stored (`refresh_tokens`); it works once (rotation), reusing a used one revokes the whole chain. `POST /auth/login/` with `client: "web"` puts the refresh token only into an httpOnly `SameSite=Strict` cookie (path `/api/v1/auth`, `Secure` when `COOKIE_SECURE=true`, set it in production with HTTPS); `client: "native"` (iOS) returns it in the body for the Keychain. The web frontend keeps the access token in `sessionStorage`.
There is no registration: only a superuser creates users (`/api/v1/users/`). The first login: `docker compose exec fastapi-app python -m app.cli set-password EMAIL [--superuser]`. Access: a superuser sees and changes everything; a trainee sees and changes their trainings; a trainer only sees the trainings where they are the trainer. Exercise statistics are about the user's own trainings (`?trainee_id=` narrows to one trainee).
### Database ### Database
Trainings belong to a trainee (`trainings.trainee_id`, required) and optionally have a trainer (`trainings.trainer_id`, NULL = solo); both are users (role `trainee` / `trainer`). A user with trainings can't be deleted. `python -m app.cli import` takes the trainer from the note header and matches it with a trainer user by "First Last". Trainings belong to a trainee (`trainings.trainee_id`, required) and optionally have a trainer (`trainings.trainer_id`, NULL = solo); both are users (role `trainee` / `trainer`). A user with trainings can't be deleted. `python -m app.cli import` takes the trainer from the note header and matches it with a trainer user by "First Last".

View file

@ -1,9 +1,17 @@
from fastapi import Depends import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.connection import get_session from app.core.database.connection import get_session
from app.core.database.repository import TrainingRepository from app.core.database.repository import TrainingRepository
from app.core.database.token_repository import RefreshTokenRepository
from app.core.database.user_repository import UserRepository from app.core.database.user_repository import UserRepository
from app.core.dto.user import UserDTO
from app.core.security import decode_access_token
# auto_error=False: a missing header gets the same 401 as a bad token
bearer_scheme = HTTPBearer(auto_error=False)
def get_training_repository( def get_training_repository(
@ -16,3 +24,38 @@ def get_user_repository(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> UserRepository: ) -> UserRepository:
return UserRepository(session) return UserRepository(session)
def get_token_repository(
session: AsyncSession = Depends(get_session),
) -> RefreshTokenRepository:
return RefreshTokenRepository(session)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
repository: UserRepository = Depends(get_user_repository),
) -> UserDTO:
"""The user of the access token. The user is read from the database on every request, so
a deactivated user loses access at once, not when the token expires."""
unauthorized = HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials is None:
raise unauthorized
try:
user_id = int(decode_access_token(credentials.credentials)["sub"])
except (jwt.PyJWTError, ValueError):
raise unauthorized
user = await repository.get(user_id)
if user is None or not user.is_active:
raise unauthorized
return user
async def require_superuser(user: UserDTO = Depends(get_current_user)) -> UserDTO:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser only")
return user

123
backend/app/api/v1/auth.py Normal file
View file

@ -0,0 +1,123 @@
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
from app.api.deps import get_current_user, get_token_repository, get_user_repository
from app.config import settings
from app.core.database.token_repository import RefreshTokenRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.user import UserDTO
from app.core.security import (
DUMMY_PASSWORD_HASH,
create_access_token,
hash_token,
new_refresh_token,
verify_password,
)
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
router = APIRouter()
REFRESH_COOKIE = "refresh_token"
# The cookie is sent only to the auth endpoints
COOKIE_PATH = "/api/v1/auth"
def _invalid_credentials() -> HTTPException:
return HTTPException(status_code=401, detail="Invalid email or password")
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
REFRESH_COOKIE,
token,
max_age=settings.REFRESH_TOKEN_TTL_DAYS * 24 * 3600,
path=COOKIE_PATH,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite="strict",
)
async def _issue_tokens(
user: UserDTO,
family_id: str,
web: bool,
response: Response,
tokens: RefreshTokenRepository,
) -> TokenResponse:
refresh_token = new_refresh_token()
await tokens.create(
user_id=user.id,
family_id=family_id,
token_hash=hash_token(refresh_token),
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
if web:
_set_refresh_cookie(response, refresh_token)
return TokenResponse(
access_token=create_access_token(user.id, user.role.value),
expires_in=settings.ACCESS_TOKEN_TTL_MINUTES * 60,
refresh_token=None if web else refresh_token,
)
@router.post("/login/", response_model=TokenResponse)
async def login(
payload: LoginRequest,
response: Response,
users: UserRepository = Depends(get_user_repository),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
found = await users.get_credentials(payload.email.strip().lower())
# Check the password even for an unknown email: the response time must not tell them apart
user, password_hash = found if found else (None, DUMMY_PASSWORD_HASH)
if not verify_password(payload.password, password_hash) or user is None or not user.is_active:
raise _invalid_credentials()
await users.update(user.id, {"last_login": datetime.now(timezone.utc)})
return await _issue_tokens(user, uuid.uuid4().hex, payload.client == "web", response, tokens)
@router.post("/refresh/", response_model=TokenResponse)
async def refresh(
response: Response,
payload: Optional[RefreshRequest] = None,
cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE),
users: UserRepository = Depends(get_user_repository),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
"""Exchange a refresh token for a new pair. The old refresh token stops working."""
body_token = payload.refresh_token if payload else None
token = body_token or cookie_token
web = body_token is None
claim = await tokens.claim(hash_token(token)) if token else None
user = await users.get(claim.user_id) if claim else None
if claim is None or user is None or not user.is_active:
if claim is not None:
await tokens.revoke_all_of_user(claim.user_id)
response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH)
raise HTTPException(status_code=401, detail="Invalid refresh token")
return await _issue_tokens(user, claim.family_id, web, response, tokens)
@router.post("/logout/")
async def logout(
response: Response,
payload: Optional[RefreshRequest] = None,
cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
"""Revoke the refresh token of this login. The access token expires by itself in minutes."""
token = (payload.refresh_token if payload else None) or cookie_token
if token:
await tokens.revoke_family_of(hash_token(token))
response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH)
return {"status": "ok"}
@router.get("/me/", response_model=UserDTO)
async def me(user: UserDTO = Depends(get_current_user)):
return user

View file

@ -2,9 +2,10 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import get_training_repository from app.api.deps import get_current_user, get_training_repository
from app.core.database.repository import TrainingRepository from app.core.database.repository import TrainingRepository
from app.core.dto.training import ExerciseNameDTO from app.core.dto.training import ExerciseNameDTO
from app.core.dto.user import UserDTO, UserRole
from app.core.metrics import Record, summarize from app.core.metrics import Record, summarize
from app.schemas.exercises import ( from app.schemas.exercises import (
ExerciseProgressResponse, ExerciseProgressResponse,
@ -16,36 +17,57 @@ from app.schemas.exercises import (
router = APIRouter() router = APIRouter()
def stats_trainee_id(user: UserDTO, trainee_id: Optional[int]) -> Optional[int]:
"""Whose trainings the statistics are about: a trainee's own unless another one is asked
for (which, like everything else, is limited to the trainings the user may see)."""
if trainee_id is None and user.role == UserRole.trainee:
return user.id
return trainee_id
def record_response(record: Optional[Record]) -> Optional[RecordResponse]: def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
return RecordResponse(value=record.value, date=record.date) if record else None return RecordResponse(value=record.value, date=record.date) if record else None
@router.get("/", response_model=List[ExerciseNameDTO]) @router.get("/", response_model=List[ExerciseNameDTO])
async def list_exercise_names( async def list_exercise_names(
trainee_id: Optional[int] = None,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository), repository: TrainingRepository = Depends(get_training_repository),
): ):
"""Distinct exercise names with the number of times each was done.""" """Distinct exercise names with the number of times each was done."""
return await repository.list_exercise_names() return await repository.list_exercise_names(
viewer=user, trainee_id=stats_trainee_id(user, trainee_id)
)
@router.get("/progress/", response_model=ExerciseProgressResponse) @router.get("/progress/", response_model=ExerciseProgressResponse)
async def exercise_progress( async def exercise_progress(
name: str, name: str,
trainee_id: Optional[int] = None,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository), repository: TrainingRepository = Depends(get_training_repository),
): ):
"""The whole history of the exercise with progress metrics. """The whole history of the exercise with progress metrics.
The name is matched exactly, but case-insensitively: what was typed may differ in case. The name is matched exactly, but case-insensitively: what was typed may differ in case.
""" """
trainee_id = stats_trainee_id(user, trainee_id)
names_by_casefold = { names_by_casefold = {
item.name.casefold(): item.name item.name.casefold(): item.name
for item in await repository.list_exercise_names() for item in await repository.list_exercise_names(
viewer=user, trainee_id=trainee_id
)
} }
exact_name = names_by_casefold.get(name.strip().casefold()) exact_name = names_by_casefold.get(name.strip().casefold())
if exact_name is None: if exact_name is None:
raise HTTPException(status_code=404, detail="Exercise not found") raise HTTPException(status_code=404, detail="Exercise not found")
progress = summarize(await repository.exercise_history(exact_name)) progress = summarize(
await repository.exercise_history(
exact_name, viewer=user, trainee_id=trainee_id
)
)
return ExerciseProgressResponse( return ExerciseProgressResponse(
name=exact_name, name=exact_name,
weighted=progress.weighted, weighted=progress.weighted,
@ -59,6 +81,7 @@ async def exercise_progress(
rows=[ rows=[
SessionRowResponse( SessionRowResponse(
training_id=row.session.training_id, training_id=row.session.training_id,
trainee_id=row.session.trainee_id,
date=row.session.date, date=row.session.date,
trainer=row.session.trainer, trainer=row.session.trainer,
splitted_weight=row.session.splitted_weight, splitted_weight=row.session.splitted_weight,

View file

@ -1,10 +1,18 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.api.v1 import exercises, parser, health, trainings, users
from app.api.deps import get_current_user
from app.api.v1 import auth, exercises, parser, health, trainings, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(parser.router, prefix="/parser", tags=["parser"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(
parser.router,
prefix="/parser",
tags=["parser"],
dependencies=[Depends(get_current_user)],
)
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"]) api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"]) api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])

View file

@ -2,30 +2,23 @@ from typing import List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import get_training_repository, get_user_repository from app.api.deps import (
get_current_user,
get_training_repository,
get_user_repository,
require_superuser,
)
from app.core.database.repository import TrainingRepository from app.core.database.repository import TrainingRepository
from app.core.database.user_repository import UserRepository from app.core.database.user_repository import UserRepository
from app.core.dto.training import TrainingDTO from app.core.dto.training import TrainingDTO
from app.core.dto.user import UserRole from app.core.dto.user import UserDTO, UserRole
from app.schemas.trainings import TrainingUpdate
from app.core.parsers.obsidian import parse_training_data from app.core.parsers.obsidian import parse_training_data
from app.schemas.trainings import TrainingUpdate
router = APIRouter() router = APIRouter()
@router.get("/obsidian/")
async def obsidian_trainings_list():
return {"data": parse_training_data()}
@router.get("/", response_model=List[TrainingDTO])
async def get_trainings_list(
repository: TrainingRepository = Depends(get_training_repository),
):
return await repository.list()
async def _check_participant( async def _check_participant(
repository: UserRepository, user_id: int, role: UserRole repository: UserRepository, user_id: int, role: UserRole
) -> None: ) -> None:
@ -36,12 +29,42 @@ async def _check_participant(
) )
async def _get_editable_training(
training_id: int, user: UserDTO, repository: TrainingRepository
) -> TrainingDTO:
"""A training the user may change: their own (as the trainee), or any for a superuser.
The trainer can only see the training. A training the user can't see at all is a 404.
"""
training = await repository.get(training_id, viewer=user)
if training is None:
raise HTTPException(status_code=404, detail="Training not found")
if not (user.is_superuser or training.trainee_id == user.id):
raise HTTPException(status_code=403, detail="Only the trainee can change a training")
return training
@router.get("/obsidian/", dependencies=[Depends(require_superuser)])
async def obsidian_trainings_list():
return {"data": parse_training_data()}
@router.get("/", response_model=List[TrainingDTO])
async def get_trainings_list(
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
"""Trainings the user takes part in (all of them for a superuser)."""
return await repository.list(viewer=user)
@router.get("/{training_id}/", response_model=TrainingDTO) @router.get("/{training_id}/", response_model=TrainingDTO)
async def get_training( async def get_training(
training_id: int, training_id: int,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository), repository: TrainingRepository = Depends(get_training_repository),
): ):
training = await repository.get(training_id) training = await repository.get(training_id, viewer=user)
if training is None: if training is None:
raise HTTPException(status_code=404, detail="Training not found") raise HTTPException(status_code=404, detail="Training not found")
return training return training
@ -51,10 +74,17 @@ async def get_training(
async def update_training( async def update_training(
training_id: int, training_id: int,
payload: TrainingUpdate, payload: TrainingUpdate,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository), repository: TrainingRepository = Depends(get_training_repository),
user_repository: UserRepository = Depends(get_user_repository), user_repository: UserRepository = Depends(get_user_repository),
): ):
"""Replace the training: its date, trainee, trainer, exercises and approaches.""" """Replace the training: its date, trainee, trainer, exercises and approaches."""
current = await _get_editable_training(training_id, user, repository)
if payload.trainee_id != current.trainee_id:
if not user.is_superuser:
raise HTTPException(
status_code=403, detail="Only a superuser can change the trainee"
)
await _check_participant(user_repository, payload.trainee_id, UserRole.trainee) await _check_participant(user_repository, payload.trainee_id, UserRole.trainee)
if payload.trainer_id is not None: if payload.trainer_id is not None:
await _check_participant(user_repository, payload.trainer_id, UserRole.trainer) await _check_participant(user_repository, payload.trainer_id, UserRole.trainer)
@ -67,9 +97,9 @@ async def update_training(
@router.delete("/{training_id}/") @router.delete("/{training_id}/")
async def delete_training( async def delete_training(
training_id: int, training_id: int,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository), repository: TrainingRepository = Depends(get_training_repository),
): ):
deleted = await repository.delete([training_id]) await _get_editable_training(training_id, user, repository)
if not deleted: await repository.delete([training_id])
raise HTTPException(status_code=404, detail="Training not found")
return {"status": "ok"} return {"status": "ok"}

View file

@ -2,13 +2,13 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import get_user_repository from app.api.deps import get_current_user, get_user_repository, require_superuser
from app.core.database.user_repository import ( from app.core.database.user_repository import (
EmailTakenError, EmailTakenError,
UserInUseError, UserInUseError,
UserRepository, UserRepository,
) )
from app.core.dto.user import UserDTO, UserRole from app.core.dto.user import TrainerDTO, UserDTO, UserRole
from app.core.security import hash_password from app.core.security import hash_password
from app.schemas.users import UserCreate, UserUpdate from app.schemas.users import UserCreate, UserUpdate
@ -17,7 +17,7 @@ router = APIRouter()
EMAIL_TAKEN = HTTPException(status_code=409, detail="Email already registered") EMAIL_TAKEN = HTTPException(status_code=409, detail="Email already registered")
@router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superuser)])
async def create_user( async def create_user(
payload: UserCreate, payload: UserCreate,
repository: UserRepository = Depends(get_user_repository), repository: UserRepository = Depends(get_user_repository),
@ -34,7 +34,7 @@ async def create_user(
raise EMAIL_TAKEN raise EMAIL_TAKEN
@router.get("/", response_model=List[UserDTO]) @router.get("/", response_model=List[UserDTO], dependencies=[Depends(require_superuser)])
async def list_users( async def list_users(
role: Optional[UserRole] = None, role: Optional[UserRole] = None,
repository: UserRepository = Depends(get_user_repository), repository: UserRepository = Depends(get_user_repository),
@ -42,7 +42,23 @@ async def list_users(
return await repository.list(role) return await repository.list(role)
@router.get("/{user_id}/", response_model=UserDTO) @router.get("/trainers/", response_model=List[TrainerDTO])
async def list_trainers(
_: UserDTO = Depends(get_current_user),
repository: UserRepository = Depends(get_user_repository),
):
"""Trainers to pick from in a training form: any signed-in user may ask for them."""
return [
TrainerDTO(
id=user.id,
name=f"{user.first_name} {user.last_name}".strip() or user.email,
)
for user in await repository.list(UserRole.trainer)
if user.is_active
]
@router.get("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)])
async def get_user( async def get_user(
user_id: int, user_id: int,
repository: UserRepository = Depends(get_user_repository), repository: UserRepository = Depends(get_user_repository),
@ -53,7 +69,7 @@ async def get_user(
return user return user
@router.patch("/{user_id}/", response_model=UserDTO) @router.patch("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)])
async def update_user( async def update_user(
user_id: int, user_id: int,
payload: UserUpdate, payload: UserUpdate,
@ -74,11 +90,14 @@ async def update_user(
return user return user
@router.delete("/{user_id}/") @router.delete("/{user_id}/", dependencies=[Depends(require_superuser)])
async def delete_user( async def delete_user(
user_id: int, user_id: int,
current: UserDTO = Depends(require_superuser),
repository: UserRepository = Depends(get_user_repository), repository: UserRepository = Depends(get_user_repository),
): ):
if user_id == current.id:
raise HTTPException(status_code=400, detail="You can't delete yourself")
try: try:
deleted = await repository.delete(user_id) deleted = await repository.delete(user_id)
except UserInUseError: except UserInUseError:

View file

@ -1,23 +1,31 @@
"""Command line helpers. """Command line helpers.
python -m app.cli import [--replace] [--trainee-email EMAIL] python -m app.cli import [--replace] [--trainee-email EMAIL]
python -m app.cli set-password EMAIL [--superuser]
Imports all trainings from the Obsidian and Apple notes into the database as trainings of one Imports all trainings from the Obsidian and Apple notes into the database as trainings of one
trainee. The trainer from a note header is matched with a user of the trainer role by trainee. The trainer from a note header is matched with a user of the trainer role by
"First Last" ("соло" means no trainer). The schema must already exist (`alembic upgrade head`). "First Last" ("соло" means no trainer). The schema must already exist (`alembic upgrade head`).
`set-password` asks for a new password of the user (and with --superuser makes them a
superuser). It is the way to get a working login: users are created only by superusers, and
the users made by migrations have random passwords.
""" """
import argparse import argparse
import asyncio import asyncio
import getpass
import logging import logging
from typing import List, Optional, Set from typing import Any, Dict, List, Optional, Set
from app.config import settings from app.config import settings
from app.core.database.connection import create_engine, create_session_factory from app.core.database.connection import create_engine, create_session_factory
from app.core.database.repository import TrainingRepository from app.core.database.repository import TrainingRepository
from app.core.database.token_repository import RefreshTokenRepository
from app.core.database.user_repository import UserRepository from app.core.database.user_repository import UserRepository
from app.core.dto.training import TrainingDTO from app.core.dto.training import TrainingDTO
from app.core.dto.user import UserRole from app.core.dto.user import UserRole
from app.core.security import hash_password
from app.core.parsers.apple import AppleNotesParser from app.core.parsers.apple import AppleNotesParser
from app.core.parsers.obsidian import ObsidianNotesParser from app.core.parsers.obsidian import ObsidianNotesParser
@ -90,6 +98,26 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
print(f"Imported {len(trainings)} trainings") print(f"Imported {len(trainings)} trainings")
async def set_password(email: str, password: str, superuser: bool) -> None:
engine = create_engine(settings.get_postgres_database_url())
try:
async with create_session_factory(engine)() as session:
users = UserRepository(session)
found = await users.get_credentials(email.strip().lower())
if found is None:
raise SystemExit(f"No user with email {email}")
user, _ = found
values: Dict[str, Any] = {"password_hash": hash_password(password)}
if superuser:
values["is_superuser"] = True
await users.update(user.id, values)
# Whoever was logged in with the old password is logged out
await RefreshTokenRepository(session).revoke_all_of_user(user.id)
finally:
await engine.dispose()
print(f"Password of {email} is changed")
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
arg_parser = argparse.ArgumentParser(prog="app.cli") arg_parser = argparse.ArgumentParser(prog="app.cli")
@ -102,10 +130,22 @@ def main() -> None:
"--trainee-email", "--trainee-email",
help="whose trainings these are (not needed when there is a single trainee)", help="whose trainings these are (not needed when there is a single trainee)",
) )
password_parser = subcommands.add_parser("set-password", help="set a user's password")
password_parser.add_argument("email")
password_parser.add_argument(
"--superuser", action="store_true", help="also make the user a superuser"
)
args = arg_parser.parse_args() args = arg_parser.parse_args()
if args.command == "import": if args.command == "import":
asyncio.run(import_notes(args.replace, args.trainee_email)) asyncio.run(import_notes(args.replace, args.trainee_email))
elif args.command == "set-password":
password = getpass.getpass("New password: ")
if len(password) < 8:
raise SystemExit("The password must be at least 8 characters long")
if password != getpass.getpass("Repeat: "):
raise SystemExit("The passwords differ")
asyncio.run(set_password(args.email, password, args.superuser))
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -11,6 +11,13 @@ class Settings(BaseSettings):
version: str = "0.0.1" version: str = "0.0.1"
debug: bool = False debug: bool = False
# Auth: JWT_SECRET signs access tokens, it must be long and random (see .env.example)
JWT_SECRET: str = ""
ACCESS_TOKEN_TTL_MINUTES: int = 15
REFRESH_TOKEN_TTL_DAYS: int = 30
# The refresh cookie of the web client is sent only over HTTPS when this is on
COOKIE_SECURE: bool = False
# Postgres database settings # Postgres database settings
POSTGRES_DATABASE_URL: Optional[str] = None POSTGRES_DATABASE_URL: Optional[str] = None
POSTGRES_DATABASE_HOST: str = "localhost" POSTGRES_DATABASE_HOST: str = "localhost"

View file

@ -1,7 +1,7 @@
from collections import defaultdict from collections import defaultdict
from typing import Dict, List, Optional, Sequence from typing import Dict, List, Optional, Sequence
from sqlalchemy import delete, func, insert, select, text, update from sqlalchemy import ColumnElement, delete, func, insert, or_, select, text, true, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.tables import approaches, exercises, trainings, users from app.core.database.tables import approaches, exercises, trainings, users
@ -12,6 +12,7 @@ from app.core.dto.training import (
ExerciseSessionDTO, ExerciseSessionDTO,
TrainingDTO, TrainingDTO,
) )
from app.core.dto.user import UserDTO
_trainer = users.alias("trainer") _trainer = users.alias("trainer")
# "First Last", the email if the name is empty # "First Last", the email if the name is empty
@ -28,6 +29,22 @@ def _trainings_query():
) )
def _scope(viewer: Optional[UserDTO], trainee_id: Optional[int]) -> List[ColumnElement]:
"""Conditions on `trainings`: what the viewer may see, narrowed to one trainee if asked.
A superuser sees everything, anyone else the trainings where they are the trainee or the
trainer. No viewer means no restriction (imports, tests).
"""
conditions: List[ColumnElement] = []
if viewer is not None and not viewer.is_superuser:
conditions.append(
or_(trainings.c.trainee_id == viewer.id, trainings.c.trainer_id == viewer.id)
)
if trainee_id is not None:
conditions.append(trainings.c.trainee_id == trainee_id)
return conditions
class TrainingRepository: class TrainingRepository:
"""Storage for trainings together with their exercises and approaches (SQLAlchemy Core).""" """Storage for trainings together with their exercises and approaches (SQLAlchemy Core)."""
@ -60,10 +77,14 @@ class TrainingRepository:
await self._session.commit() await self._session.commit()
return training_ids return training_ids
async def get(self, training_id: int) -> Optional[TrainingDTO]: async def get(
self, training_id: int, *, viewer: Optional[UserDTO] = None
) -> Optional[TrainingDTO]:
row = ( row = (
await self._session.execute( await self._session.execute(
_trainings_query().where(trainings.c.id == training_id) _trainings_query().where(
trainings.c.id == training_id, *_scope(viewer, None)
)
) )
).first() ).first()
if row is None: if row is None:
@ -120,19 +141,25 @@ class TrainingRepository:
await self._session.commit() await self._session.commit()
return True return True
async def list(self) -> List[TrainingDTO]: async def list(self, *, viewer: Optional[UserDTO] = None) -> List[TrainingDTO]:
training_rows = ( training_rows = (
await self._session.execute( await self._session.execute(
_trainings_query().order_by( _trainings_query()
trainings.c.date.desc(), trainings.c.id.desc() .where(*_scope(viewer, None))
) .order_by(trainings.c.date.desc(), trainings.c.id.desc())
) )
).all() ).all()
approaches_by_exercise = await self._approaches_by_exercise() exercise_rows = (
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list) await self._session.execute(
exercise_rows = await self._session.execute( select(exercises)
select(exercises).order_by(exercises.c.id) .where(exercises.c.training_id.in_([row.id for row in training_rows]))
.order_by(exercises.c.id)
) )
).all()
approaches_by_exercise = await self._approaches_by_exercise(
[row.id for row in exercise_rows]
)
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
for row in exercise_rows: for row in exercise_rows:
exercises_by_training[row.training_id].append( exercises_by_training[row.training_id].append(
ExerciseDTO( ExerciseDTO(
@ -155,29 +182,41 @@ class TrainingRepository:
for row in training_rows for row in training_rows
] ]
async def list_exercise_names(self) -> List[ExerciseNameDTO]: async def list_exercise_names(
self, *, viewer: Optional[UserDTO] = None, trainee_id: Optional[int] = None
) -> List[ExerciseNameDTO]:
"""Distinct exercise names with the number of times each was done.""" """Distinct exercise names with the number of times each was done."""
query = select( query = (
exercises.c.name, func.count(exercises.c.id).label("times") select(exercises.c.name, func.count(exercises.c.id).label("times"))
).group_by(exercises.c.name) .join(trainings, trainings.c.id == exercises.c.training_id)
.where(*_scope(viewer, trainee_id))
.group_by(exercises.c.name)
)
result = await self._session.execute(query) result = await self._session.execute(query)
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result] names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
# Sorted here, not in SQL: the order must not depend on DB collation # Sorted here, not in SQL: the order must not depend on DB collation
return sorted(names, key=lambda item: item.name.casefold()) return sorted(names, key=lambda item: item.name.casefold())
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]: async def exercise_history(
self,
name: str,
*,
viewer: Optional[UserDTO] = None,
trainee_id: Optional[int] = None,
) -> List[ExerciseSessionDTO]:
"""Every occurrence of the exercise, newest first.""" """Every occurrence of the exercise, newest first."""
query = ( query = (
select( select(
exercises.c.id, exercises.c.id,
exercises.c.splitted_weight, exercises.c.splitted_weight,
trainings.c.id.label("training_id"), trainings.c.id.label("training_id"),
trainings.c.trainee_id,
trainings.c.date, trainings.c.date,
_trainer_name, _trainer_name,
) )
.join(trainings, trainings.c.id == exercises.c.training_id) .join(trainings, trainings.c.id == exercises.c.training_id)
.outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id) .outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id)
.where(exercises.c.name == name) .where(exercises.c.name == name, *_scope(viewer, trainee_id))
.order_by(trainings.c.date.desc(), exercises.c.id.desc()) .order_by(trainings.c.date.desc(), exercises.c.id.desc())
) )
rows = (await self._session.execute(query)).all() rows = (await self._session.execute(query)).all()
@ -187,6 +226,7 @@ class TrainingRepository:
return [ return [
ExerciseSessionDTO( ExerciseSessionDTO(
training_id=row.training_id, training_id=row.training_id,
trainee_id=row.trainee_id,
date=row.date, date=row.date,
trainer=row.trainer, trainer=row.trainer,
splitted_weight=row.splitted_weight, splitted_weight=row.splitted_weight,

View file

@ -86,3 +86,16 @@ users = Table(
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("last_login", DateTime(timezone=True)), Column("last_login", DateTime(timezone=True)),
) )
refresh_tokens = Table(
"refresh_tokens",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
# Tokens issued by rotation from one login share a family: a reused token revokes it all
Column("family_id", String(32), nullable=False, index=True),
Column("token_hash", String(64), nullable=False, unique=True),
Column("expires_at", DateTime(timezone=True), nullable=False),
Column("revoked_at", DateTime(timezone=True)),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)

View file

@ -0,0 +1,96 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from sqlalchemy import func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.tables import refresh_tokens
@dataclass
class RefreshClaim:
user_id: int
family_id: str
class RefreshTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self, *, user_id: int, family_id: str, token_hash: str, expires_at: datetime
) -> None:
await self._session.execute(
insert(refresh_tokens).values(
user_id=user_id,
family_id=family_id,
token_hash=token_hash,
expires_at=expires_at,
)
)
await self._session.commit()
async def claim(self, token_hash: str) -> Optional[RefreshClaim]:
"""Use up a valid refresh token (it can be used only once).
The check and the revocation are one UPDATE, so of two simultaneous requests with the
same token only one wins. A token that was already used up means it leaked (or was
replayed): the whole family is revoked and None is returned.
"""
row = (
await self._session.execute(
update(refresh_tokens)
.where(
refresh_tokens.c.token_hash == token_hash,
refresh_tokens.c.revoked_at.is_(None),
refresh_tokens.c.expires_at > func.now(),
)
.values(revoked_at=func.now())
.returning(refresh_tokens.c.user_id, refresh_tokens.c.family_id)
)
).first()
if row is not None:
await self._session.commit()
return RefreshClaim(row.user_id, row.family_id)
used = (
await self._session.execute(
select(refresh_tokens.c.family_id).where(
refresh_tokens.c.token_hash == token_hash,
refresh_tokens.c.revoked_at.is_not(None),
)
)
).first()
if used is not None:
await self._revoke_family(used.family_id)
await self._session.commit()
return None
async def revoke_family_of(self, token_hash: str) -> None:
"""Log out: revoke the token and everything issued from it."""
family = (
await self._session.execute(
select(refresh_tokens.c.family_id).where(
refresh_tokens.c.token_hash == token_hash
)
)
).first()
if family is not None:
await self._revoke_family(family.family_id)
await self._session.commit()
async def revoke_all_of_user(self, user_id: int) -> None:
await self._session.execute(
update(refresh_tokens)
.where(refresh_tokens.c.user_id == user_id, refresh_tokens.c.revoked_at.is_(None))
.values(revoked_at=func.now())
)
await self._session.commit()
async def _revoke_family(self, family_id: str) -> None:
await self._session.execute(
update(refresh_tokens)
.where(refresh_tokens.c.family_id == family_id, refresh_tokens.c.revoked_at.is_(None))
.values(revoked_at=func.now())
)

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import delete, insert, select, update from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -54,6 +54,13 @@ class UserRepository:
).first() ).first()
return UserDTO.model_validate(row) if row else None return UserDTO.model_validate(row) if row else None
async def get_credentials(self, email: str) -> Optional[Tuple[UserDTO, str]]:
"""The user and the hash of the password, for logging in."""
row = (
await self._session.execute(select(users).where(users.c.email == email))
).first()
return (UserDTO.model_validate(row), row.password_hash) if row else None
async def list(self, role: Optional[UserRole] = None) -> List[UserDTO]: async def list(self, role: Optional[UserRole] = None) -> List[UserDTO]:
query = select(users).order_by(users.c.id) query = select(users).order_by(users.c.id)
if role is not None: if role is not None:

View file

@ -42,6 +42,7 @@ class ExerciseSessionDTO(DTOBase):
"""One occurrence of an exercise in the history of that exercise.""" """One occurrence of an exercise in the history of that exercise."""
training_id: int | None = None training_id: int | None = None
trainee_id: int | None = None
date: date date: date
trainer: str | None = None trainer: str | None = None
splitted_weight: bool = False splitted_weight: bool = False

View file

@ -23,3 +23,10 @@ class UserDTO(BaseModel):
is_superuser: bool is_superuser: bool
created_at: datetime created_at: datetime
last_login: datetime | None = None last_login: datetime | None = None
class TrainerDTO(BaseModel):
"""What any signed-in user may know about a trainer: enough to pick one in a form."""
id: int
name: str

View file

@ -1,16 +1,26 @@
"""Password hashing with scrypt from the standard library (no extra dependencies). """Passwords and tokens.
Stored format: scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. The parameters are stored with Passwords are hashed with scrypt from the standard library. Stored format:
the hash, so they can be raised later without invalidating existing passwords. scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. The parameters are stored with the hash, so
they can be raised later without invalidating existing passwords.
Access tokens are short-lived JWTs (HS256). Refresh tokens are opaque random strings, only their
hash is stored in the database.
""" """
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import secrets import secrets
from datetime import datetime, timedelta, timezone
import jwt
from app.config import settings
_N, _R, _P = 2**14, 8, 1 _N, _R, _P = 2**14, 8, 1
_KEY_LENGTH = 32 _KEY_LENGTH = 32
JWT_ALGORITHM = "HS256"
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes: def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
@ -37,3 +47,37 @@ def verify_password(password: str, stored: str) -> bool:
except ValueError: except ValueError:
return False return False
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
# Verified when the email is unknown, so that a login takes the same time either way
DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
def create_access_token(user_id: int, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iat": now,
"exp": now + timedelta(minutes=settings.ACCESS_TOKEN_TTL_MINUTES),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Payload of a valid access token, jwt.PyJWTError otherwise (bad signature, expired...)."""
return jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"require": ["sub", "exp"]},
)
def new_refresh_token() -> str:
return secrets.token_urlsafe(48)
def hash_token(token: str) -> str:
# The token is random with 384 bits of entropy: a plain fast hash is enough
return hashlib.sha256(token.encode()).hexdigest()

View file

@ -15,6 +15,8 @@ logging.basicConfig(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
if len(settings.JWT_SECRET) < 32:
raise RuntimeError("JWT_SECRET must be set to a random string of 32+ characters")
# Schema is managed by Alembic: run `alembic upgrade head` before starting the app # Schema is managed by Alembic: run `alembic upgrade head` before starting the app
engine = create_engine(settings.get_postgres_database_url()) engine = create_engine(settings.get_postgres_database_url())
app.state.session_factory = create_session_factory(engine) app.state.session_factory = create_session_factory(engine)

View file

@ -0,0 +1,24 @@
from typing import Literal, Optional
from pydantic import BaseModel
class LoginRequest(BaseModel):
email: str
password: str
# "web": the refresh token goes only into an httpOnly cookie, script code never sees it.
# "native" (iOS...): it is returned in the body, the app keeps it in the Keychain.
client: Literal["web", "native"] = "native"
class RefreshRequest(BaseModel):
"""Native clients send the refresh token in the body, the web client in the cookie."""
refresh_token: Optional[str] = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int # seconds
refresh_token: Optional[str] = None # None for the web client: it is in the cookie

View file

@ -16,6 +16,7 @@ class SessionMetricsResponse(BaseModel):
class SessionRowResponse(BaseModel): class SessionRowResponse(BaseModel):
training_id: Optional[int] training_id: Optional[int]
trainee_id: Optional[int]
date: date date: date
trainer: Optional[str] trainer: Optional[str]
splitted_weight: bool splitted_weight: bool

View file

@ -0,0 +1,51 @@
"""add refresh tokens
Also makes the owner a superuser (the first one, who creates the others).
Revision ID: 57f1f0337033
Revises: b1c2d3e4f5a6
Create Date: 2026-10-23 18:00:02.101967
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '57f1f0337033'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('refresh_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('family_id', sa.String(length=32), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###
# Somebody has to be able to create users: the owner of the data is the first superuser
op.execute("UPDATE users SET is_superuser = true WHERE email = 'pro100ton@gmail.com'")
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
# ### end Alembic commands ###

View file

@ -5,3 +5,4 @@ pytest-asyncio==1.4.0
mypy==1.17.1 mypy==1.17.1
types-grpcio==1.0.0.20250703 types-grpcio==1.0.0.20250703
types-protobuf==6.30.2.20250822 types-protobuf==6.30.2.20250822
httpx==0.28.1

View file

@ -6,3 +6,4 @@ python-dotenv==1.1.1
sqlalchemy[asyncio]==2.1.6 sqlalchemy[asyncio]==2.1.6
asyncpg==0.32.0 asyncpg==0.32.0
alembic==1.20.1 alembic==1.20.1
PyJWT==2.10.1

View file

@ -86,3 +86,20 @@ async def trainer(session):
last_name="Степан", last_name="Степан",
role=UserRole.trainer, role=UserRole.trainer,
) )
@pytest.fixture
async def client(session, monkeypatch):
"""HTTP client of the app on the test database (needs the `session` fixture's clean DB)."""
from httpx import ASGITransport, AsyncClient
from app.main import app
monkeypatch.setattr(settings, "JWT_SECRET", "test-secret-" + "x" * 40)
engine = create_engine(TEST_DATABASE_URL)
app.state.session_factory = create_session_factory(engine)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as http_client:
yield http_client
await engine.dispose()

View file

View file

@ -0,0 +1,289 @@
"""Login, token rotation and who may do what, through the real HTTP API."""
from datetime import date
import pytest
from app.config import settings
from app.core.database.repository import TrainingRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
from app.core.dto.user import UserRole
from app.core.security import hash_password
PASSWORD = "correct horse"
async def make_user(session, email, role=UserRole.trainee, superuser=False, **fields):
repository = UserRepository(session)
user = await repository.create(
email=email, password_hash=hash_password(PASSWORD), role=role, **fields
)
if superuser:
user = await repository.update(user.id, {"is_superuser": True})
return user
async def make_training(session, trainee, trainer=None, exercise="Жим", weight=50):
return await TrainingRepository(session).save(
TrainingDTO(
date=date(2025, 1, 1),
trainee_id=trainee.id,
trainer_id=trainer.id if trainer else None,
exercises=[
ExerciseDTO(name=exercise, approaches=[ApproachDTO(weight=weight, reps=10)])
],
)
)
async def login(http, email, **extra):
return await http.post(
"/api/v1/auth/login/", json={"email": email, "password": PASSWORD, **extra}
)
async def auth_headers(client, email):
token = (await login(client, email)).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# --- login and tokens ---------------------------------------------------------------------
async def test_login_returns_working_tokens(client, session):
await make_user(session, "a@example.com", first_name="Anna")
response = await login(client, " A@Example.com ")
assert response.status_code == 200
body = response.json()
assert body["token_type"] == "bearer" and body["expires_in"] == 15 * 60
assert body["refresh_token"]
me = await client.get(
"/api/v1/auth/me/", headers={"Authorization": f"Bearer {body['access_token']}"}
)
assert me.json()["email"] == "a@example.com" and "password" not in me.text
assert (await UserRepository(session).list())[0].last_login is not None
async def test_wrong_password_unknown_email_and_inactive_user_are_rejected(client, session):
user = await make_user(session, "a@example.com")
wrong = await client.post(
"/api/v1/auth/login/", json={"email": "a@example.com", "password": "nope"}
)
unknown = await login(client, "nobody@example.com")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json() # nothing tells which of the two was wrong
await UserRepository(session).update(user.id, {"is_active": False})
assert (await login(client, "a@example.com")).status_code == 401
async def test_web_client_gets_the_refresh_token_only_in_an_httponly_cookie(client, session):
await make_user(session, "a@example.com")
response = await login(client, "a@example.com", client="web")
assert response.json()["refresh_token"] is None
cookie = response.headers["set-cookie"].lower()
assert "refresh_token=" in cookie and "httponly" in cookie and "samesite=strict" in cookie
assert "path=/api/v1/auth" in cookie
refreshed = await client.post("/api/v1/auth/refresh/") # the cookie jar sends it
assert refreshed.status_code == 200
assert refreshed.json()["refresh_token"] is None
assert refreshed.headers["set-cookie"] != response.headers["set-cookie"] # rotated
async def test_refresh_token_works_once_and_reuse_revokes_the_whole_chain(client, session):
await make_user(session, "a@example.com")
first = (await login(client, "a@example.com")).json()["refresh_token"]
second = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert second.status_code == 200
newest = second.json()["refresh_token"]
assert newest != first
# The old token again: somebody has a copy. Everything issued from it dies.
replay = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert replay.status_code == 401
after = await client.post("/api/v1/auth/refresh/", json={"refresh_token": newest})
assert after.status_code == 401
async def test_logout_revokes_the_refresh_token(client, session):
await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await client.post("/api/v1/auth/logout/", json={"refresh_token": refresh_token})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_refresh_fails_for_a_deactivated_user(client, session):
user = await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await UserRepository(session).update(user.id, {"is_active": False})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_missing_bad_and_expired_access_tokens_are_401(client, session, monkeypatch):
await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/")).status_code == 401
bad = {"Authorization": headers["Authorization"][:-3] + "abc"}
assert (await client.get("/api/v1/trainings/", headers=bad)).status_code == 401
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 200
monkeypatch.setattr(settings, "ACCESS_TOKEN_TTL_MINUTES", -1)
expired = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/", headers=expired)).status_code == 401
async def test_deactivated_user_loses_access_before_the_token_expires(client, session):
user = await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
await UserRepository(session).update(user.id, {"is_active": False})
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 401
async def test_health_is_open(client):
assert (await client.get("/api/v1/health/")).status_code == 200
# --- who may do what ----------------------------------------------------------------------
async def test_everyone_sees_only_their_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан")
other_coach = await make_user(session, "coach2@example.com", UserRole.trainer)
admin = await make_user(session, "admin@example.com", superuser=True)
annas = await make_training(session, anna, trainer=coach)
boriss = await make_training(session, boris, trainer=other_coach)
async def visible(email):
response = await client.get(
"/api/v1/trainings/", headers=await auth_headers(client, email)
)
return {item["id"] for item in response.json()}
assert await visible("anna@example.com") == {annas}
assert await visible("boris@example.com") == {boriss}
assert await visible("coach@example.com") == {annas}
assert await visible("admin@example.com") == {annas, boriss}
# a training of someone else doesn't exist for the user
headers = await auth_headers(client, "anna@example.com")
assert (await client.get(f"/api/v1/trainings/{boriss}/", headers=headers)).status_code == 404
assert (await client.get(f"/api/v1/trainings/{annas}/", headers=headers)).status_code == 200
def update_body(trainee_id, trainer_id=None):
return {
"date": "2025-02-02",
"trainee_id": trainee_id,
"trainer_id": trainer_id,
"exercises": [{"name": "Тяга", "approaches": [{"weight": 30, "reps": 8}]}],
}
async def test_only_the_trainee_or_a_superuser_can_change_a_training(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer)
await make_user(session, "admin@example.com", superuser=True)
training_id = await make_training(session, anna, trainer=coach)
url = f"/api/v1/trainings/{training_id}/"
coach_headers = await auth_headers(client, "coach@example.com")
boris_headers = await auth_headers(client, "boris@example.com")
anna_headers = await auth_headers(client, "anna@example.com")
assert (await client.put(url, json=update_body(anna.id, coach.id), headers=coach_headers)).status_code == 403
assert (await client.delete(url, headers=coach_headers)).status_code == 403
assert (await client.put(url, json=update_body(anna.id), headers=boris_headers)).status_code == 404
assert (await client.delete(url, headers=boris_headers)).status_code == 404
# the trainee can't hand the training over to somebody else, the superuser can
assert (await client.put(url, json=update_body(boris.id), headers=anna_headers)).status_code == 403
ok = await client.put(url, json=update_body(anna.id), headers=anna_headers)
assert ok.status_code == 200 and ok.json()["trainer"] is None
admin_headers = await auth_headers(client, "admin@example.com")
moved = await client.put(url, json=update_body(boris.id), headers=admin_headers)
assert moved.status_code == 200 and moved.json()["trainee_id"] == boris.id
assert (await client.delete(url, headers=admin_headers)).status_code == 200
async def test_user_management_is_for_superusers_only(client, session):
await make_user(session, "anna@example.com")
admin = await make_user(session, "admin@example.com", superuser=True)
anna_headers = await auth_headers(client, "anna@example.com")
admin_headers = await auth_headers(client, "admin@example.com")
new_user = {"email": "new@example.com", "password": "12345678"}
assert (await client.get("/api/v1/users/", headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user, headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user)).status_code == 401
assert (await client.post("/api/v1/users/", json=new_user, headers=admin_headers)).status_code == 201
# the new user can log in with the password the superuser chose
created = await client.post(
"/api/v1/auth/login/", json={"email": "new@example.com", "password": "12345678"}
)
assert created.status_code == 200
# a superuser can't lock themselves out
assert (await client.delete(f"/api/v1/users/{admin.id}/", headers=admin_headers)).status_code == 400
async def test_any_user_can_list_trainers_but_only_by_name(client, session):
await make_user(session, "anna@example.com")
await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан", last_name="Степан")
response = await client.get(
"/api/v1/users/trainers/", headers=await auth_headers(client, "anna@example.com")
)
assert response.status_code == 200
assert [(t["name"], set(t)) for t in response.json()] == [("Пан Степан", {"id", "name"})]
async def test_exercise_statistics_are_about_the_users_own_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
await make_training(session, anna, exercise="Жим", weight=50)
await make_training(session, boris, exercise="Жим", weight=90)
await make_training(session, boris, exercise="Тяга")
headers = await auth_headers(client, "anna@example.com")
names = await client.get("/api/v1/exercises/", headers=headers)
progress = await client.get("/api/v1/exercises/progress/", params={"name": "Жим"}, headers=headers)
foreign = await client.get("/api/v1/exercises/progress/", params={"name": "Тяга"}, headers=headers)
other_trainee = await client.get(
"/api/v1/exercises/progress/", params={"name": "Жим", "trainee_id": boris.id}, headers=headers
)
assert [n["name"] for n in names.json()] == ["Жим"]
assert progress.json()["max_weight"] == 50 and len(progress.json()["rows"]) == 1
assert foreign.status_code == 404
assert other_trainee.status_code == 404 # Boris' trainings are not visible to Anna
async def test_parser_and_dev_endpoints_need_a_login(client, session):
await make_user(session, "anna@example.com")
headers = await auth_headers(client, "anna@example.com")
body = {"data": "# 07.01.2025 (solo-2)\n", "format": "obsidian"}
assert (await client.post("/api/v1/parser/parse/", json=body)).status_code == 401
assert (await client.post("/api/v1/parser/parse/", json=body, headers=headers)).status_code == 200
assert (await client.get("/api/v1/trainings/obsidian/", headers=headers)).status_code == 403

View file

@ -33,6 +33,7 @@ services:
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro - ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro - ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
- ./frontend/users.html:/usr/share/nginx/html/users.html:ro - ./frontend/users.html:/usr/share/nginx/html/users.html:ro
- ./frontend/login.html:/usr/share/nginx/html/login.html:ro
- ./frontend/css:/usr/share/nginx/html/css:ro - ./frontend/css:/usr/share/nginx/html/css:ro
- ./frontend/js:/usr/share/nginx/html/js:ro - ./frontend/js:/usr/share/nginx/html/js:ro
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro - ./frontend/vendor:/usr/share/nginx/html/vendor:ro

View file

@ -1,7 +1,7 @@
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html exercises.html parse.html users.html /usr/share/nginx/html/ COPY index.html exercises.html parse.html users.html login.html /usr/share/nginx/html/
COPY css /usr/share/nginx/html/css COPY css /usr/share/nginx/html/css
COPY js /usr/share/nginx/html/js COPY js /usr/share/nginx/html/js
COPY vendor /usr/share/nginx/html/vendor COPY vendor /usr/share/nginx/html/vendor

View file

@ -18,8 +18,9 @@
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li> <li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li> <li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li> <li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li> <li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul> </ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside> </aside>
<main class="flex-grow-1 p-4 content" id="content"> <main class="flex-grow-1 p-4 content" id="content">

View file

@ -18,8 +18,9 @@
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li> <li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li> <li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li> <li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li> <li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul> </ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside> </aside>
<main class="flex-grow-1 p-4 content" id="content"> <main class="flex-grow-1 p-4 content" id="content">

101
frontend/js/auth.js Normal file
View file

@ -0,0 +1,101 @@
// Session: the short-lived access token lives in sessionStorage; the refresh token is an
// httpOnly cookie that the browser sends to /auth/* by itself (script code can't read it).
const API = '/api/v1';
const STORAGE_KEY = 'access';
// Refresh a bit before the expiry so that a request doesn't go out with a dying token
const MARGIN_MS = 30_000;
function stored() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY));
} catch {
return null;
}
}
function store(tokens) {
const access = { token: tokens.access_token, expiresAt: Date.now() + tokens.expires_in * 1000 };
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(access));
} catch {
// sessionStorage is unavailable: the token is then only valid for this call chain
}
return access;
}
export function clearSession() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// nothing to clear
}
}
async function post(path, body) {
return fetch(API + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** Signs in; throws an Error with `status` (401: wrong email or password). */
export async function login(email, password) {
const response = await post('/auth/login/', { email, password, client: 'web' });
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
throw error;
}
store(await response.json());
}
// One refresh at a time: the refresh token works once, two parallel requests would
// make the second one look like a stolen token
let refreshing = null;
/** Exchanges the cookie for a new access token. false: the session is over, sign in again. */
export function refresh() {
refreshing ??= (async () => {
try {
const response = await post('/auth/refresh/');
if (!response.ok) {
clearSession();
return false;
}
store(await response.json());
return true;
} finally {
refreshing = null;
}
})();
return refreshing;
}
/** A valid access token, refreshed when needed; null when there is no session. */
export async function accessToken() {
let access = stored();
if (!access || access.expiresAt - Date.now() < MARGIN_MS) {
if (!(await refresh())) {
return null;
}
access = stored();
}
return access?.token ?? null;
}
export async function logout() {
try {
await post('/auth/logout/');
} finally {
clearSession();
}
}
/** Never settles: stops the page code that follows while the browser goes to the login page */
export function redirectToLogin() {
const next = location.pathname + location.search;
location.href = `login.html?next=${encodeURIComponent(next)}`;
return new Promise(() => {});
}

View file

@ -1,12 +1,31 @@
// Shared helpers: API access, DOM building, formatting // Shared helpers: API access, DOM building, formatting
import { accessToken, redirectToLogin, refresh } from './auth.js';
const API = '/api/v1'; const API = '/api/v1';
/** What a training without a trainer is called */ /** What a training without a trainer is called */
export const SOLO = 'соло'; export const SOLO = 'соло';
export async function api(path, options) { async function send(path, options, token) {
const response = await fetch(API + path, options); const headers = { ...options.headers, Authorization: `Bearer ${token}` };
return fetch(API + path, { ...options, headers });
}
/** JSON request to the API with the access token. Without a session the browser is sent to
* the login page; an expired token is refreshed and the request repeated once. */
export async function api(path, options = {}) {
const token = await accessToken();
if (!token) {
return redirectToLogin();
}
let response = await send(path, options, token);
if (response.status === 401) {
if (!(await refresh())) {
return redirectToLogin();
}
response = await send(path, options, await accessToken());
}
if (!response.ok) { if (!response.ok) {
const error = new Error(`HTTP ${response.status}`); const error = new Error(`HTTP ${response.status}`);
error.status = response.status; error.status = response.status;
@ -37,9 +56,10 @@ export function formatDate(iso) {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
/** "First Last" of a user, the email when the name is empty */ /** "First Last" of a user, the email when the name is empty. Trainers in a list come with
* a ready `name`. */
export function userName(user) { export function userName(user) {
return `${user.first_name} ${user.last_name}`.trim() || user.email; return user.name || `${user.first_name} ${user.last_name}`.trim() || user.email;
} }
/** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */ /** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */

View file

@ -3,6 +3,7 @@
import { alert, api, h } from './common.js'; import { alert, api, h } from './common.js';
import { editForm } from './edit-form.js'; import { editForm } from './edit-form.js';
import { openModal } from './modal.js'; import { openModal } from './modal.js';
import { user } from './page.js';
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */ /** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
export function editButton(trainingId) { export function editButton(trainingId) {
@ -18,8 +19,9 @@ export async function openEditDialog(trainingId, onChange) {
const [training, exerciseNames, trainees, trainers] = await Promise.all([ const [training, exerciseNames, trainees, trainers] = await Promise.all([
api(`/trainings/${trainingId}/`), api(`/trainings/${trainingId}/`),
api('/exercises/'), api('/exercises/'),
api('/users/?role=trainee'), // only a superuser may hand a training to another trainee
api('/users/?role=trainer'), user.is_superuser ? api('/users/?role=trainee') : null,
api('/users/trainers/'),
]); ]);
body.replaceChildren(editForm(training, exerciseNames, { trainees, trainers }, { body.replaceChildren(editForm(training, exerciseNames, { trainees, trainers }, {
onDone: () => { hide(); onChange(); }, onDone: () => { hide(); onChange(); },

View file

@ -79,7 +79,7 @@ function userSelect(name, users, selectedId, emptyTitle) {
} }
/** /**
* trainees / trainers: users to choose from. onDone() is called after the training was saved * trainees / trainers: users to choose from (trainees is null when the trainee can't be changed). onDone() is called after the training was saved
* or deleted, onCancel() on "Отмена". * or deleted, onCancel() on "Отмена".
*/ */
export function editForm(training, exerciseNames, { trainees, trainers }, { onDone, onCancel }) { export function editForm(training, exerciseNames, { trainees, trainers }, { onDone, onCancel }) {
@ -101,7 +101,9 @@ export function editForm(training, exerciseNames, { trainees, trainers }, { onDo
h('datalist', { id: 'edit-exercise-names' }, ...exerciseNames.map((item) => h('option', { value: item.name }))), h('datalist', { id: 'edit-exercise-names' }, ...exerciseNames.map((item) => h('option', { value: item.name }))),
h('div', { class: 'row g-3 mb-3' }, h('div', { class: 'row g-3 mb-3' },
field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-12 col-md-3'), field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-12 col-md-3'),
field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3'), trainees
? field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3')
: h('input', { type: 'hidden', name: 'trainee_id', value: training.trainee_id }),
field('Тренер', userSelect('trainer_id', trainers, training.trainer_id, `${SOLO} (без тренера)`), 'col-12 col-md-3')), field('Тренер', userSelect('trainer_id', trainers, training.trainer_id, `${SOLO} (без тренера)`), 'col-12 col-md-3')),
exercisesBox, exercisesBox,
addExercise, addExercise,

View file

@ -1,5 +1,7 @@
import './page.js';
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js'; import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js';
import { editButton } from './edit-dialog.js'; import { editButton } from './edit-dialog.js';
import { canEditTraining } from './page.js';
import { createTable } from './table.js'; import { createTable } from './table.js';
const HINTS = { const HINTS = {
@ -116,7 +118,7 @@ function progressView(progress) {
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` }, { title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` }, { title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
]), ]),
{ title: '', cell: (row) => row.training_id ? editButton(row.training_id) : null }, { title: '', cell: (row) => row.training_id && canEditTraining(row.trainee_id) ? editButton(row.training_id) : null },
]; ];
return [ return [

26
frontend/js/login.js Normal file
View file

@ -0,0 +1,26 @@
import { login } from './auth.js';
import { alert, h } from './common.js';
const form = document.getElementById('login-form');
const status = document.getElementById('status');
/** Where to go after signing in: the page that sent us here, but only a path of this site */
function nextPage() {
const next = new URLSearchParams(location.search).get('next');
return next && next.startsWith('/') && !next.startsWith('//') ? next : 'index.html';
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button');
submit.disabled = true;
try {
await login(form.elements.email.value, form.elements.password.value);
location.href = nextPage();
} catch (error) {
status.replaceChildren(h('div', { class: 'mb-3' }, alert('danger', error.status === 401
? 'Неверный email или пароль.'
: `Не удалось войти: ${error.message}`)));
submit.disabled = false;
}
});

28
frontend/js/page.js Normal file
View file

@ -0,0 +1,28 @@
// Every page imports this first: it requires a signed-in user and fills the sidebar.
// `user` is the current user (the module waits for it, so importers can use it at once).
import { logout, redirectToLogin } from './auth.js';
import { api, h, userName } from './common.js';
const ROLES = { trainer: 'Тренер', trainee: 'Тренирующийся' };
export const user = await api('/auth/me/');
/** A training can be changed by its trainee (a trainer only sees it) and by a superuser */
export function canEditTraining(traineeId) {
return user.is_superuser || user.id === traineeId;
}
for (const element of document.querySelectorAll('[data-superuser-only]')) {
element.hidden = !user.is_superuser;
}
const signOut = h('button', { type: 'button', class: 'btn btn-sm btn-outline-light mt-2' }, 'Выйти');
signOut.addEventListener('click', async () => {
await logout();
await redirectToLogin();
});
document.getElementById('user-box')?.replaceChildren(
h('div', { class: 'text-white text-truncate' }, userName(user)),
h('div', {}, ROLES[user.role] + (user.is_superuser ? ', администратор' : '')),
signOut);

View file

@ -1,4 +1,5 @@
import { alert, api } from './common.js'; import { alert, api } from './common.js';
import './page.js';
import { trainingsView } from './trainings-view.js'; import { trainingsView } from './trainings-view.js';
const form = document.getElementById('parse-form'); const form = document.getElementById('parse-form');

View file

@ -2,6 +2,7 @@
import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js'; import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js';
import { editButton } from './edit-dialog.js'; import { editButton } from './edit-dialog.js';
import { canEditTraining } from './page.js';
import { createTable } from './table.js'; import { createTable } from './table.js';
export function trainingsView(trainings) { export function trainingsView(trainings) {
@ -39,7 +40,7 @@ export function trainingsView(trainings) {
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) }, { title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
...(editable ? [{ ...(editable ? [{
title: '', title: '',
cell: (row) => editButton(row.training.id), cell: (row) => canEditTraining(row.training.trainee_id) ? editButton(row.training.id) : null,
}] : []), }] : []),
], ],
})); }));

View file

@ -1,4 +1,5 @@
import { alert, api } from './common.js'; import { alert, api } from './common.js';
import './page.js';
import { trainingsView } from './trainings-view.js'; import { trainingsView } from './trainings-view.js';
const container = document.getElementById('trainings'); const container = document.getElementById('trainings');

View file

@ -1,4 +1,5 @@
import { alert, api, formatDateTime, h } from './common.js'; import { alert, api, formatDateTime, h } from './common.js';
import { user } from './page.js';
import { createTable } from './table.js'; import { createTable } from './table.js';
import { openUserDialog } from './user-dialog.js'; import { openUserDialog } from './user-dialog.js';
@ -7,6 +8,10 @@ const ROLES = {
trainee: { title: 'Тренирующийся', badge: 'text-bg-secondary' }, trainee: { title: 'Тренирующийся', badge: 'text-bg-secondary' },
}; };
if (!user.is_superuser) {
location.href = 'index.html';
}
const container = document.getElementById('users'); const container = document.getElementById('users');
const reload = () => location.reload(); const reload = () => location.reload();

30
frontend/login.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход - Fitness Parser</title>
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<link href="css/main.css" rel="stylesheet">
</head>
<body class="bg-light">
<main class="container" style="max-width: 24rem; padding-top: 15vh;">
<h1 class="h3 mb-4 text-center">Fitness parser</h1>
<form id="login-form" class="card card-body">
<div class="mb-3">
<label class="form-label small text-muted mb-1" for="email">Email</label>
<input class="form-control" id="email" name="email" type="email" required autofocus autocomplete="username">
</div>
<div class="mb-3">
<label class="form-label small text-muted mb-1" for="password">Пароль</label>
<input class="form-control" id="password" name="password" type="password" required autocomplete="current-password">
</div>
<div id="status"></div>
<button class="btn btn-primary w-100" type="submit">Войти</button>
</form>
</main>
<script type="module" src="js/login.js"></script>
</body>
</html>

View file

@ -18,8 +18,9 @@
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li> <li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li> <li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li> <li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li> <li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul> </ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside> </aside>
<main class="flex-grow-1 p-4 content" id="content"> <main class="flex-grow-1 p-4 content" id="content">

View file

@ -18,8 +18,9 @@
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li> <li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li> <li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li> <li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item"><a class="nav-link text-white active" href="users.html">Пользователи</a></li> <li class="nav-item" data-superuser-only hidden><a class="nav-link text-white active" href="users.html">Пользователи</a></li>
</ul> </ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside> </aside>
<main class="flex-grow-1 p-4 content" id="content"> <main class="flex-grow-1 p-4 content" id="content">