diff --git a/.gitignore b/.gitignore index bf9a613..620c125 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,28 @@ /backups + +# Python +/__pycache__/* +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/backend/alembic/README b/backend/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/backend/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/README.md b/backend/alembic/README.md new file mode 100644 index 0000000..8498672 --- /dev/null +++ b/backend/alembic/README.md @@ -0,0 +1,25 @@ +# Alembic + +Alembic allows for our database migrations to be tracked in a version control system. + +To create a new migration run: + +```bash +alembic revision --autogenerate -m 'Describe change here' +``` + +It's best practice to review the script post revision creation: `alembic/versions` + +To apply the migration: + +```bash +alembic upgrade head +``` + +Now you can re-check using `alembic check` + +If we need to rollback use: + +```bash +alembic downgrade -i +``` diff --git a/backend/alembic/versions/__pycache__/a3ac646e53a8_init.cpython-313.pyc b/backend/alembic/versions/__pycache__/a3ac646e53a8_init.cpython-313.pyc deleted file mode 100644 index cebecc6..0000000 Binary files a/backend/alembic/versions/__pycache__/a3ac646e53a8_init.cpython-313.pyc and /dev/null differ diff --git a/backend/alembic/versions/b9dcd098debd_1_1_0.py b/backend/alembic/versions/b9dcd098debd_1_1_0.py new file mode 100644 index 0000000..e42395e --- /dev/null +++ b/backend/alembic/versions/b9dcd098debd_1_1_0.py @@ -0,0 +1,58 @@ +"""1.1.0 + +Revision ID: b9dcd098debd +Revises: a3ac646e53a8 +Create Date: 2025-06-14 09:22:14.878105 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b9dcd098debd' +down_revision: Union[str, None] = 'a3ac646e53a8' +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.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_items_id'), table_name='items') + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_table('items') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('items', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('description', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('body', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('items_pkey')) + ) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False) + op.create_table('users', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('username', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('permissions', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=False), + sa.Column('subscriber', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')) + ) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + # ### end Alembic commands ### diff --git a/backend/app/__pycache__/crud.cpython-313.pyc b/backend/app/__pycache__/crud.cpython-313.pyc deleted file mode 100644 index 79c89c3..0000000 Binary files a/backend/app/__pycache__/crud.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc deleted file mode 100644 index 58007e1..0000000 Binary files a/backend/app/__pycache__/database.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 50919a1..0000000 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 41f01e8..0000000 Binary files a/backend/app/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/schemas.cpython-313.pyc b/backend/app/__pycache__/schemas.cpython-313.pyc deleted file mode 100644 index 6127c00..0000000 Binary files a/backend/app/__pycache__/schemas.cpython-313.pyc and /dev/null differ diff --git a/backend/app/crud.py b/backend/app/crud.py index 5f19cf7..45b8ba8 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -31,10 +31,10 @@ def delete_item(db: Session, item_id: int): def authenticate_user(db: Session, username: str, password: str): - user = get_user_by_username(db, username) + user = get_user_by_username(db, username) or get_user_by_email(db, username) if not user: return None - if not verify_password(password, str(user.hashed_password)): + if not verify_password(password, user.hashed_password): return None return user @@ -50,7 +50,11 @@ def get_user_by_email(db: Session, email: str): def create_user(db: Session, user: schemas.UserCreate): hashed_pw = hash_password(user.password) db_user = models.User( - username=user.username, email=user.email, hashed_password=hashed_pw + username=user.username, + email=user.email, + hashed_password=hashed_pw, + permissions=user.permissions, + subscriber=user.subscriber, ) db.add(db_user) db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 687bda0..a50819a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session @@ -10,6 +11,14 @@ Base.metadata.create_all(bind=engine) app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "https://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Dependency def get_db(): @@ -81,4 +90,5 @@ def register_user(user: schemas.UserCreate, db: Session = Depends(get_db)): status_code=status.HTTP_400_BAD_REQUEST, detail="Account with that email already registered", ) + # Default Cases return crud.create_user(db, user) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6d46a78..1c0eaa1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -24,13 +24,15 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str + permissions: dict = {} + subscriber: bool = False class UserOut(UserBase): id: int class Config: - orm_mode = True + from_attributes = True # Other Schemas diff --git a/backend/app/utils.py b/backend/app/utils.py index 07249e0..a107133 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,4 +1,3 @@ -import logging import os from typing import Any, Mapping from passlib.context import CryptContext @@ -6,7 +5,8 @@ from datetime import UTC, datetime, timedelta from jose import JWTError, jwt from app.logger_config import Logger -pwd_context = CryptContext(schemas=["bcrypt"], deprecated="auto") +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +_logger = Logger().logger def hash_password(password: str) -> str: @@ -37,5 +37,5 @@ def decode_access_token(token: str) -> Mapping[Any, Any] | None: try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError: - logging.exception(msg="Failed to Decode JWT", extra={"TOKEN": token}) + _logger.exception(msg="Failed to Decode JWT", extra={"TOKEN": token}) return None diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5654703..362f2fa 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "fastapi[standard]>=0.115.12", "passlib[bcrypt]>=1.7.4", "psycopg2-binary>=2.9.10", - "pyrefly>=0.18.1", "python-jose[cryptography]>=3.5.0", "sqlalchemy>=2.0.41", "uvicorn[standard]>=0.34.2", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60c40ce..54ec7b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "fs": "^0.0.1-security", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.57.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.6.0" }, @@ -4392,6 +4393,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.57.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz", + "integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a0f28e4..ac62763 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "fs": "^0.0.1-security", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.57.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.6.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df1b5cc..34b6ab1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,78 +3,191 @@ import { Routes, Route, Link, - useParams, + useNavigate, } from "react-router-dom"; -import { useState, useEffect } from "react"; -import ReactMarkdown from "react-markdown"; +import { useState, useContext, useEffect, createContext, useRef } from "react"; +import { useForm } from "react-hook-form"; import { BlogViewer } from "./utils/BlogViewer"; import { BlogList } from "./utils/BlogList"; import { RequireAdmin } from "./utils/RouteGuard"; import { AdminPage } from "./utils/AdminPage"; import Unauthorized from "./utils/UnauthorizedPage"; +// Use Vite environment variable for API base URL +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +// Auth Context +interface AuthContextProps { + isAuthenticated: boolean; + login: (token: string) => void; + logout: () => void; +} +const AuthContext = createContext({ + isAuthenticated: false, + login: () => {}, + logout: () => {}, +}); +export const useAuth = () => useContext(AuthContext); + +const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const token = localStorage.getItem("token"); + setIsAuthenticated(!!token); + }, []); + + const login = (token: string) => { + localStorage.setItem("token", token); + setIsAuthenticated(true); + }; + + const logout = () => { + localStorage.removeItem("token"); + setIsAuthenticated(false); + navigate("/signin"); + }; + + return ( + + {children} + + ); +}; + function App() { const [darkMode, setDarkMode] = useState(true); return ( -
- -
- setDarkMode(!darkMode)} /> -
- - } /> - } /> - } /> - } /> - } /> - - - - } - /> - } /> - + + +
+
+ setDarkMode(!darkMode)} /> +
+ + } /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + } /> + } /> + } /> + +
-
-
+ + ); } function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) { + const { isAuthenticated, logout } = useAuth(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuOpen && + menuRef.current && + !menuRef.current.contains(event.target as Node) + ) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [menuOpen]); + return ( -