Skip to content

Commit 81b2abf

Browse files
authored
feat: load user data to state (freeCodeCamp-2025-Summer-Hackathon#149)
1 parent 633f34f commit 81b2abf

File tree

9 files changed

+216
-67
lines changed

9 files changed

+216
-67
lines changed

backend/src/api/routes/auth.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Annotated
22

3-
from fastapi import APIRouter, Depends, HTTPException
3+
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
44
from fastapi.security import OAuth2PasswordRequestForm
55

66
from src.auth import (
@@ -10,7 +10,7 @@
1010
refresh_access_token,
1111
)
1212
from src.dependencies import Db
13-
from src.models import Token
13+
from src.models import LoginData, RefreshToken, Token, UserMe
1414

1515
router = APIRouter()
1616

@@ -19,7 +19,8 @@
1919
async def login_for_access_token(
2020
db: Db,
2121
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
22-
) -> Token:
22+
response: Response,
23+
) -> LoginData:
2324
user = await authenticate_user(db, form_data.username, form_data.password)
2425
if not user:
2526
raise HTTPException(
@@ -30,12 +31,21 @@ async def login_for_access_token(
3031
access_token = create_access_token(
3132
data={"sub": str(user.id)},
3233
)
33-
refresh_token = create_refresh_token(data={"sub": str(user.id)})
34-
return Token(
35-
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
34+
(refresh_token, expiration) = create_refresh_token(data={"sub": str(user.id)})
35+
response.set_cookie(
36+
"refresh_token",
37+
refresh_token,
38+
httponly=True,
39+
expires=int(expiration.total_seconds()),
40+
)
41+
return LoginData(
42+
user_data=UserMe(**user.model_dump()),
43+
token=Token(access_token=access_token, token_type="bearer"),
3644
)
3745

3846

39-
@router.post("/refresh")
40-
async def get_new_access_token(db: Db, refresh_token: str):
41-
return await refresh_access_token(db, refresh_token)
47+
@router.get("/refresh")
48+
async def get_new_access_token(
49+
db: Db, refresh_token: Annotated[RefreshToken, Cookie()]
50+
) -> Token:
51+
return Token(**(await refresh_access_token(db, refresh_token.refresh_token)))

backend/src/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import UTC, datetime, timedelta
2-
from typing import Annotated
2+
from typing import Annotated, Literal
33

44
import jwt
55
from fastapi import Depends, HTTPException, status
@@ -28,7 +28,7 @@ def get_password_hash(plain_password):
2828
return password_context.hash(plain_password)
2929

3030

31-
async def authenticate_user(db, username, plain_password):
31+
async def authenticate_user(db, username, plain_password) -> User | Literal[False]:
3232
user = await db.find_one(User, User.username == username)
3333
if not user:
3434
return False
@@ -54,7 +54,7 @@ def create_access_token(
5454
def create_refresh_token(
5555
data: dict, expires_delta: timedelta = REFRESH_TOKEN_EXPIRE_MINUTES
5656
):
57-
return create_access_token(data, expires_delta)
57+
return create_access_token(data, expires_delta), expires_delta
5858

5959

6060
credentials_exception = HTTPException(

backend/src/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from fastapi_csrf_protect.exceptions import CsrfProtectError
77

88
from src.api.main import api_router
9-
from src.auth import User, get_current_active_user
9+
from src.auth import get_current_active_user
1010
from src.config import get_settings
1111
from src.csrf import verify_csrf
12+
from src.models import User, UserMe
1213

1314
app = FastAPI(dependencies=[Depends(verify_csrf)])
1415

@@ -50,7 +51,7 @@ async def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError
5051

5152
@app.get("/me")
5253
async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]):
53-
return current_user
54+
return UserMe(**current_user.model_dump())
5455

5556

5657
@app.get("/")

backend/src/models.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ class User(Model):
1212
downvotes: list[ObjectId] = []
1313

1414

15+
class UserMe(BaseModel):
16+
id: ObjectId
17+
username: str
18+
name: str
19+
is_active: bool
20+
is_admin: bool
21+
upvotes: list[ObjectId]
22+
downvotes: list[ObjectId]
23+
24+
1525
class UserPublic(BaseModel):
1626
id: ObjectId
1727
name: str = Field(max_length=255)
@@ -78,9 +88,17 @@ class Message(BaseModel):
7888

7989
class Token(BaseModel):
8090
access_token: str
81-
refresh_token: str
8291
token_type: str
8392

8493

94+
class RefreshToken(BaseModel):
95+
refresh_token: str
96+
97+
8598
class TokenData(BaseModel):
8699
id: ObjectId | None = None
100+
101+
102+
class LoginData(BaseModel):
103+
user_data: UserMe
104+
token: Token

frontend/src/components/IdeaListItem.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@ import { useState } from 'react';
22
import { Link } from 'react-router';
33

44
import { UpvoteButton } from './VoteButtons';
5+
import { useUser } from '../hooks/useUser';
56

67
export const IdeaListItem = ({ id, name, upvoted_by }) => {
8+
const { userState, dispatch } = useUser();
79
const [idea, setIdea] = useState({
810
id,
911
name,
1012
upvotes: upvoted_by.length,
1113
});
12-
const [isUpvoted, setIsUpvoted] = useState(false);
14+
const [isUpvoted, setIsUpvoted] = useState(userState?.upvotes?.has(id));
1315

1416
const onSuccess = () => {
1517
if (!isUpvoted) {
1618
setIdea(idea => ({
1719
...idea,
1820
upvotes: idea.upvotes + 1,
1921
}));
22+
dispatch({ type: 'upvote', ideaId: id });
2023
setIsUpvoted(true);
2124
}
2225
};

frontend/src/components/VoteButtons.jsx

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,34 +78,42 @@ const Button = ({
7878
);
7979
};
8080

81-
export const DownvoteButton = ({ ideaId, onSuccess, onError }) => (
82-
<Button
83-
{...{
84-
buttonProps: {
85-
className: 'image-only-downvote-button',
86-
},
87-
buttonContents: <>downvote</>,
88-
fetchAddr: `/ideas/${ideaId}/downvote`,
89-
ideaId,
90-
onSuccess,
91-
onError,
92-
}}
93-
/>
94-
);
81+
export const DownvoteButton = ({ ideaId, onSuccess, onError }) => {
82+
const { userState } = useUser();
83+
return (
84+
<Button
85+
{...{
86+
buttonProps: {
87+
className: 'image-only-downvote-button',
88+
...{ ...(userState.downvotes.has(ideaId) && { disabled: true }) },
89+
},
90+
buttonContents: <>downvote</>,
91+
fetchAddr: `/ideas/${ideaId}/downvote`,
92+
ideaId,
93+
onSuccess,
94+
onError,
95+
}}
96+
/>
97+
);
98+
};
9599

96-
export const UpvoteButton = ({ ideaId, onSuccess, onError }) => (
97-
<Button
98-
{...{
99-
buttonProps: {
100-
className: 'image-only-upvote-button',
101-
},
102-
buttonContents: (
103-
<img src={UpvoteImg} alt='Upvote' className='upvote-icon' />
104-
),
105-
fetchAddr: `/ideas/${ideaId}/upvote`,
106-
ideaId,
107-
onSuccess,
108-
onError,
109-
}}
110-
/>
111-
);
100+
export const UpvoteButton = ({ ideaId, onSuccess, onError }) => {
101+
const { userState } = useUser();
102+
return (
103+
<Button
104+
{...{
105+
buttonProps: {
106+
className: 'image-only-upvote-button',
107+
...{ ...(userState.upvotes.has(ideaId) && { disabled: true }) },
108+
},
109+
buttonContents: (
110+
<img src={UpvoteImg} alt='Upvote' className='upvote-icon' />
111+
),
112+
fetchAddr: `/ideas/${ideaId}/upvote`,
113+
ideaId,
114+
onSuccess,
115+
onError,
116+
}}
117+
/>
118+
);
119+
};

frontend/src/hooks/useApi.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const useApi = ({ method = 'GET', loadingInitially = false }) => {
4747
};
4848
}
4949

50-
return options;
50+
return { ...options, credentials: 'include' };
5151
}, []);
5252

5353
// TODO handle responses when access_token is no longer valid -> refreshing it with refresh_token

frontend/src/pages/Ideas/IdeaPage.jsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import { DownvoteButton, UpvoteButton } from '../../components/VoteButtons';
88

99
export const IdeaPage = () => {
1010
const { ideaId } = useParams();
11-
const { isLogged } = useUser();
11+
const { isLogged, userState, upvote, downvote } = useUser();
1212
const [loading, setLoading] = useState(true);
13-
const [isUpvoted, setIsUpvoted] = useState(false);
14-
const [isDownvoted, setIsDownvoted] = useState(false);
13+
const [isUpvoted, setIsUpvoted] = useState(userState.upvotes.has(ideaId));
14+
const [isDownvoted, setIsDownvoted] = useState(
15+
userState.downvotes.has(ideaId)
16+
);
1517
const { error, data, fetchFromApi } = useApi({
1618
loadingInitially: true,
1719
});
@@ -25,6 +27,7 @@ export const IdeaPage = () => {
2527
upvotes: idea.upvotes + 1,
2628
...(isDownvoted && { downvotes: idea.downvotes - 1 }),
2729
}));
30+
upvote(ideaId);
2831
setIsUpvoted(true);
2932
}
3033
};
@@ -40,6 +43,7 @@ export const IdeaPage = () => {
4043
downvotes: idea.downvotes + 1,
4144
...(isUpvoted && { upvotes: idea.upvotes - 1 }),
4245
}));
46+
downvote(ideaId);
4347
setIsDownvoted(true);
4448
}
4549
};

0 commit comments

Comments
 (0)