diff --git a/static/js/src/advantage/credentials/dashboard/api/queryFns.ts b/static/js/src/advantage/credentials/dashboard/api/queryFns.ts index 5044183811d..c8fda0fbfef 100644 --- a/static/js/src/advantage/credentials/dashboard/api/queryFns.ts +++ b/static/js/src/advantage/credentials/dashboard/api/queryFns.ts @@ -173,3 +173,24 @@ export async function issueCredlyBadge(badgeData: any) { throw new Error(error as string); } } + +export async function getUserPermissions() { + try { + const URL = `/credentials/api/user-permissions`; + const response = await fetch(URL, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error); + } + return data; + } catch (error) { + throw new Error(error as string); + } +} diff --git a/static/js/src/advantage/credentials/dashboard/api/queryKeys.ts b/static/js/src/advantage/credentials/dashboard/api/queryKeys.ts index 65c55bdbe76..6ea3cfc85de 100644 --- a/static/js/src/advantage/credentials/dashboard/api/queryKeys.ts +++ b/static/js/src/advantage/credentials/dashboard/api/queryKeys.ts @@ -1,2 +1,3 @@ export const getBulkBadgesCredly = () => ["credlyIssuedBadgesBulk"]; export const postIssueCredlyBadge = () => ["credlyIssueBadge"]; +export const getUserPermissionsKey = () => ["userPermissions"]; diff --git a/static/js/src/advantage/credentials/dashboard/app.tsx b/static/js/src/advantage/credentials/dashboard/app.tsx index deeb8a78552..f30a0ba350f 100644 --- a/static/js/src/advantage/credentials/dashboard/app.tsx +++ b/static/js/src/advantage/credentials/dashboard/app.tsx @@ -1,37 +1,10 @@ import { createRoot } from "react-dom/client"; import * as Sentry from "@sentry/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import Exams from "./routes/Exams"; -import Credly from "./routes/Credly"; -import Keys from "./routes/Keys"; - -import UpcomingExams from "./components/UpcomingExams/UpcomingExams"; -import ExamResults from "./components/ExamResults/ExamResults"; -import KeysList from "./components/KeysList/KeysList"; -import TestTakers from "./components/TestTakers/TestTakers"; -import CertificationIssued from "./components/CertificationsIssued/CertificationIssued"; -import BadgeTracking from "./components/BadgeTracking/BadgeTracking"; +import QueryClient from "./components/QueryClient/QueryClient"; +import Routes from "./components/Routes/Routes"; import Sidebar from "./components/Sidebar/Sidebar"; -import { - BrowserRouter as Router, - Routes, - Route, - Navigate, -} from "react-router-dom"; - -const oneHour = 1000 * 60 * 60; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: oneHour, - retryOnMount: false, - }, - }, -}); +import { BrowserRouter as Router } from "react-router-dom"; Sentry.init({ dsn: "https://0293bb7fc3104e56bafd2422e155790c@sentry.is.canonical.com//13", @@ -43,37 +16,17 @@ function App() { return ( - +
- - } /> - }> - } /> - } /> - - }> - } /> - - }> - } - /> - } - /> - - } /> - +
-
+
); diff --git a/static/js/src/advantage/credentials/dashboard/components/QueryClient/QueryClient.tsx b/static/js/src/advantage/credentials/dashboard/components/QueryClient/QueryClient.tsx new file mode 100644 index 00000000000..280b1b19c71 --- /dev/null +++ b/static/js/src/advantage/credentials/dashboard/components/QueryClient/QueryClient.tsx @@ -0,0 +1,31 @@ +import { + QueryClient as Client, + QueryClientProvider, +} from "@tanstack/react-query"; + +const oneHour = 1000 * 60 * 60; +const queryClient = new Client({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + staleTime: oneHour, + retryOnMount: false, + }, + }, +}); + +type Props = { + children: React.ReactNode; +}; + +const QueryClient = (props: Props) => { + return ( + + {props.children} + + ); +}; + +export default QueryClient; diff --git a/static/js/src/advantage/credentials/dashboard/components/Routes/Routes.tsx b/static/js/src/advantage/credentials/dashboard/components/Routes/Routes.tsx new file mode 100644 index 00000000000..26f6ec653a9 --- /dev/null +++ b/static/js/src/advantage/credentials/dashboard/components/Routes/Routes.tsx @@ -0,0 +1,42 @@ +import { Routes as RouterRoutes, Route, Navigate } from "react-router-dom"; +import UpcomingExams from "../UpcomingExams/UpcomingExams"; +import ExamResults from "../ExamResults/ExamResults"; +import KeysList from "../KeysList/KeysList"; +import TestTakers from "../TestTakers/TestTakers"; +import CertificationIssued from "../CertificationsIssued/CertificationIssued"; +import BadgeTracking from "../BadgeTracking/BadgeTracking"; +import Exams from "../../routes/Exams"; +import Credly from "../../routes/Credly"; +import Keys from "../../routes/Keys"; +import { getUserPermissions } from "../../api/queryFns"; +import { getUserPermissionsKey } from "../../api/queryKeys"; +import { useQuery } from "@tanstack/react-query"; + +const Routes = () => { + const { data: permissions } = useQuery({ + queryKey: getUserPermissionsKey(), + queryFn: getUserPermissions, + }); + + return ( + + } /> + }> + } /> + {permissions?.is_credentials_admin && ( + } /> + )} + + }> + } /> + + }> + } /> + } /> + + } /> + + ); +}; + +export default Routes; diff --git a/static/js/src/advantage/credentials/dashboard/routes/Exams.tsx b/static/js/src/advantage/credentials/dashboard/routes/Exams.tsx index dec4b20545b..b7919e2c1d3 100644 --- a/static/js/src/advantage/credentials/dashboard/routes/Exams.tsx +++ b/static/js/src/advantage/credentials/dashboard/routes/Exams.tsx @@ -1,12 +1,20 @@ import { useMemo } from "react"; import { Tabs } from "@canonical/react-components"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { getUserPermissions } from "../api/queryFns"; +import { getUserPermissionsKey } from "../api/queryKeys"; const Exams = () => { const location = useLocation(); const navigate = useNavigate(); + const { data: permissions } = useQuery({ + queryKey: getUserPermissionsKey(), + queryFn: getUserPermissions, + }); const tabs = useMemo(() => { - return [ + const is_credentials_admin = permissions?.is_credentials_admin; + const currTabs = [ { active: location.pathname === "/exams/upcoming", label: "Upcoming Exams", @@ -14,15 +22,20 @@ const Exams = () => { navigate("/exams/upcoming"); }, }, - { + ]; + + if (is_credentials_admin) { + currTabs.push({ active: location.pathname === "/exams/results", label: "Exam Results", onClick: () => { navigate("/exams/results"); }, - }, - ]; - }, [location.pathname]); + }); + } + return currTabs; + }, [location.pathname, permissions]); + return ( <> diff --git a/webapp/app.py b/webapp/app.py index 63f1845fe1e..b0583890a42 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -96,6 +96,7 @@ get_issued_badges_bulk, get_test_taker_stats, issue_credly_badge, + get_cred_user_permissions, get_my_issued_badges, get_webhook_response, issue_badges, @@ -968,7 +969,6 @@ def takeovers_index(): "/credentials/dashboard", view_func=cred_dashboard, methods=["GET"], - defaults={"path": ""}, ) app.add_url_rule( "/credentials/dashboard/", @@ -1011,6 +1011,11 @@ def takeovers_index(): view_func=issue_credly_badge, methods=["POST"], ) +app.add_url_rule( + "/credentials/api/user-permissions", + view_func=get_cred_user_permissions, + methods=["GET"], +) app.add_url_rule( "/credentials/your-badges", diff --git a/webapp/login.py b/webapp/login.py index ffe862fd3f1..4776f816f38 100644 --- a/webapp/login.py +++ b/webapp/login.py @@ -15,6 +15,7 @@ COMMUNITY_TEAM = "ubuntumembers" CREDENTIALS_TEAM = "canonical-credentials" +CREDENTIALS_SUPPORT = "canonical-credentials-support" login_url = os.getenv("CANONICAL_LOGIN_URL", "https://login.ubuntu.com") @@ -43,6 +44,9 @@ def user_info(user_session): "is_credentials_admin": ( user_session["openid"].get("is_credentials_admin", False) ), + "is_credentials_support": ( + user_session["openid"].get("is_credentials_support", False) + ), } else: return None @@ -90,7 +94,11 @@ def login_handler(): extensions=[ openid_macaroon, TeamsRequest( - query_membership=[COMMUNITY_TEAM, CREDENTIALS_TEAM], + query_membership=[ + COMMUNITY_TEAM, + CREDENTIALS_TEAM, + CREDENTIALS_SUPPORT, + ], lp_ns_uri="http://ns.launchpad.net/2007/openid-teams", ), ], @@ -121,6 +129,9 @@ def after_login(resp): is_community_member = COMMUNITY_TEAM in resp.extensions["lp"].is_member is_credentials_admin = CREDENTIALS_TEAM in resp.extensions["lp"].is_member + is_credentials_support = ( + CREDENTIALS_SUPPORT in resp.extensions["lp"].is_member + ) flask.session["openid"] = { "identity_url": resp.identity_url, @@ -130,6 +141,7 @@ def after_login(resp): "email": resp.email, "is_community_member": is_community_member, "is_credentials_admin": is_credentials_admin, + "is_credentials_support": is_credentials_support, } return flask.redirect(open_id.get_next_url()) diff --git a/webapp/shop/cred/views.py b/webapp/shop/cred/views.py index 902761ab51f..066ce3082d4 100644 --- a/webapp/shop/cred/views.py +++ b/webapp/shop/cred/views.py @@ -19,6 +19,7 @@ ) from webapp.shop.decorators import ( credentials_group, + credentials_admin, shop_decorator, canonical_staff, get_trueability_api_instance, @@ -1211,6 +1212,19 @@ def issue_credly_badge(credly_api, **kwargs): return flask.jsonify({"error": error}), 400 +@shop_decorator(area="cred", permission="user", response="json") +def get_cred_user_permissions(credly_api, **kwargs): + sso_user = user_info(flask.session) + is_credentials_admin = sso_user.get("is_credentials_admin", False) + is_credentials_support = sso_user.get("is_credentials_support", False) + return flask.jsonify( + { + "is_credentials_admin": is_credentials_admin, + "is_credentials_support": is_credentials_support, + } + ) + + @shop_decorator(area="cred", permission="user", response="html") def get_my_issued_badges(credly_api, **kwargs): sso_user_email = user_info(flask.session)["email"] @@ -1320,7 +1334,7 @@ def cred_dashboard_upcoming_exams(trueability_api, **_): @shop_decorator(area="cred", permission="user", response="json") -@credentials_group() +@credentials_admin() def cred_dashboard_exam_results(trueability_api, **_): try: per_page = 50 diff --git a/webapp/shop/decorators.py b/webapp/shop/decorators.py index ad659ddcf2c..88993557aeb 100644 --- a/webapp/shop/decorators.py +++ b/webapp/shop/decorators.py @@ -207,6 +207,26 @@ def decorated_function(*args, **kwargs): def credentials_group(): + def decorator(func): + @wraps(func) + def decorated_function(*args, **kwargs): + sso_user = user_info(flask.session) + if sso_user and ( + (sso_user.get("is_credentials_admin", False) is True) + or (sso_user.get("is_credentials_support", False) is True) + ): + return func(*args, **kwargs) + + return flask.render_template( + "account/forbidden.html", reason="is_not_admin" + ) + + return decorated_function + + return decorator + + +def credentials_admin(): def decorator(func): @wraps(func) def decorated_function(*args, **kwargs):