diff --git a/admin_app_2/package-lock.json b/admin_app_2/package-lock.json index b6d04a0ff..975be8874 100644 --- a/admin_app_2/package-lock.json +++ b/admin_app_2/package-lock.json @@ -24,6 +24,7 @@ "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.1.3", + "prettier-plugin-organize-imports": "^3.2.4", "typescript": "^5" } }, @@ -3837,6 +3838,42 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz", + "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==", + "dev": true, + "peerDependencies": { + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", + "prettier": ">=2.0", + "typescript": ">=2.9" + }, + "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, + "@volar/vue-typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/admin_app_2/package.json b/admin_app_2/package.json index ac5c21d1e..6ebc3b262 100644 --- a/admin_app_2/package.json +++ b/admin_app_2/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.1.3", + "prettier-plugin-organize-imports": "^3.2.4", "typescript": "^5" } } diff --git a/admin_app_2/src/app/content/edit/page.tsx b/admin_app_2/src/app/content/edit/page.tsx index 9c8482393..61cff70db 100644 --- a/admin_app_2/src/app/content/edit/page.tsx +++ b/admin_app_2/src/app/content/edit/page.tsx @@ -1,8 +1,10 @@ "use client"; import LanguageButtonBar from "@/components/LanguageButtonBar"; import { Layout } from "@/components/Layout"; +import { FullAccessComponent } from "@/components/ProtectedComponent"; import { appColors, appStyles, sizes } from "@/utils"; import { apiCalls } from "@/utils/api"; +import { useAuth } from "@/utils/auth"; import { ChevronLeft } from "@mui/icons-material"; import { Button, CircularProgress, TextField, Typography } from "@mui/material"; import Alert from "@mui/material/Alert"; @@ -29,12 +31,13 @@ const AddEditContentPage = () => { const [content, setContent] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); + const { token } = useAuth(); React.useEffect(() => { if (!content_id) { setIsLoading(false); return; } else { - apiCalls.getContent(content_id).then((data) => { + apiCalls.getContent(content_id, token!).then((data) => { setContent(data); setIsLoading(false); }); @@ -58,17 +61,19 @@ const AddEditContentPage = () => { ); } return ( - -
- - - - + + +
+ + + + + - + ); }; @@ -84,6 +89,8 @@ const ContentBox = ({ const [isTitleEmpty, setIsTitleEmpty] = React.useState(false); const [isContentEmpty, setIsContentEmpty] = React.useState(false); + const { token } = useAuth(); + const router = useRouter(); const saveContent = async (content: Content) => { const body: EditContentBody = { @@ -95,8 +102,8 @@ const ContentBox = ({ const promise = content.content_id === null - ? apiCalls.addContent(body) - : apiCalls.editContent(content.content_id, body); + ? apiCalls.addContent(body, token!) + : apiCalls.editContent(content.content_id, body, token!); const result = promise .then((data) => { @@ -162,7 +169,7 @@ const ContentBox = ({ "& .MuiFormHelperText-root": { backgroundColor: appColors.lightGrey, mx: 0, - my: 0, // Set your desired background color here + my: 0, }, }} value={content ? content.content_title : ""} @@ -180,7 +187,7 @@ const ContentBox = ({ "& .MuiFormHelperText-root": { backgroundColor: appColors.lightGrey, mx: 0, - my: 0, // Set your desired background color here + my: 0, }, }} label="Content" @@ -234,11 +241,13 @@ const ContentBox = ({ }; const Header = ({ content_id }: { content_id: number | null }) => { + const router = useRouter(); + return ( (window.location.href = "/content/")} + onClick={() => (content_id ? router.back() : router.push("/content"))} /> {content_id ? ( diff --git a/admin_app_2/src/app/content/layout.tsx b/admin_app_2/src/app/content/layout.tsx new file mode 100644 index 000000000..d71d3473e --- /dev/null +++ b/admin_app_2/src/app/content/layout.tsx @@ -0,0 +1,16 @@ +import NavBar from "@/components/NavBar"; +import { ProtectedComponent } from "@/components/ProtectedComponent"; +import React from "react"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + ); +} diff --git a/admin_app_2/src/app/content/page.tsx b/admin_app_2/src/app/content/page.tsx index 20584074d..487e0d7b5 100644 --- a/admin_app_2/src/app/content/page.tsx +++ b/admin_app_2/src/app/content/page.tsx @@ -1,8 +1,9 @@ "use client"; import ContentCard from "@/components/ContentCard"; import { Layout } from "@/components/Layout"; -import { LANGUAGE_OPTIONS, appColors, sizes } from "@/utils"; +import { LANGUAGE_OPTIONS, sizes } from "@/utils"; import { apiCalls } from "@/utils/api"; +import { useAuth } from "@/utils/auth"; import { Add, ChevronLeft, ChevronRight } from "@mui/icons-material"; import { Box, Button, CircularProgress, Grid, Typography } from "@mui/material"; import Alert from "@mui/material/Alert"; @@ -26,17 +27,19 @@ const CardsView = () => { const [displayLanguage, setDisplayLanguage] = React.useState( LANGUAGE_OPTIONS[0].label, ); + const { accessLevel } = useAuth(); + return ( - + ); }; -const CardsUtilityStrip = () => { +const CardsUtilityStrip = ({ editAccess }: { editAccess: boolean }) => { return ( { }} gap={sizes.baseGap} > - - - + ); }; @@ -70,6 +76,8 @@ const CardsGrid = ({ displayLanguage }: { displayLanguage: string }) => { const action = searchParams.get("action") || null; const content_id = Number(searchParams.get("content_id")) || null; + const { token, accessLevel } = useAuth(); + const getSnackMessage = ( action: string | null, content_id: number | null, @@ -90,23 +98,24 @@ const CardsGrid = ({ displayLanguage }: { displayLanguage: string }) => { const onSuccessfulDelete = (content_id: number) => { setIsLoading(true); setRefreshKey((prevKey) => prevKey + 1); - console.log("hello"); setSnackMessage(`Content #${content_id} deleted successfully`); }; React.useEffect(() => { - apiCalls - .getContentList() - .then((data) => { - setCards(data); - setMaxPages(Math.ceil(data.length / MAX_CARDS_PER_PAGE)); - setIsLoading(false); - }) - .catch((error) => { - console.error("Failed to fetch content:", error); - setIsLoading(false); - }); - }, [refreshKey]); + if (token) { + apiCalls + .getContentList(token!) + .then((data) => { + setCards(data); + setMaxPages(Math.ceil(data.length / MAX_CARDS_PER_PAGE)); + setIsLoading(false); + }) + .catch((error) => { + console.error("Failed to fetch content:", error); + setIsLoading(false); + }); + } + }, [refreshKey, token]); if (isLoading) { return ( @@ -180,6 +189,10 @@ const CardsGrid = ({ displayLanguage }: { displayLanguage: string }) => { onFailedDelete={(content_id: number) => { setSnackMessage(`Failed to delete content #${content_id}`); }} + deleteContent={(content_id: number) => { + return apiCalls.deleteContent(content_id, token!); + }} + editAccess={accessLevel === "fullaccess"} /> ))} diff --git a/admin_app_2/src/app/layout.tsx b/admin_app_2/src/app/layout.tsx index 7a2883b46..317f27dd1 100644 --- a/admin_app_2/src/app/layout.tsx +++ b/admin_app_2/src/app/layout.tsx @@ -1,10 +1,9 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import NavBar from "@/components/NavBar"; import theme from "@/theme"; +import AuthProvider from "@/utils/auth"; import { CssBaseline, ThemeProvider } from "@mui/material"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; import React from "react"; - const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { @@ -25,8 +24,7 @@ export default function RootLayout({ - - {children} + {children} diff --git a/admin_app_2/src/app/login/page.tsx b/admin_app_2/src/app/login/page.tsx new file mode 100644 index 000000000..82d9cecb7 --- /dev/null +++ b/admin_app_2/src/app/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; +import { useAuth } from "@/utils/auth"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import Alert from "@mui/material/Alert"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CssBaseline from "@mui/material/CssBaseline"; +import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import * as React from "react"; + +const Login = () => { + const [isUsernameEmpty, setIsUsernameEmpty] = React.useState(false); + const [isPasswordEmpty, setIsPasswordEmpty] = React.useState(false); + const { login, loginError } = useAuth(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const username = data.get("username") as string; + const password = data.get("password") as string; + + if ( + username === "" || + password === "" || + username === null || + password === null + ) { + username === "" || username === null ? setIsUsernameEmpty(true) : null; + password === "" || password === null ? setIsPasswordEmpty(true) : null; + } else { + login(username, password); + } + }; + + return ( + + + + + + + + + + Sign in + + + + {" "} + {/* Reserve space for the alert */} + {loginError && {loginError}} + + { + setIsUsernameEmpty(false); + }} + /> + { + setIsPasswordEmpty(false); + }} + /> + + + + + + ); +}; + +export default Login; diff --git a/admin_app_2/src/app/page.tsx b/admin_app_2/src/app/page.tsx index 0811d8758..24e0ecb69 100644 --- a/admin_app_2/src/app/page.tsx +++ b/admin_app_2/src/app/page.tsx @@ -1,22 +1,14 @@ "use client"; -import { Button } from "@mui/material"; import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function Home() { const router = useRouter(); - const handleLogin = () => { + + useEffect(() => { router.push("/content"); - }; + }, []); - return ( -
-

Login page

- -
- ); + return
; } diff --git a/admin_app_2/src/components/ContentCard.tsx b/admin_app_2/src/components/ContentCard.tsx index d879e5bc6..e1d583b69 100644 --- a/admin_app_2/src/components/ContentCard.tsx +++ b/admin_app_2/src/components/ContentCard.tsx @@ -1,11 +1,10 @@ -import { appColors, appStyles, sizes } from "@/utils"; -import { Edit, Delete } from "@mui/icons-material"; import { ContentViewModal, DeleteContentModal, } from "@/components/ContentModal"; -import { apiCalls } from "@/utils/api"; -import { Button, IconButton, Card, Typography } from "@mui/material"; +import { appColors, appStyles, sizes } from "@/utils"; +import { Delete, Edit } from "@mui/icons-material"; +import { Button, Card, IconButton, Typography } from "@mui/material"; import Link from "next/link"; import React from "react"; import { Layout } from "./Layout"; @@ -17,6 +16,8 @@ const ContentCard = ({ last_modified, onSuccessfulDelete, onFailedDelete, + deleteContent, + editAccess, }: { title: string; text: string; @@ -24,6 +25,8 @@ const ContentCard = ({ last_modified: string; onSuccessfulDelete: (content_id: number) => void; onFailedDelete: (content_id: number) => void; + deleteContent: (content_id: number) => Promise; + editAccess: boolean; }) => { const [openReadModal, setOpenReadModal] = React.useState(false); const [openDeleteModal, setOpenDeleteModal] = React.useState(false); @@ -71,14 +74,17 @@ const ContentCard = ({ Read - - - +
setOpenDeleteModal(true)} > @@ -93,6 +99,7 @@ const ContentCard = ({ last_modified={last_modified} open={openReadModal} onClose={() => setOpenReadModal(false)} + editAccess={editAccess} /> setOpenDeleteModal(false)} onSuccessfulDelete={onSuccessfulDelete} onFailedDelete={onFailedDelete} + deleteContent={deleteContent} /> ); diff --git a/admin_app_2/src/components/ContentModal.tsx b/admin_app_2/src/components/ContentModal.tsx index 2449da082..88249472f 100644 --- a/admin_app_2/src/components/ContentModal.tsx +++ b/admin_app_2/src/components/ContentModal.tsx @@ -1,23 +1,20 @@ import { appColors, appStyles, sizes } from "@/utils"; import { Close, - Delete, Edit, RemoveRedEye, ThumbDown, ThumbUp, } from "@mui/icons-material"; import { Box, Button, Fade, Modal, Typography } from "@mui/material"; -import Link from "next/link"; -import { apiCalls } from "../utils/api"; -import LanguageButtonBar from "./LanguageButtonBar"; -import { Layout } from "./Layout"; -import React from "react"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import Link from "next/link"; +import LanguageButtonBar from "./LanguageButtonBar"; +import { Layout } from "./Layout"; const ContentViewModal = ({ title, @@ -26,6 +23,7 @@ const ContentViewModal = ({ last_modified, open, onClose, + editAccess, }: { title: string; text: string; @@ -33,6 +31,7 @@ const ContentViewModal = ({ last_modified: string; open: boolean; onClose: () => void; + editAccess: boolean; }) => { return ( - - - +
void; onSuccessfulDelete: (content_id: number) => void; onFailedDelete: (content_id: number) => void; + deleteContent: (content_id: number) => Promise; }) => { return ( { const handleDeleteContent = async (content_id: number) => { - const results = apiCalls - .deleteContent(content_id) + const results = deleteContent(content_id) .then((res) => { onSuccessfulDelete(content_id); }) diff --git a/admin_app_2/src/components/NavBar.tsx b/admin_app_2/src/components/NavBar.tsx index 075ee6fc2..18b8ffe74 100644 --- a/admin_app_2/src/components/NavBar.tsx +++ b/admin_app_2/src/components/NavBar.tsx @@ -1,5 +1,6 @@ "use client"; import { appColors, appStyles, sizes } from "@/utils"; +import { useAuth } from "@/utils/auth"; import MenuIcon from "@mui/icons-material/Menu"; import { Box } from "@mui/material"; import AppBar from "@mui/material/AppBar"; @@ -14,7 +15,6 @@ import { usePathname, useRouter } from "next/navigation"; import * as React from "react"; import logowhite from "../../../docs/images/logo-light.png"; import { Layout } from "./Layout"; - const pages = [ { title: "Playground", path: "/playground" }, { title: "Manage Content", path: "/content" }, @@ -166,6 +166,7 @@ const LargeScreenNavMenu = () => { }; const UserDropdown = () => { + const { logout } = useAuth(); const router = useRouter(); const [anchorElUser, setAnchorElUser] = React.useState( null, @@ -205,7 +206,7 @@ const UserDropdown = () => { onClose={() => setAnchorElUser(null)} > {settings.map((setting) => ( - + {setting} ))} diff --git a/admin_app_2/src/components/ProtectedComponent.tsx b/admin_app_2/src/components/ProtectedComponent.tsx new file mode 100644 index 000000000..52563e637 --- /dev/null +++ b/admin_app_2/src/components/ProtectedComponent.tsx @@ -0,0 +1,48 @@ +"use client"; +import { useAuth } from "@/utils/auth"; +import { useRouter, usePathname } from "next/navigation"; +import React, { useEffect } from "react"; + +interface ProtectedComponentProps { + children: React.ReactNode; +} + +const ProtectedComponent: React.FC = ({ + children, +}) => { + const router = useRouter(); + const { token } = useAuth(); + const pathname = usePathname(); + + useEffect(() => { + if (!token) { + router.push("/login?sourcePage=" + encodeURIComponent(pathname)); + } + }, [token]); + + return <>{children}; +}; + +const FullAccessComponent: React.FC = ({ + children, +}) => { + const router = useRouter(); + const { token, accessLevel } = useAuth(); + + if (token && accessLevel == "fullaccess") { + return <>{children}; + } else { + return + Not Authorised + ; + } +}; + +export { FullAccessComponent, ProtectedComponent }; diff --git a/admin_app_2/src/utils/api.ts b/admin_app_2/src/utils/api.ts index 2925b2b2b..62c21fbd4 100644 --- a/admin_app_2/src/utils/api.ts +++ b/admin_app_2/src/utils/api.ts @@ -1,7 +1,5 @@ -// Temp read bearer token from file. Will be removed when auth is implemented. -const json = require("../../temp_secrets.json"); -const ACCESS_TOKEN = json.ACCESS_TOKEN; -const BACKEND_ROOT_PATH = "http://localhost:8000"; +const BACKEND_ROOT_PATH: string = + process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"; interface ContentBody { content_title: string; @@ -10,12 +8,12 @@ interface ContentBody { content_metadata: Record; } -const getContentList = async () => { +const getContentList = async (token: string) => { return fetch(`${BACKEND_ROOT_PATH}/content/list`, { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, }).then((response) => { if (response.ok) { @@ -27,12 +25,12 @@ const getContentList = async () => { }); }; -const getContent = async (content_id: number) => { +const getContent = async (content_id: number, token: string) => { return fetch(`${BACKEND_ROOT_PATH}/content/${content_id}`, { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, }).then((response) => { if (response.ok) { @@ -44,12 +42,12 @@ const getContent = async (content_id: number) => { }); }; -const deleteContent = async (content_id: number) => { +const deleteContent = async (content_id: number, token: string) => { return fetch(`${BACKEND_ROOT_PATH}/content/${content_id}/delete`, { method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, }).then((response) => { if (response.ok) { @@ -61,12 +59,12 @@ const deleteContent = async (content_id: number) => { }); }; -const createContent = async (content: number) => { +const createContent = async (content: number, token: string) => { return fetch(`${BACKEND_ROOT_PATH}/content/create`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify(content), }).then((response) => { @@ -79,12 +77,16 @@ const createContent = async (content: number) => { }); }; -const editContent = async (content_id: number, content: ContentBody) => { +const editContent = async ( + content_id: number, + content: ContentBody, + token: string, +) => { return fetch(`${BACKEND_ROOT_PATH}/content/${content_id}/edit`, { method: "PUT", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify(content), }).then((response) => { @@ -97,12 +99,12 @@ const editContent = async (content_id: number, content: ContentBody) => { }); }; -const addContent = async (content: ContentBody) => { +const addContent = async (content: ContentBody, token: string) => { return fetch(`${BACKEND_ROOT_PATH}/content/create`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${ACCESS_TOKEN}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify(content), }).then((response) => { @@ -115,6 +117,23 @@ const addContent = async (content: ContentBody) => { }); }; +const getLoginToken = async (username: string, password: string) => { + const formData = new FormData(); + formData.append("username", username); + formData.append("password", password); + return fetch(`${BACKEND_ROOT_PATH}/login`, { + method: "POST", + body: formData, + }).then((response) => { + if (response.ok) { + let resp = response.json(); + return resp; + } else { + throw new Error("Error fetching login token"); + } + }); +}; + export const apiCalls = { getContentList, getContent, @@ -122,4 +141,5 @@ export const apiCalls = { createContent, editContent, addContent, + getLoginToken, }; diff --git a/admin_app_2/src/utils/auth.tsx b/admin_app_2/src/utils/auth.tsx new file mode 100644 index 000000000..e08370010 --- /dev/null +++ b/admin_app_2/src/utils/auth.tsx @@ -0,0 +1,99 @@ +"use client"; +import { apiCalls } from "@/utils/api"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ReactNode, createContext, useContext, useState } from "react"; + +type AuthContextType = { + token: string | null; + user: string | null; + accessLevel: "readonly" | "fullaccess"; + loginError: string | null; + login: (username: string, password: string) => void; + logout: () => void; +}; + +const AuthContext = createContext(undefined); + +type AuthProviderProps = { + children: ReactNode; +}; + +const AuthProvider = ({ children }: AuthProviderProps) => { + const [user, setUser] = useState(null); + + const getInitialToken = () => { + if (typeof window !== "undefined") { + return localStorage.getItem("token"); + } + return null; + }; + const [token, setToken] = useState(getInitialToken); + + const [loginError, setLoginError] = useState(null); + + const getInitialAccessLevel = () => { + if (typeof window !== "undefined") { + return localStorage.getItem("accessLevel") as "readonly" | "fullaccess"; + } + return "readonly"; + }; + const [accessLevel, setAccessLevel] = useState<"readonly" | "fullaccess">( + getInitialAccessLevel, + ); + + const searchParams = useSearchParams(); + const router = useRouter(); + + const login = async (username: string, password: string) => { + const sourcePage = searchParams.has("sourcePage") + ? decodeURIComponent(searchParams.get("sourcePage") as string) + : "/"; + + apiCalls + .getLoginToken(username, password) + .then(({ access_token, access_level }) => { + localStorage.setItem("token", access_token); + localStorage.setItem("accessLevel", access_level); + setUser(username); + setToken(access_token); + setAccessLevel(access_level); + router.push(sourcePage); + }) + .catch((error) => { + setLoginError("Invalid username or password"); + console.error("Login error:", error); + }); + }; + + const logout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("accessLevel"); + setUser(null); + setToken(null); + setAccessLevel("readonly"); + router.push("/login"); + }; + + const authValue: AuthContextType = { + token: token, + user: user, + accessLevel: accessLevel, + loginError: loginError, + login: login, + logout: logout, + }; + + return ( + {children} + ); +}; + +export default AuthProvider; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/core_backend/app/contents/models.py b/core_backend/app/contents/models.py index 101dac256..b4b089876 100644 --- a/core_backend/app/contents/models.py +++ b/core_backend/app/contents/models.py @@ -134,7 +134,7 @@ async def get_list_of_content_from_db( """ Retrieves all content from the database """ - stmt = select(ContentDB) + stmt = select(ContentDB).order_by(ContentDB.content_id) if offset > 0: stmt = stmt.offset(offset) if limit is not None: