diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17d475a7..c5bf2907 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import EditRobotForm from "pages/EditRobotForm"; import Home from "pages/Home"; import Login from "pages/Login"; import Logout from "pages/Logout"; +import MyParts from "pages/MyParts"; +import MyRobots from "pages/MyRobots"; import NewPart from "pages/NewPart"; import NewRobot from "pages/NewRobot"; import NotFound from "pages/NotFound"; @@ -19,8 +21,6 @@ import Parts from "pages/Parts"; import RobotDetails from "pages/RobotDetails"; import Robots from "pages/Robots"; import TestImages from "pages/TestImages"; -import YourParts from "pages/YourParts"; -import YourRobots from "pages/YourRobots"; import { Container } from "react-bootstrap"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import "./App.css"; @@ -44,13 +44,13 @@ const App = () => { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/nav/TopNavbar.tsx b/frontend/src/components/nav/TopNavbar.tsx index 41b99033..2538a6a7 100644 --- a/frontend/src/components/nav/TopNavbar.tsx +++ b/frontend/src/components/nav/TopNavbar.tsx @@ -17,7 +17,7 @@ const TopNavbar = () => { useEffect(() => { (async () => { try { - // get code from query string to carry out oauth login + // Get the code from the query string to carry out OAuth login. const search = window.location.search; const params = new URLSearchParams(search); const code = params.get("code"); @@ -25,8 +25,8 @@ const TopNavbar = () => { const { email } = await auth_api.me(); auth.setEmail(email); } else if (code) { - const res = await auth_api.login_github(code as string); - setLocalStorageAuth(res.username); + const res = await auth_api.loginGithub(code as string); + setLocalStorageAuth(res.api_key_id); auth.setIsAuthenticated(true); } } catch (error) { diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx index d63f5358..21a5f75c 100644 --- a/frontend/src/hooks/api.tsx +++ b/frontend/src/hooks/api.tsx @@ -37,13 +37,21 @@ export interface Robot { packages: Package[]; } +interface GithubAuthResponse { + api_key_id: string; +} + interface MeResponse { - id: string; + user_id: string; email: string; username: string; admin: boolean; } +interface UploadImageResponse { + image_id: string; +} + export class api { public api: AxiosInstance; @@ -51,7 +59,7 @@ export class api { this.api = api; } - public async send_register_github(): Promise { + public async sendRegisterGithub(): Promise { try { const res = await this.api.get("/users/github/login"); return res.data; @@ -68,9 +76,11 @@ export class api { } } - public async login_github(code: string): Promise { + public async loginGithub(code: string): Promise { try { - const res = await this.api.get(`/users/github/code/${code}`); + const res = await this.api.get( + `/users/github/code/${code}`, + ); return res.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -87,7 +97,7 @@ export class api { public async logout(): Promise { try { - await this.api.delete("/users/logout/"); + await this.api.delete("/users/logout/"); } catch (error) { if (axios.isAxiosError(error)) { console.error("Error logging out:", error.response?.data); @@ -101,7 +111,7 @@ export class api { public async me(): Promise { try { - const res = await this.api.get("/users/me/"); + const res = await this.api.get("/users/me/"); return res.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -156,9 +166,9 @@ export class api { return map; } - public async getYourRobots(page: number): Promise<[Robot[], boolean]> { + public async getMyRobots(page: number): Promise<[Robot[], boolean]> { try { - const response = await this.api.get("/robots/your/", { + const response = await this.api.get("/robots/me/", { params: { page }, }); return response.data; @@ -174,6 +184,7 @@ export class api { } } } + public async getRobotById(robotId: string | undefined): Promise { try { const response = await this.api.get(`/robots/${robotId}`); @@ -188,6 +199,7 @@ export class api { } } } + public async getPartById(partId: string | undefined): Promise { try { const response = await this.api.get(`/parts/${partId}`); @@ -202,6 +214,7 @@ export class api { } } } + public async currentUser(): Promise { try { const response = await this.api.get("/users/me"); @@ -218,6 +231,7 @@ export class api { } } } + public async addRobot(robot: Robot): Promise { const s = robot.name; try { @@ -234,6 +248,7 @@ export class api { } } } + public async deleteRobot(id: string | undefined): Promise { const s = id; try { @@ -250,10 +265,11 @@ export class api { } } } + public async editRobot(robot: Robot): Promise { const s = robot.name; try { - await this.api.post(`robots/edit-robot/${robot.id}/`, robot); + await this.api.post(`/robots/edit/${robot.id}/`, robot); } catch (error) { if (axios.isAxiosError(error)) { console.error("Error editing robot:", error.response?.data); @@ -301,9 +317,10 @@ export class api { } } } - public async getYourParts(page: number): Promise<[Part[], boolean]> { + + public async getMyParts(page: number): Promise<[Part[], boolean]> { try { - const response = await this.api.get("/parts/your/", { params: { page } }); + const response = await this.api.get("/parts/me/", { params: { page } }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -315,6 +332,7 @@ export class api { } } } + public async addPart(part: Part): Promise { const s = part.name; try { @@ -331,10 +349,11 @@ export class api { } } } + public async deletePart(id: string | undefined): Promise { const s = id; try { - await this.api.delete(`parts/delete/${id}/`); + await this.api.delete(`/parts/delete/${id}/`); } catch (error) { if (axios.isAxiosError(error)) { console.error("Error deleting part:", error.response?.data); @@ -347,10 +366,11 @@ export class api { } } } + public async editPart(part: Part): Promise { const s = part.name; try { - await this.api.post(`parts/edit-part/${part.id}/`, part); + await this.api.post(`/parts/edit/${part.id}/`, part); } catch (error) { if (axios.isAxiosError(error)) { console.error("Error editing part:", error.response?.data); @@ -363,32 +383,19 @@ export class api { } } } - public async getImage(imageId: string): Promise { - try { - const response = await this.api.get(`/image/${imageId}/`, { - responseType: "blob", - }); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - console.error("Error fetching image:", error.response?.data); - throw new Error( - error.response?.data?.detail || "Error fetching image " + imageId, - ); - } else { - console.error("Unexpected error:", error); - throw new Error("Unexpected error"); - } - } - } + public async uploadImage(formData: FormData): Promise { try { - const res = await this.api.post("/image/upload/", formData, { - headers: { - "Content-Type": "multipart/form-data", + const res = await this.api.post( + "/image/upload/", + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, }, - }); - return res.data.id; + ); + return res.data.image_id; } catch (error) { if (axios.isAxiosError(error)) { console.error("Error uploading image:", error.response?.data); diff --git a/frontend/src/pages/EditPartForm.tsx b/frontend/src/pages/EditPartForm.tsx index 30f8aab2..b050456d 100644 --- a/frontend/src/pages/EditPartForm.tsx +++ b/frontend/src/pages/EditPartForm.tsx @@ -56,7 +56,7 @@ const EditPartForm: React.FC = () => { try { await auth_api.editPart(newFormData); setMessage(`Part edited successfully.`); - navigate(`/parts/your/1`); + navigate(`/parts/me/1`); } catch (error) { setMessage("Error adding part "); } diff --git a/frontend/src/pages/EditRobotForm.tsx b/frontend/src/pages/EditRobotForm.tsx index 339d1205..066ba5ee 100644 --- a/frontend/src/pages/EditRobotForm.tsx +++ b/frontend/src/pages/EditRobotForm.tsx @@ -76,7 +76,7 @@ const EditRobotForm: React.FC = () => { try { await auth_api.editRobot(newFormData); setMessage(`Robot edited successfully.`); - navigate(`/robots/your/1`); + navigate(`/robots/me/1`); } catch (error) { setMessage("Error adding robot "); } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 45a27214..c1d1f289 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -45,10 +45,10 @@ const Home: React.FC = () => { width: "100%", }} onClick={() => { - navigate("/robots/your/1"); + navigate("/robots/me/1"); }} > - View Your Robots + View My Robots @@ -59,10 +59,10 @@ const Home: React.FC = () => { width: "100%", }} onClick={() => { - navigate("/parts/your/1"); + navigate("/parts/me/1"); }} > - View Your Parts + View My Parts diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 343bc149..90a4e973 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -16,7 +16,7 @@ const Login = () => { const handleGithubSubmit = async (event: FormEvent) => { event.preventDefault(); try { - const redirectUrl = await auth_api.send_register_github(); + const redirectUrl = await auth_api.sendRegisterGithub(); window.location.href = redirectUrl; } catch (err) { if (err instanceof Error) { diff --git a/frontend/src/pages/YourParts.tsx b/frontend/src/pages/MyParts.tsx similarity index 91% rename from frontend/src/pages/YourParts.tsx rename to frontend/src/pages/MyParts.tsx index a7d606d6..b03fe57d 100644 --- a/frontend/src/pages/YourParts.tsx +++ b/frontend/src/pages/MyParts.tsx @@ -14,7 +14,7 @@ import { import Markdown from "react-markdown"; import { Link, useNavigate, useParams } from "react-router-dom"; -const YourParts = () => { +const MyParts = () => { const auth = useAuthentication(); const auth_api = new api(auth.api); const [partsData, setParts] = useState(null); @@ -35,7 +35,7 @@ const YourParts = () => { useEffect(() => { const fetch_parts = async () => { try { - const partsQuery = await auth_api.getYourParts(pageNumber); + const partsQuery = await auth_api.getMyParts(pageNumber); setParts(partsQuery[0]); setMoreParts(partsQuery[1]); } catch (err) { @@ -70,7 +70,7 @@ const YourParts = () => { <> navigate("/")}>Home - Your Parts + My Parts @@ -120,12 +120,12 @@ const YourParts = () => { {pageNumber > 1 && ( - Previous Page + Previous Page )} {moreParts && ( - Next Page + Next Page )} @@ -134,4 +134,4 @@ const YourParts = () => { ); }; -export default YourParts; +export default MyParts; diff --git a/frontend/src/pages/YourRobots.tsx b/frontend/src/pages/MyRobots.tsx similarity index 90% rename from frontend/src/pages/YourRobots.tsx rename to frontend/src/pages/MyRobots.tsx index fa64c7ab..1b3f0e51 100644 --- a/frontend/src/pages/YourRobots.tsx +++ b/frontend/src/pages/MyRobots.tsx @@ -14,7 +14,7 @@ import { import Markdown from "react-markdown"; import { Link, useNavigate, useParams } from "react-router-dom"; -const YourRobots = () => { +const MyRobots = () => { const auth = useAuthentication(); const auth_api = new api(auth.api); const [robotsData, setRobot] = useState([]); @@ -34,9 +34,9 @@ const YourRobots = () => { } useEffect(() => { - const fetch_your_robots = async () => { + const fetchMyRobots = async () => { try { - const robotsQuery = await auth_api.getYourRobots(pageNumber); + const robotsQuery = await auth_api.getMyRobots(pageNumber); setRobot(robotsQuery[0]); setMoreRobots(robotsQuery[1]); } catch (err) { @@ -47,7 +47,7 @@ const YourRobots = () => { } } }; - fetch_your_robots(); + fetchMyRobots(); }, [pageNumber]); const navigate = useNavigate(); @@ -70,7 +70,7 @@ const YourRobots = () => { <> navigate("/")}>Home - Your Robots + My Robots @@ -120,12 +120,12 @@ const YourRobots = () => { {pageNumber > 1 && ( - Previous Page + Previous Page )} {moreRobots && ( - Next Page + Next Page )} @@ -134,4 +134,4 @@ const YourRobots = () => { ); }; -export default YourRobots; +export default MyRobots; diff --git a/frontend/src/pages/NewPart.tsx b/frontend/src/pages/NewPart.tsx index ff6e5806..59ad0de7 100644 --- a/frontend/src/pages/NewPart.tsx +++ b/frontend/src/pages/NewPart.tsx @@ -31,7 +31,7 @@ const NewPart: React.FC = () => { try { await auth_api.addPart(newFormData); setMessage(`Part added successfully.`); - navigate("/parts/your/1"); + navigate("/parts/me/1"); } catch (error) { setMessage("Error adding Part "); } diff --git a/frontend/src/pages/NewRobot.tsx b/frontend/src/pages/NewRobot.tsx index 208bc763..71bad6a9 100644 --- a/frontend/src/pages/NewRobot.tsx +++ b/frontend/src/pages/NewRobot.tsx @@ -44,7 +44,7 @@ const NewRobot: React.FC = () => { try { await auth_api.addRobot(newFormData); setMessage(`Robot added successfully.`); - navigate(`/robots/your/1`); + navigate(`/robots/me/1`); } catch (error) { setMessage("Error adding robot "); } diff --git a/frontend/src/pages/PartDetails.tsx b/frontend/src/pages/PartDetails.tsx index 1223ae6f..1be41861 100644 --- a/frontend/src/pages/PartDetails.tsx +++ b/frontend/src/pages/PartDetails.tsx @@ -168,7 +168,7 @@ const PartDetails = () => { width: "100%", }} onClick={() => { - navigate(`/edit-part/${id}/`); + navigate(`/part/edit/${id}/`); }} > Edit Part @@ -210,7 +210,7 @@ const PartDetails = () => { variant="danger" onClick={async () => { await auth_api.deletePart(id); - navigate(`/parts/your/1`); + navigate(`/parts/me/1`); }} > Delete Part diff --git a/frontend/src/pages/RobotDetails.tsx b/frontend/src/pages/RobotDetails.tsx index 1b9114f1..c856c552 100644 --- a/frontend/src/pages/RobotDetails.tsx +++ b/frontend/src/pages/RobotDetails.tsx @@ -277,7 +277,7 @@ const RobotDetails = () => { width: "100%", }} onClick={() => { - navigate(`/edit-robot/${id}/`); + navigate(`/robot/edit/${id}/`); }} > Edit Robot @@ -319,7 +319,7 @@ const RobotDetails = () => { variant="danger" onClick={async () => { await auth_api.deleteRobot(id); - navigate(`/robots/your/1`); + navigate(`/robots/me/1`); }} > Delete Robot diff --git a/store/app/crud/base.py b/store/app/crud/base.py index ac3b1da6..fb99227c 100644 --- a/store/app/crud/base.py +++ b/store/app/crud/base.py @@ -68,20 +68,25 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # if self.__s3 is not None: await self.__s3.__aexit__(exc_type, exc_val, exc_tb) - async def _add_item(self, item: RobolistBaseModel) -> None: + async def _add_item(self, item: RobolistBaseModel, unique_fields: list[str] | None = None) -> None: table = await self.db.Table(TABLE_NAME) item_data = item.model_dump() if "type" in item_data: raise ValueError("Cannot add item with 'type' attribute") item_data["type"] = item.__class__.__name__ - await table.put_item(Item=item_data) + condition = "attribute_not_exists(id)" + if unique_fields: + for field in unique_fields: + assert hasattr(item, field), f"Item does not have field {field}" + condition += " AND " + " AND ".join(f"attribute_not_exists({field})" for field in unique_fields) + await table.put_item( + Item=item_data, + ConditionExpression=condition, + ) async def _delete_item(self, item: RobolistBaseModel | str) -> None: table = await self.db.Table(TABLE_NAME) - if isinstance(item, str): - await table.delete_item(Key={"id": item}) - else: - await table.delete_item(Key={"id": item.id}) + await table.delete_item(Key={"id": item if isinstance(item, str) else item.id}) async def _list_items( self, @@ -131,7 +136,7 @@ async def _list( sorted_items = sorted(response, key=sort_key, reverse=True) return sorted_items[(page - 1) * ITEMS_PER_PAGE : page * ITEMS_PER_PAGE], page * ITEMS_PER_PAGE < len(response) - async def _list_your( + async def _list_me( self, item_class: type[T], user_id: str, @@ -181,7 +186,7 @@ async def _get_item(self, item_id: str, item_class: type[T], throw_if_missing: b item_dict = await table.get_item(Key={"id": item_id}) if "Item" not in item_dict: if throw_if_missing: - raise ValueError(f"Item {item_id} not found") + raise ValueError("Item not found") return None item_data = item_dict["Item"] return self._validate_item(item_data, item_class) diff --git a/store/app/crud/robots.py b/store/app/crud/robots.py index dc1e00b0..90d2cb52 100644 --- a/store/app/crud/robots.py +++ b/store/app/crud/robots.py @@ -36,14 +36,14 @@ async def dump_parts(self) -> list[Part]: async def list_robots(self, page: int, search_query: str | None = None) -> tuple[list[Robot], bool]: return await self._list(Robot, page, lambda x: x.timestamp, search_query) - async def list_your_robots(self, user_id: str, page: int, search_query: str) -> tuple[list[Robot], bool]: - return await self._list_your(Robot, user_id, page, lambda x: x.timestamp, search_query) + async def list_user_robots(self, user_id: str, page: int, search_query: str) -> tuple[list[Robot], bool]: + return await self._list_me(Robot, user_id, page, lambda x: x.timestamp, search_query) async def list_parts(self, page: int, search_query: str | None = None) -> tuple[list[Part], bool]: return await self._list(Part, page, lambda x: x.timestamp, search_query) - async def list_your_parts(self, user_id: str, page: int, search_query: str) -> tuple[list[Part], bool]: - return await self._list_your(Part, user_id, page, lambda x: x.timestamp, search_query) + async def list_user_parts(self, user_id: str, page: int, search_query: str) -> tuple[list[Part], bool]: + return await self._list_me(Part, user_id, page, lambda x: x.timestamp, search_query) async def upload_image(self, file: UploadFile) -> None: bucket = await self.s3.Bucket(settings.s3.bucket) diff --git a/store/app/crud/users.py b/store/app/crud/users.py index ec5165ad..e8412992 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -31,50 +31,71 @@ def __init__(self) -> None: def get_gsis(cls) -> list[GlobalSecondaryIndex]: return super().get_gsis() + [ ("emailIndex", "email", "S", "HASH"), + ("tokenIndex", "token", "S", "HASH"), ] @overload - async def get_user(self, id: str) -> User | None: ... + async def get_user(self, id: str, throw_if_missing: Literal[True]) -> User: ... @overload - async def get_user(self, id: str, throw_if_missing: Literal[True]) -> User: ... + async def get_user(self, id: str, throw_if_missing: bool = False) -> User | None: ... async def get_user(self, id: str, throw_if_missing: bool = False) -> User | None: return await self._get_item(id, User, throw_if_missing=throw_if_missing) - async def create_user_from_token(self, token: str, email: str) -> User: + async def _create_user_from_email(self, email: str) -> User: user = User.create(email=email) - await self._add_item(user) - key = OAuthKey.create(token, user.id) - await self._add_item(key) + await self._add_item(user, unique_fields=["email"]) return user - async def get_user_from_token(self, token: str) -> User | None: - key = await self._get_item(token, OAuthKey, throw_if_missing=False) - if key is None: - return None - return await self.get_user(key.user_id) - - async def create_user_from_github_token(self, github_id: str, email: str) -> User: - return await self.create_user_from_token(github_auth_key(github_id), email) - - async def create_user_from_google_token(self, google_id: str, email: str) -> User: - return await self.create_user_from_token(google_auth_key(google_id), email) + async def _create_user_from_auth_key(self, auth_key: str, email: str) -> User: + user = await self._create_user_from_email(email) + key = OAuthKey.create(auth_key, user.id) + await self._add_item(key, unique_fields=["user_token"]) + return user - async def get_user_from_github_token(self, token: str) -> User | None: - return await self.get_user_from_token(github_auth_key(token)) + @overload + async def _get_oauth_key(self, token: str, throw_if_missing: Literal[True]) -> OAuthKey: ... - async def get_user_from_google_token(self, token: str) -> User | None: - return await self.get_user_from_token(google_auth_key(token)) + @overload + async def _get_oauth_key(self, token: str, throw_if_missing: bool = False) -> OAuthKey | None: ... + + async def _get_oauth_key(self, token: str, throw_if_missing: bool = False) -> OAuthKey | None: + return await self._get_unique_item_from_secondary_index( + "tokenIndex", + "token", + token, + OAuthKey, + throw_if_missing=throw_if_missing, + ) + + async def _get_user_from_auth_key(self, token: str) -> User | None: + key = await self._get_oauth_key(token) + return None if key is None else await self.get_user(key.user_id) + + async def get_user_from_github_token(self, token: str, email: str) -> User: + auth_key = github_auth_key(token) + user = await self._get_user_from_auth_key(auth_key) + if user is not None: + return user + return await self._create_user_from_auth_key(auth_key, email) + + async def delete_github_token(self, github_id: str) -> None: + await self._delete_item(await self._get_oauth_key(github_auth_key(github_id), throw_if_missing=True)) + + async def get_user_from_google_token(self, token: str, email: str) -> User | None: + auth_key = google_auth_key(token) + user = await self._get_user_from_auth_key(auth_key) + if user is not None: + return user + return await self._create_user_from_auth_key(auth_key, email) + + async def delete_google_token(self, google_id: str) -> None: + await self._delete_item(await self._get_oauth_key(google_auth_key(google_id), throw_if_missing=True)) async def get_user_from_email(self, email: str) -> User | None: return await self._get_unique_item_from_secondary_index("emailIndex", "email", email, User) - async def create_user_from_email(self, email: str) -> User: - user = User.create(email=email) - await self._add_item(user) - return user - async def get_user_batch(self, ids: list[str]) -> list[User]: return await self._get_item_batch(ids, User) @@ -102,9 +123,9 @@ async def add_api_key( source: APIKeySource, permissions: APIKeyPermissionSet, ) -> APIKey: - token = APIKey.create(user_id=user_id, source=source, permissions=permissions) - await self._add_item(token) - return token + api_key = APIKey.create(user_id=user_id, source=source, permissions=permissions) + await self._add_item(api_key) + return api_key async def delete_api_key(self, token: APIKey | str) -> None: await self._delete_item(token) @@ -112,7 +133,7 @@ async def delete_api_key(self, token: APIKey | str) -> None: async def test_adhoc() -> None: async with UserCrud() as crud: - await crud.create_user_from_email(email="ben@kscale.dev") + await crud._create_user_from_email(email="ben@kscale.dev") if __name__ == "__main__": diff --git a/store/app/model.py b/store/app/model.py index f051a6a5..918d63bc 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -23,8 +23,7 @@ class RobolistBaseModel(BaseModel): id: str -class UserPermissions(BaseModel): - is_admin: bool = False +UserPermission = Literal["is_admin"] class User(RobolistBaseModel): @@ -36,26 +35,27 @@ class User(RobolistBaseModel): """ email: str - permissions: UserPermissions = UserPermissions() + permissions: set[UserPermission] | None = None @classmethod def create(cls, email: str) -> Self: - return cls(id=str(new_uuid()), email=email) + return cls(id=str(new_uuid()), email=email, permissions=None) class OAuthKey(RobolistBaseModel): """Keys for OAuth providers which identify users.""" user_id: str + user_token: str @classmethod - def create(cls, token: str, user_id: str) -> Self: - return cls(id=token, user_id=user_id) + def create(cls, user_token: str, user_id: str) -> Self: + return cls(id=str(new_uuid()), user_id=user_id, user_token=user_token) APIKeySource = Literal["user", "oauth"] APIKeyPermission = Literal["read", "write", "admin"] -APIKeyPermissionSet = set[APIKeyPermission] | Literal["full"] +APIKeyPermissionSet = set[APIKeyPermission] | Literal["full", None] class APIKey(RobolistBaseModel): @@ -68,7 +68,7 @@ class APIKey(RobolistBaseModel): user_id: str source: APIKeySource - permissions: set[APIKeyPermission] + permissions: set[APIKeyPermission] | None = None @classmethod def create( diff --git a/store/app/routers/auth/github.py b/store/app/routers/auth/github.py index b27191e4..6711b729 100644 --- a/store/app/routers/auth/github.py +++ b/store/app/routers/auth/github.py @@ -8,7 +8,6 @@ from pydantic.main import BaseModel from store.app.db import Crud -from store.app.model import UserPermissions from store.settings import settings logger = logging.getLogger(__name__) @@ -45,17 +44,16 @@ async def github_email_req(headers: dict[str, str]) -> HttpxResponse: return await client.get("https://api.github.com/user/emails", headers=headers) -class UserInfoResponse(BaseModel): - id: str - permissions: UserPermissions +class GithubAuthResponse(BaseModel): + api_key_id: str -@github_auth_router.get("/code/{code}", response_model=UserInfoResponse) +@github_auth_router.get("/code/{code}", response_model=GithubAuthResponse) async def github_code( code: str, crud: Annotated[Crud, Depends(Crud.get)], response: Response, -) -> UserInfoResponse: +) -> GithubAuthResponse: """Gives the user a session token upon successful github authentication and creation of user. Args: @@ -84,14 +82,7 @@ async def github_code( github_id = oauth_response.json()["html_url"] email = next(entry["email"] for entry in oauth_email_response.json() if entry["primary"]) - user = await crud.get_user_from_github_token(github_id) - - # We create a new user if the user does not exist yet. - if user is None: - user = await crud.create_user_from_github_token( - email=email, - github_id=github_id, - ) + user = await crud.get_user_from_github_token(github_id, email) api_key = await crud.add_api_key( user_id=user.id, @@ -101,4 +92,4 @@ async def github_code( response.set_cookie(key="session_token", value=api_key.id, httponly=True, samesite="lax") - return UserInfoResponse(id=user.id, permissions=user.permissions) + return GithubAuthResponse(api_key_id=api_key.id) diff --git a/store/app/routers/image.py b/store/app/routers/image.py index 6575349d..33bf4317 100644 --- a/store/app/routers/image.py +++ b/store/app/routers/image.py @@ -5,8 +5,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, UploadFile -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.responses import RedirectResponse from PIL import Image +from pydantic.main import BaseModel from store.app.db import Crud from store.settings import settings @@ -17,8 +18,12 @@ logger = logging.getLogger(__name__) +class UserInfoResponse(BaseModel): + image_id: str + + @image_router.post("/upload/") -async def upload_image(crud: Annotated[Crud, Depends(Crud.get)], file: UploadFile) -> JSONResponse: +async def upload_image(crud: Annotated[Crud, Depends(Crud.get)], file: UploadFile) -> UserInfoResponse: try: if file.content_type != "image/png": raise HTTPException(status_code=400, detail="Only PNG images are supported") @@ -36,7 +41,7 @@ async def upload_image(crud: Annotated[Crud, Depends(Crud.get)], file: UploadFil upload = UploadFile(filename="mini" + image_id + ".png", file=compressed_image_io) await crud.upload_image(upload) - return JSONResponse(status_code=200, content={"id": image_id}) + return UserInfoResponse(image_id=image_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/store/app/routers/part.py b/store/app/routers/part.py index d2022c37..b463736c 100644 --- a/store/app/routers/part.py +++ b/store/app/routers/part.py @@ -34,14 +34,14 @@ async def dump_parts(crud: Annotated[Crud, Depends(Crud.get)]) -> list[Part]: return await crud.dump_parts() -@parts_router.get("/your/") -async def list_your_parts( +@parts_router.get("/me/") +async def list_my_parts( crud: Annotated[Crud, Depends(Crud.get)], user: Annotated[User, Depends(get_session_user_with_read_permission)], page: int = Query(description="Page number for pagination"), search_query: str = Query(None, description="Search query string"), ) -> tuple[list[Part], bool]: - return await crud.list_your_parts(user.id, page, search_query=search_query) + return await crud.list_user_parts(user.id, page, search_query=search_query) @parts_router.get("/{part_id}") @@ -97,7 +97,7 @@ async def delete_part( # TODO: Improve part type annotations. -@parts_router.post("/edit-part/{part_id}/") +@parts_router.post("/edit/{part_id}/") async def edit_part( part_id: str, part: dict[str, Any], diff --git a/store/app/routers/robot.py b/store/app/routers/robot.py index 089905f7..054f6296 100644 --- a/store/app/routers/robot.py +++ b/store/app/routers/robot.py @@ -46,14 +46,14 @@ async def list_robots( return await crud.list_robots(page, search_query=search_query) -@robots_router.get("/your/") -async def list_your_robots( +@robots_router.get("/me/") +async def list_my_robots( crud: Annotated[Crud, Depends(Crud.get)], user: Annotated[User, Depends(get_session_user_with_read_permission)], page: int = Query(description="Page number for pagination"), search_query: str = Query(None, description="Search query string"), ) -> tuple[list[Robot], bool]: - return await crud.list_your_robots(user.id, page, search_query=search_query) + return await crud.list_user_robots(user.id, page, search_query=search_query) @robots_router.post("/add/") @@ -96,7 +96,7 @@ async def delete_robot( return True -@robots_router.post("/edit-robot/{id}/") +@robots_router.post("/edit/{id}/") async def edit_robot( id: str, robot: dict[str, Any], diff --git a/store/app/routers/users.py b/store/app/routers/users.py index 2528018d..c214ea6d 100644 --- a/store/app/routers/users.py +++ b/store/app/routers/users.py @@ -9,7 +9,7 @@ from pydantic.main import BaseModel as PydanticBaseModel from store.app.db import Crud -from store.app.model import User, UserPermissions +from store.app.model import User, UserPermission from store.app.routers.auth.github import github_auth_router from store.app.utils.email import send_delete_email @@ -64,7 +64,7 @@ async def get_session_user_with_read_permission( api_key_id: Annotated[str, Depends(get_request_api_key_id)], ) -> User: api_key = await crud.get_api_key(api_key_id) - if "read" not in api_key.permissions: + if api_key.permissions is None or "read" not in api_key.permissions: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") return await crud.get_user(api_key.user_id, throw_if_missing=True) @@ -74,7 +74,7 @@ async def get_session_user_with_write_permission( api_key_id: Annotated[str, Depends(get_request_api_key_id)], ) -> User: api_key = await crud.get_api_key(api_key_id) - if "write" not in api_key.permissions: + if api_key.permissions is None or "write" not in api_key.permissions: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") return await crud.get_user(api_key.user_id, throw_if_missing=True) @@ -84,7 +84,7 @@ async def get_session_user_with_admin_permission( api_key_id: Annotated[str, Depends(get_request_api_key_id)], ) -> User: api_key = await crud.get_api_key(api_key_id) - if "admin" not in api_key.permissions: + if api_key.permissions is None or "admin" not in api_key.permissions: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") return await crud.get_user(api_key.user_id, throw_if_missing=True) @@ -106,18 +106,17 @@ class UserRegister(BaseModel): class UserInfoResponse(BaseModel): - id: str - permissions: UserPermissions + user_id: str + permissions: set[UserPermission] | None @users_router.get("/me", response_model=UserInfoResponse) async def get_user_info_endpoint( user: Annotated[User, Depends(get_session_user_with_read_permission)], - crud: Annotated[Crud, Depends(Crud.get)], ) -> UserInfoResponse | None: try: return UserInfoResponse( - id=user.id, + user_id=user.id, permissions=user.permissions, ) except ValueError: diff --git a/tests/test_users.py b/tests/test_users.py index 311c9d6f..2c3d1578 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -22,21 +22,21 @@ async def test_user_auth_functions(app_client: AsyncClient) -> None: assert response.status_code == 200, response.json() assert "session_token" in response.cookies token = response.cookies["session_token"] - - user_id = response.json()["id"] + assert token == response.json()["api_key_id"] # Checks that with the session token we get a 200 response. response = await app_client.get("/users/me") assert response.status_code == 200, response.json() - # Check the id of the user we are authenticated as matches the id of the user we created. - assert response.json()["id"] == user_id + user_id = response.json()["user_id"] # Use the Authorization header instead of the cookie. response = await app_client.get( - "/users/me", cookies={"session_token": ""}, headers={"Authorization": f"Bearer {token}"} + "/users/me", + cookies={"session_token": ""}, + headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200, response.json() - assert response.json()["id"] == user_id + assert response.json()["user_id"] == user_id # Log the user out, which deletes the session token. response = await app_client.delete("/users/logout") @@ -61,4 +61,4 @@ async def test_user_auth_functions(app_client: AsyncClient) -> None: # Tries deleting the user again, which should fail. response = await app_client.delete("/users/me") assert response.status_code == 400, response.json() - assert response.json()["detail"] == "Item " + user_id + " not found" + assert response.json()["detail"] == "Item not found"