Compare commits

..

2 Commits

Author SHA1 Message Date
muszyn 2729ba49f2 feat: front end for login 2025-06-14 10:02:02 -04:00
muszyn df5b247cdd feat: auth backend implementation 2025-06-14 08:00:38 -04:00
18 changed files with 531 additions and 68 deletions
+27
View File
@@ -1 +1,28 @@
/backups /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?
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
+25
View File
@@ -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
```
@@ -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 ###
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -3
View File
@@ -31,10 +31,10 @@ def delete_item(db: Session, item_id: int):
def authenticate_user(db: Session, username: str, password: str): 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: if not user:
return None return None
if not verify_password(password, str(user.hashed_password)): if not verify_password(password, user.hashed_password):
return None return None
return user return user
@@ -50,7 +50,11 @@ def get_user_by_email(db: Session, email: str):
def create_user(db: Session, user: schemas.UserCreate): def create_user(db: Session, user: schemas.UserCreate):
hashed_pw = hash_password(user.password) hashed_pw = hash_password(user.password)
db_user = models.User( 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.add(db_user)
db.commit() db.commit()
+10
View File
@@ -1,4 +1,5 @@
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,6 +11,14 @@ Base.metadata.create_all(bind=engine)
app = FastAPI() app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Dependency # Dependency
def get_db(): 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, status_code=status.HTTP_400_BAD_REQUEST,
detail="Account with that email already registered", detail="Account with that email already registered",
) )
# Default Cases
return crud.create_user(db, user) return crud.create_user(db, user)
+3 -1
View File
@@ -24,13 +24,15 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
permissions: dict = {}
subscriber: bool = False
class UserOut(UserBase): class UserOut(UserBase):
id: int id: int
class Config: class Config:
orm_mode = True from_attributes = True
# Other Schemas # Other Schemas
+3 -3
View File
@@ -1,4 +1,3 @@
import logging
import os import os
from typing import Any, Mapping from typing import Any, Mapping
from passlib.context import CryptContext from passlib.context import CryptContext
@@ -6,7 +5,8 @@ from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt from jose import JWTError, jwt
from app.logger_config import Logger 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: def hash_password(password: str) -> str:
@@ -37,5 +37,5 @@ def decode_access_token(token: str) -> Mapping[Any, Any] | None:
try: try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError: except JWTError:
logging.exception(msg="Failed to Decode JWT", extra={"TOKEN": token}) _logger.exception(msg="Failed to Decode JWT", extra={"TOKEN": token})
return None return None
-1
View File
@@ -10,7 +10,6 @@ dependencies = [
"fastapi[standard]>=0.115.12", "fastapi[standard]>=0.115.12",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pyrefly>=0.18.1",
"python-jose[cryptography]>=3.5.0", "python-jose[cryptography]>=3.5.0",
"sqlalchemy>=2.0.41", "sqlalchemy>=2.0.41",
"uvicorn[standard]>=0.34.2", "uvicorn[standard]>=0.34.2",
+17
View File
@@ -12,6 +12,7 @@
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0" "react-router-dom": "^7.6.0"
}, },
@@ -4392,6 +4393,22 @@
"react": "^19.1.0" "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": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0" "react-router-dom": "^7.6.0"
}, },
+354 -33
View File
@@ -3,22 +3,68 @@ import {
Routes, Routes,
Route, Route,
Link, Link,
useParams, useNavigate,
} from "react-router-dom"; } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useContext, useEffect, createContext, useRef } from "react";
import ReactMarkdown from "react-markdown"; import { useForm } from "react-hook-form";
import { BlogViewer } from "./utils/BlogViewer"; import { BlogViewer } from "./utils/BlogViewer";
import { BlogList } from "./utils/BlogList"; import { BlogList } from "./utils/BlogList";
import { RequireAdmin } from "./utils/RouteGuard"; import { RequireAdmin } from "./utils/RouteGuard";
import { AdminPage } from "./utils/AdminPage"; import { AdminPage } from "./utils/AdminPage";
import Unauthorized from "./utils/UnauthorizedPage"; 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<AuthContextProps>({
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 (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
function App() { function App() {
const [darkMode, setDarkMode] = useState(true); const [darkMode, setDarkMode] = useState(true);
return ( return (
<div className={darkMode ? "dark" : ""}>
<Router> <Router>
<AuthProvider>
<div className={darkMode ? "dark" : ""}>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"> <div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<AppBar toggleDarkMode={() => setDarkMode(!darkMode)} /> <AppBar toggleDarkMode={() => setDarkMode(!darkMode)} />
<div className="p-4"> <div className="p-4">
@@ -37,44 +83,111 @@ function App() {
} }
/> />
<Route path="/unauthorized" element={<Unauthorized />} /> <Route path="/unauthorized" element={<Unauthorized />} />
<Route path="/register" element={<Register />} />
<Route path="/signin" element={<SignIn />} />
<Route path="/profile" element={<Profile />} />
</Routes> </Routes>
</div> </div>
</div> </div>
</Router>
</div> </div>
</AuthProvider>
</Router>
); );
} }
function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) { function AppBar({ toggleDarkMode }: { toggleDarkMode: () => void }) {
const { isAuthenticated, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(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 ( return (
<nav className="bg-white dark:bg-gray-800 shadow p-4 flex space-x-4 items-center"> <nav className="bg-white dark:bg-gray-800 shadow p-4 flex items-center relative">
<Link to="/" className="font-bold text-blue-600 dark:text-blue-400"> <Link to="/" className="font-bold text-blue-600 dark:text-blue-400">
Home Home
</Link> </Link>
<Link <Link
to="/blog" to="/blog"
className="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400" className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
> >
Blog Blog
</Link> </Link>
<Link <Link
to="/about" to="/about"
className="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400" className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
> >
About Me About Me
</Link> </Link>
<Link <Link
to="/contact" to="/contact"
className="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400" className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
> >
Contact Contact
</Link> </Link>
<div className="ml-auto flex items-center space-x-4">
{!isAuthenticated ? (
<>
<Link
to="/register"
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Register
</Link>
<Link
to="/signin"
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Sign In
</Link>
</>
) : (
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center"
>
<span className="text-gray-700 dark:text-gray-200">U</span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-700 shadow rounded">
<Link
to="/profile"
className="block px-4 py-2 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
>
Profile
</Link>
<button
onClick={logout}
className="w-full text-left block px-4 py-2 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
>
Sign Out
</button>
</div>
)}
</div>
)}
<button <button
onClick={toggleDarkMode} onClick={toggleDarkMode}
className="ml-auto text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded" className="text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded"
> >
Toggle Dark Mode Toggle Dark Mode
</button> </button>
</div>
</nav> </nav>
); );
} }
@@ -90,41 +203,249 @@ function LandingPage() {
); );
} }
export const BlogPost = () => { // Profile placeholder
const { slug } = useParams<{ slug: string }>(); function Profile() {
const [content, setContent] = useState<string>("");
useEffect(() => {
if (!slug) return;
fetch(`../public/blogs/${slug}.md`)
.then((res) => res.text())
.then((text) => setContent(text));
}, [slug]);
if (!content) return <div>Loading...</div>;
return ( return (
<article className="prose dark:prose-invert max-w-none p-4"> <div className="text-center py-10">
<ReactMarkdown>{content}</ReactMarkdown> <h2 className="text-2xl font-bold mb-4">Profile</h2>
</article> <p>Profile page coming soon.</p>
</div>
); );
}; }
function About() { function About() {
return ( return (
<div> <div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">About Me</h2> <h2 className="text-2xl font-bold mb-4">About Me</h2>
<p>This is a placeholder for information about me.</p> <p>About page content coming soon.</p>
</div> </div>
); );
} }
function Contact() { function Contact() {
return ( return (
<div> <div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">Contact</h2> <h2 className="text-2xl font-bold mb-4">Contact</h2>
<p>This is a placeholder for contact information or a form.</p> <p>Contact page content coming soon.</p>
</div>
);
}
// Registration form with API integration
interface RegisterForm {
username: string;
email: string;
password: string;
confirmPassword: string;
}
function Register() {
const navigate = useNavigate();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterForm>();
const onSubmit = async (data: RegisterForm) => {
try {
const response = await fetch(`${API_URL}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: data.username,
email: data.email,
password: data.password,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Registration failed");
}
navigate("/signin");
} catch (err: any) {
alert(err.message);
}
};
return (
<div className="max-w-md mx-auto py-10">
<h2 className="text-2xl font-bold mb-6 text-center">Register</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
{...register("username", { required: true })}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.username && (
<span className="text-red-600 text-sm">Username is required</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register("email", {
required: true,
pattern: /^\S+@\S+\.\S+$/,
})}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.email && (
<span className="text-red-600 text-sm">
Valid email is required
</span>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register("password", {
required: true,
pattern: /(?=.*[A-Z])(?=.*\d).+/,
})}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.password && (
<span className="text-red-600 text-sm">
Password must include uppercase and number
</span>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium mb-1"
>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword", {
validate: (value) =>
value === watch("password") || "Passwords do not match",
})}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.confirmPassword && (
<span className="text-red-600 text-sm">
{errors.confirmPassword.message}
</span>
)}
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Register
</button>
</form>
</div>
);
}
// Sign-in form with API integration
interface SignInForm {
identifier: string;
password: string;
}
function SignIn() {
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignInForm>();
const { login } = useAuth();
const onSubmit = async (data: SignInForm) => {
try {
const body = new URLSearchParams();
body.append("username", data.identifier);
body.append("password", data.password);
const response = await fetch(`${API_URL}/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Login failed");
}
const tokenData = await response.json();
login(tokenData.access_token);
navigate("/");
} catch (err: any) {
alert(err.message);
}
};
return (
<div className="max-w-md mx-auto py-10">
<h2 className="text-2xl font-bold mb-6 text-center">Sign In</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="identifier"
className="block text-sm font-medium mb-1"
>
Username or Email
</label>
<input
id="identifier"
{...register("identifier", { required: true })}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.identifier && (
<span className="text-red-600 text-sm">This field is required</span>
)}
</div>
<div>
<label
htmlFor="signinPassword"
className="block text-sm font-medium mb-1"
>
Password
</label>
<input
id="signinPassword"
type="password"
{...register("password", { required: true })}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
/>
{errors.password && (
<span className="text-red-600 text-sm">Password is required</span>
)}
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Sign In
</button>
</form>
</div> </div>
); );
} }