From 306b602a306f138e6fb8f97bcbf5187d96a156de Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:31:04 -0600 Subject: [PATCH 01/19] refactor: use list of permissions in nav route instead of str This makes the permission handling more consistent between the fastAPI routes and the nav handler. --- goosebit/ui/nav.py | 2 +- goosebit/ui/routes.py | 6 +++--- goosebit/ui/templates/nav.html.jinja | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/goosebit/ui/nav.py b/goosebit/ui/nav.py index f8adf86e..62370ca6 100644 --- a/goosebit/ui/nav.py +++ b/goosebit/ui/nav.py @@ -2,7 +2,7 @@ class Navigation: def __init__(self): self.items = [] - def route(self, text: str, permissions: str | None = None): + def route(self, text: str, permissions: list[str] | None = None): def decorator(func): self.items.append({"function": func.__name__, "text": text, "permissions": permissions}) return func diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 18011f47..a79a75b5 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -24,7 +24,7 @@ async def ui_root(request: Request): "/devices", dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])], ) -@nav.route("Devices", permissions="device.read") +@nav.route("Devices", permissions=["device.read"]) async def devices_ui(request: Request): return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"}) @@ -33,7 +33,7 @@ async def devices_ui(request: Request): "/software", dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["software.read"])], ) -@nav.route("Software", permissions="software.read") +@nav.route("Software", permissions=["software.read"]) async def software_ui(request: Request): return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) @@ -42,7 +42,7 @@ async def software_ui(request: Request): "/rollouts", dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["rollout.read"])], ) -@nav.route("Rollouts", permissions="rollout.read") +@nav.route("Rollouts", permissions=["rollout.read"]) async def rollouts_ui(request: Request): return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"}) diff --git a/goosebit/ui/templates/nav.html.jinja b/goosebit/ui/templates/nav.html.jinja index 4d7ff27b..d62ba565 100644 --- a/goosebit/ui/templates/nav.html.jinja +++ b/goosebit/ui/templates/nav.html.jinja @@ -98,7 +98,7 @@ From b35d4ad67a938b0493d97194d3d24cf718c11b8c Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Tue, 1 Apr 2025 10:18:13 -0600 Subject: [PATCH 04/19] feature: add settings page to display user data --- goosebit.yaml | 5 +- goosebit/auth/permissions.py | 14 +++- goosebit/schema/users.py | 9 ++ goosebit/ui/bff/common/columns.py | 6 ++ goosebit/ui/bff/routes.py | 3 +- goosebit/ui/bff/settings/__init__.py | 1 + goosebit/ui/bff/settings/routes.py | 7 ++ goosebit/ui/bff/settings/users/__init__.py | 1 + goosebit/ui/bff/settings/users/responses.py | 33 ++++++++ goosebit/ui/bff/settings/users/routes.py | 58 +++++++++++++ goosebit/ui/routes.py | 11 +++ goosebit/ui/static/js/settings.js | 92 +++++++++++++++++++++ goosebit/ui/templates/settings.html.jinja | 24 ++++++ 13 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 goosebit/schema/users.py create mode 100644 goosebit/ui/bff/settings/__init__.py create mode 100644 goosebit/ui/bff/settings/routes.py create mode 100644 goosebit/ui/bff/settings/users/__init__.py create mode 100644 goosebit/ui/bff/settings/users/responses.py create mode 100644 goosebit/ui/bff/settings/users/routes.py create mode 100644 goosebit/ui/static/js/settings.js create mode 100644 goosebit/ui/templates/settings.html.jinja diff --git a/goosebit.yaml b/goosebit.yaml index 670092d0..d8d3daa0 100644 --- a/goosebit.yaml +++ b/goosebit.yaml @@ -35,10 +35,7 @@ poll_time: 00:01:00 # Defaults to a randomized value. If this value is not set, user sessions will not persist when app restarts. #secret_key: my_very_top_secret_key123 -# User account for the frontend. Available permissions: -# "software.read", "software.write", "software.delete" -# "device.read", "device.write", "device.delete" -# "rollout.read", "rollout.write", "rollout.delete" +# User account for the frontend. initial_user: username: admin@goosebit.local password: admin diff --git a/goosebit/auth/permissions.py b/goosebit/auth/permissions.py index 82ff12f4..9fba1553 100644 --- a/goosebit/auth/permissions.py +++ b/goosebit/auth/permissions.py @@ -50,16 +50,26 @@ def sub_permissions_by_name(self) -> dict[str, "Permission"]: description="Access to rollouts", sub_permissions=[READ_PERMISSION, WRITE_PERMISSION, DELETE_PERMISSION], ) +SETTINGS_USERS_PERMISSIONS = Permission( + name="users", + description="Access to user control", + sub_permissions=[READ_PERMISSION, WRITE_PERMISSION, DELETE_PERMISSION], +) +SETTING_PERMISSIONS = Permission( + name="settings", + description="Access to settings", + sub_permissions=[SETTINGS_USERS_PERMISSIONS], +) API_PERMISSIONS = Permission( name="api", description="Access to the GooseBit API", - sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS], + sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], ) UI_PERMISSIONS = Permission( name="ui", description="Access to the GooseBit UI", - sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS], + sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], ) GOOSEBIT_PERMISSIONS = Permission( diff --git a/goosebit/schema/users.py b/goosebit/schema/users.py new file mode 100644 index 00000000..9249544a --- /dev/null +++ b/goosebit/schema/users.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + + +class UserSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + username: str + enabled: bool + permissions: list[str] diff --git a/goosebit/ui/bff/common/columns.py b/goosebit/ui/bff/common/columns.py index 49870d24..4619d0b3 100644 --- a/goosebit/ui/bff/common/columns.py +++ b/goosebit/ui/bff/common/columns.py @@ -42,3 +42,9 @@ class SoftwareColumns: version = DTColumnDescription(title="Version", data="version", name="version", searchable=True, orderable=True) compatibility = DTColumnDescription(title="Compatibility", name="compatibility", data="compatibility") size = DTColumnDescription(title="Size", name="size", data="size") + + +class SettingsUsersColumns: + username = DTColumnDescription(title="Username", data="username", searchable=True, orderable=True) + enabled = DTColumnDescription(title="Enabled", data="enabled") + permissions = DTColumnDescription(title="Permissions", data="permissions") diff --git a/goosebit/ui/bff/routes.py b/goosebit/ui/bff/routes.py index 1e2d2985..ef1791ab 100644 --- a/goosebit/ui/bff/routes.py +++ b/goosebit/ui/bff/routes.py @@ -4,10 +4,11 @@ from goosebit.auth import validate_current_user -from . import devices, download, rollouts, software +from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)]) router.include_router(devices.router) router.include_router(software.router) router.include_router(rollouts.router) router.include_router(download.router) +router.include_router(settings.router) diff --git a/goosebit/ui/bff/settings/__init__.py b/goosebit/ui/bff/settings/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/ui/bff/settings/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/ui/bff/settings/routes.py b/goosebit/ui/bff/settings/routes.py new file mode 100644 index 00000000..a9344ee7 --- /dev/null +++ b/goosebit/ui/bff/settings/routes.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from . import users + +router = APIRouter(prefix="/settings") + +router.include_router(users.router) diff --git a/goosebit/ui/bff/settings/users/__init__.py b/goosebit/ui/bff/settings/users/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/ui/bff/settings/users/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/ui/bff/settings/users/responses.py b/goosebit/ui/bff/settings/users/responses.py new file mode 100644 index 00000000..46bf9ba8 --- /dev/null +++ b/goosebit/ui/bff/settings/users/responses.py @@ -0,0 +1,33 @@ +from typing import Callable + +from pydantic import BaseModel, Field +from tortoise.queryset import QuerySet + +from goosebit.schema.users import UserSchema +from goosebit.ui.bff.common.requests import DataTableRequest + + +class BFFSettingsUsersResponse(BaseModel): + data: list[UserSchema] + draw: int + records_total: int = Field(serialization_alias="recordsTotal") + records_filtered: int = Field(serialization_alias="recordsFiltered") + + @classmethod + async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + total_records = await query.count() + if dt_query.search.value: + query = query.filter(search_filter(dt_query.search.value)) + + filtered_records = await query.count() + + if dt_query.order_query: + query = query.order_by(dt_query.order_query) + + if dt_query.length is not None: + query = query.limit(dt_query.length) + + rollouts = await query.offset(dt_query.start).all() + data = [UserSchema.model_validate(r) for r in rollouts] + + return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records) diff --git a/goosebit/ui/bff/settings/users/routes.py b/goosebit/ui/bff/settings/users/routes.py new file mode 100644 index 00000000..70a8fdd5 --- /dev/null +++ b/goosebit/ui/bff/settings/users/routes.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Security +from tortoise.expressions import Q + +from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS +from goosebit.db.models import User +from goosebit.ui.bff.common.columns import SettingsUsersColumns +from goosebit.ui.bff.common.requests import DataTableRequest +from goosebit.ui.bff.common.responses import DTColumns +from goosebit.ui.bff.common.util import parse_datatables_query +from goosebit.ui.bff.settings.users.responses import BFFSettingsUsersResponse + +router = APIRouter(prefix="/users") + + +@router.get( + "", + dependencies=[ + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]["users"]["read"]()]) + ], +) +async def software_get( + dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], +) -> BFFSettingsUsersResponse: + filters: list[Q] = [] + + def search_filter(search_value): + base_filter = Q(Q(username__icontains=search_value), join_type="OR") + return Q(base_filter, *filters, join_type="AND") + + query = User.all() + + return await BFFSettingsUsersResponse.convert(dt_query, query, search_filter) + + +@router.get( + "/columns", + dependencies=[ + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]["users"]["read"]()]) + ], + response_model_exclude_none=True, +) +async def settings_users_get_columns() -> DTColumns: + columns = list( + filter( + None, + [ + SettingsUsersColumns.username, + SettingsUsersColumns.enabled, + SettingsUsersColumns.permissions, + ], + ) + ) + return DTColumns(columns=columns) diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 59a69cc4..c6966376 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -66,3 +66,14 @@ async def rollouts_ui(request: Request): ) async def logs_ui(request: Request, dev_id: str): return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id}) + + +@router.get( + "/settings", + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]()]), + ], +) +async def settings_ui(request: Request): + return templates.TemplateResponse(request, "settings.html.jinja", context={"title": "Settings"}) diff --git a/goosebit/ui/static/js/settings.js b/goosebit/ui/static/js/settings.js new file mode 100644 index 00000000..25525bfe --- /dev/null +++ b/goosebit/ui/static/js/settings.js @@ -0,0 +1,92 @@ +let dataTable; + +const renderFunctions = { + enabled: (data, type) => { + if (type === "display") { + const color = data ? "success" : "muted"; + return ` +
+ ● +
+ `; + } + return data; + }, +}; + +document.addEventListener("DOMContentLoaded", async () => { + const columnConfig = await get_request("/ui/bff/settings/users/columns"); + for (const col in columnConfig.columns) { + const colDesc = columnConfig.columns[col]; + const colName = colDesc.data; + if (renderFunctions[colName]) { + columnConfig.columns[col].render = renderFunctions[colName]; + } + } + + dataTable = new DataTable("#users-table", { + responsive: true, + paging: true, + processing: false, + serverSide: true, + order: { name: "username", dir: "asc" }, + scrollCollapse: true, + scroller: true, + scrollY: "65vh", + stateSave: true, + select: true, + rowId: "username", + ajax: { + url: "/ui/bff/settings/users", + data: (data) => { + // biome-ignore lint/performance/noDelete: really has to be deleted + delete data.columns; + }, + contentType: "application/json", + }, + initComplete: () => { + updateBtnState(); + }, + columnDefs: [ + { + targets: "_all", + searchable: false, + orderable: false, + render: (data) => data || "-", + }, + ], + columns: columnConfig.columns, + }); + + dataTable + .on("select", () => { + updateBtnState(); + }) + .on("deselect", () => { + updateBtnState(); + }); + + setInterval(() => { + updateUsersList(); + }, TABLE_UPDATE_TIME); +}); + +function updateUsersList() { + const scrollPosition = $("#users-table").parent().scrollTop(); // Get current scroll position + + const selectedRows = dataTable + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.username); + + dataTable.ajax.reload(() => { + dataTable.rows().every(function () { + const rowData = this.data(); + if (selectedRows.includes(rowData.username)) { + this.select(); + } + }); + $("#users-table").parent().scrollTop(scrollPosition); // Restore scroll position after reload + }, false); +} diff --git a/goosebit/ui/templates/settings.html.jinja b/goosebit/ui/templates/settings.html.jinja new file mode 100644 index 00000000..004a67a0 --- /dev/null +++ b/goosebit/ui/templates/settings.html.jinja @@ -0,0 +1,24 @@ +{% extends "nav.html.jinja" %} +{% block content %} +
+ +
+
+ +
+
+
+
+ +{% endblock content %} From 43b4825079fa6edab377c960dbac260aee6d2620 Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Wed, 2 Apr 2025 08:49:40 -0600 Subject: [PATCH 05/19] feature: add `/settings/users` to the API --- goosebit/api/v1/devices/device/routes.py | 4 ++-- goosebit/api/v1/devices/routes.py | 8 ++++---- goosebit/api/v1/rollouts/routes.py | 8 ++++---- goosebit/api/v1/routes.py | 3 ++- goosebit/api/v1/settings/__init__.py | 1 + goosebit/api/v1/settings/routes.py | 7 +++++++ goosebit/api/v1/settings/users/__init__.py | 1 + goosebit/api/v1/settings/users/responses.py | 7 +++++++ goosebit/api/v1/settings/users/routes.py | 20 ++++++++++++++++++++ goosebit/api/v1/software/routes.py | 6 +++--- goosebit/auth/permissions.py | 15 +++------------ goosebit/ui/bff/devices/device/routes.py | 2 +- goosebit/ui/bff/devices/routes.py | 8 ++++---- goosebit/ui/bff/rollouts/routes.py | 10 +++++----- goosebit/ui/bff/settings/users/routes.py | 10 +++------- goosebit/ui/bff/software/routes.py | 8 ++++---- goosebit/ui/routes.py | 21 +++++++++------------ 17 files changed, 80 insertions(+), 59 deletions(-) create mode 100644 goosebit/api/v1/settings/__init__.py create mode 100644 goosebit/api/v1/settings/routes.py create mode 100644 goosebit/api/v1/settings/users/__init__.py create mode 100644 goosebit/api/v1/settings/users/responses.py create mode 100644 goosebit/api/v1/settings/users/routes.py diff --git a/goosebit/api/v1/devices/device/routes.py b/goosebit/api/v1/devices/device/routes.py index 3cee45f6..c5e8e7ef 100644 --- a/goosebit/api/v1/devices/device/routes.py +++ b/goosebit/api/v1/devices/device/routes.py @@ -14,7 +14,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def device_get(_: Request, device: Device = Depends(get_device)) -> DeviceResponse: if device is None: @@ -25,7 +25,7 @@ async def device_get(_: Request, device: Device = Depends(get_device)) -> Device @router.get( "/log", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def device_logs(_: Request, device: Device = Depends(get_device)) -> DeviceLogResponse: if device is None: diff --git a/goosebit/api/v1/devices/routes.py b/goosebit/api/v1/devices/routes.py index 13c1f612..209056db 100644 --- a/goosebit/api/v1/devices/routes.py +++ b/goosebit/api/v1/devices/routes.py @@ -23,7 +23,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def devices_get(_: Request) -> DevicesResponse: devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility") @@ -43,7 +43,7 @@ async def set_assigned_sw(d: DeviceSchema): @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])], ) async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse: await DeviceManager.delete_devices(config.devices) @@ -52,7 +52,7 @@ async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResp @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: for dev_id in config.devices: @@ -82,7 +82,7 @@ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusRespon @router.put( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_put(_: Request, config: DevicesPutRequest) -> StatusResponse: for dev_id in config.devices: diff --git a/goosebit/api/v1/rollouts/routes.py b/goosebit/api/v1/rollouts/routes.py index fbedeb69..17f14a1c 100644 --- a/goosebit/api/v1/rollouts/routes.py +++ b/goosebit/api/v1/rollouts/routes.py @@ -14,7 +14,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], ) async def rollouts_get(_: Request) -> RolloutsResponse: rollouts = await Rollout.all().prefetch_related("software", "software__compatibility") @@ -23,7 +23,7 @@ async def rollouts_get(_: Request) -> RolloutsResponse: @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], ) async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse: software = await Software.filter(id=rollout.software_id) @@ -39,7 +39,7 @@ async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutRe @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], ) async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused) @@ -48,7 +48,7 @@ async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusRe @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])], ) async def rollouts_delete(_: Request, rollouts: RolloutsDeleteRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).delete() diff --git a/goosebit/api/v1/routes.py b/goosebit/api/v1/routes.py index 7c32bba9..90bfad1d 100644 --- a/goosebit/api/v1/routes.py +++ b/goosebit/api/v1/routes.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from . import devices, download, rollouts, software +from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/v1") router.include_router(software.router) router.include_router(devices.router) router.include_router(rollouts.router) router.include_router(download.router) +router.include_router(settings.router) diff --git a/goosebit/api/v1/settings/__init__.py b/goosebit/api/v1/settings/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/api/v1/settings/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/api/v1/settings/routes.py b/goosebit/api/v1/settings/routes.py new file mode 100644 index 00000000..f6a8add7 --- /dev/null +++ b/goosebit/api/v1/settings/routes.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from . import users + +router = APIRouter(prefix="/settings", tags=["settings"]) + +router.include_router(users.router) diff --git a/goosebit/api/v1/settings/users/__init__.py b/goosebit/api/v1/settings/users/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/api/v1/settings/users/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/api/v1/settings/users/responses.py b/goosebit/api/v1/settings/users/responses.py new file mode 100644 index 00000000..686f47a9 --- /dev/null +++ b/goosebit/api/v1/settings/users/responses.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from goosebit.schema.users import UserSchema + + +class SettingsUsersResponse(BaseModel): + users: list[UserSchema] diff --git a/goosebit/api/v1/settings/users/routes.py b/goosebit/api/v1/settings/users/routes.py new file mode 100644 index 00000000..fd5adc33 --- /dev/null +++ b/goosebit/api/v1/settings/users/routes.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from fastapi import APIRouter, Security +from fastapi.requests import Request + +from goosebit.api.v1.settings.users.responses import SettingsUsersResponse +from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS +from goosebit.db.models import User + +router = APIRouter(prefix="/users") + + +@router.get( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], +) +async def settings_users_get(_: Request) -> SettingsUsersResponse: + users = await User.all() + return SettingsUsersResponse(users=users) diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index 83e808ef..bc61683e 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -22,7 +22,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], ) async def software_get(_: Request) -> SoftwareResponse: software = await Software.all().prefetch_related("compatibility") @@ -31,7 +31,7 @@ async def software_get(_: Request) -> SoftwareResponse: @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["delete"]()])], ) async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> StatusResponse: success = False @@ -57,7 +57,7 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])], ) async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)): if url is not None: diff --git a/goosebit/auth/permissions.py b/goosebit/auth/permissions.py index 9fba1553..b0c4dd0e 100644 --- a/goosebit/auth/permissions.py +++ b/goosebit/auth/permissions.py @@ -61,17 +61,8 @@ def sub_permissions_by_name(self) -> dict[str, "Permission"]: sub_permissions=[SETTINGS_USERS_PERMISSIONS], ) -API_PERMISSIONS = Permission( - name="api", - description="Access to the GooseBit API", - sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], -) -UI_PERMISSIONS = Permission( - name="ui", - description="Access to the GooseBit UI", - sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], -) - GOOSEBIT_PERMISSIONS = Permission( - name="goosebit", description="Access to GooseBit", sub_permissions=[API_PERMISSIONS, UI_PERMISSIONS] + name="goosebit", + description="Access to GooseBit", + sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], ) diff --git a/goosebit/ui/bff/devices/device/routes.py b/goosebit/ui/bff/devices/device/routes.py index e17b7f12..fad88b01 100644 --- a/goosebit/ui/bff/devices/device/routes.py +++ b/goosebit/ui/bff/devices/device/routes.py @@ -12,6 +12,6 @@ "/log", routes.device_logs, methods=["GET"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], name="bff_device_logs", ) diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index 41ba1f70..046ca86a 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -30,7 +30,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse: def search_filter(search_value: str): @@ -61,7 +61,7 @@ async def set_assigned_sw(d: DeviceSchema): @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: for dev_id in config.devices: @@ -93,14 +93,14 @@ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusRespon "", routes.devices_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])], name="bff_devices_delete", ) @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns(request: Request) -> DTColumns: diff --git a/goosebit/ui/bff/rollouts/routes.py b/goosebit/ui/bff/rollouts/routes.py index 916f3410..08ae85a0 100644 --- a/goosebit/ui/bff/rollouts/routes.py +++ b/goosebit/ui/bff/rollouts/routes.py @@ -19,7 +19,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], ) async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse: def search_filter(search_value): @@ -34,7 +34,7 @@ def search_filter(search_value): "", routes.rollouts_put, methods=["POST"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], name="bff_rollouts_post", ) @@ -43,7 +43,7 @@ def search_filter(search_value): "", routes.rollouts_patch, methods=["PATCH"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], name="bff_rollouts_patch", ) @@ -52,14 +52,14 @@ def search_filter(search_value): "", routes.rollouts_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])], name="bff_rollouts_delete", ) @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["rollout"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns() -> DTColumns: diff --git a/goosebit/ui/bff/settings/users/routes.py b/goosebit/ui/bff/settings/users/routes.py index 70a8fdd5..c98f97b4 100644 --- a/goosebit/ui/bff/settings/users/routes.py +++ b/goosebit/ui/bff/settings/users/routes.py @@ -19,11 +19,9 @@ @router.get( "", - dependencies=[ - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]["users"]["read"]()]) - ], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], ) -async def software_get( +async def settings_users_get( dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], ) -> BFFSettingsUsersResponse: filters: list[Q] = [] @@ -39,9 +37,7 @@ def search_filter(search_value): @router.get( "/columns", - dependencies=[ - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]["users"]["read"]()]) - ], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], response_model_exclude_none=True, ) async def settings_users_get_columns() -> DTColumns: diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 63d69e75..531e930a 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -25,7 +25,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], ) async def software_get( dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], @@ -50,14 +50,14 @@ def search_filter(search_value): "", routes.software_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["delete"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["delete"]()])], name="bff_software_delete", ) @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["write"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])], ) async def post_update( request: Request, @@ -101,7 +101,7 @@ async def post_update( @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["read"]()])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns() -> DTColumns: diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index c6966376..f14a2433 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -1,17 +1,14 @@ from fastapi import APIRouter, Depends, Security from fastapi.requests import Request from fastapi.responses import RedirectResponse -from fastapi.security import OAuth2PasswordBearer from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.ui.nav import nav -from ..auth.permissions import GOOSEBIT_PERMISSIONS from . import bff from .templates import templates -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") - router = APIRouter(prefix="/ui", include_in_schema=False) router.include_router(bff.router) @@ -25,10 +22,10 @@ async def ui_root(request: Request): "/devices", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()]), + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), ], ) -@nav.route("Devices", permissions=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()]) +@nav.route("Devices", permissions=[GOOSEBIT_PERMISSIONS["device"]["read"]()]) async def devices_ui(request: Request): return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"}) @@ -37,10 +34,10 @@ async def devices_ui(request: Request): "/software", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["software"]["read"]()]), + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()]), ], ) -@nav.route("Software", permissions=[GOOSEBIT_PERMISSIONS["ui"]["software"]["read"]()]) +@nav.route("Software", permissions=[GOOSEBIT_PERMISSIONS["software"]["read"]()]) async def software_ui(request: Request): return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) @@ -49,10 +46,10 @@ async def software_ui(request: Request): "/rollouts", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=GOOSEBIT_PERMISSIONS["ui"]["rollout"]["read"]()), + Security(validate_user_permissions, scopes=GOOSEBIT_PERMISSIONS["rollout"]["read"]()), ], ) -@nav.route("Rollouts", permissions=GOOSEBIT_PERMISSIONS["ui"]["rollout"]["read"]()) +@nav.route("Rollouts", permissions=GOOSEBIT_PERMISSIONS["rollout"]["read"]()) async def rollouts_ui(request: Request): return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"}) @@ -61,7 +58,7 @@ async def rollouts_ui(request: Request): "/logs/{dev_id}", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["device"]["read"]()]), + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), ], ) async def logs_ui(request: Request, dev_id: str): @@ -72,7 +69,7 @@ async def logs_ui(request: Request, dev_id: str): "/settings", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["ui"]["settings"]()]), + Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]()]), ], ) async def settings_ui(request: Request): From a7988a7eb7a75b8395e3e1d8e3f25cd7a284c4c6 Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:19:34 -0600 Subject: [PATCH 06/19] refactor: rework rollouts add modal to make it more similar to other modals --- goosebit/ui/templates/rollouts.html.jinja | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/goosebit/ui/templates/rollouts.html.jinja b/goosebit/ui/templates/rollouts.html.jinja index 11304aea..db4b1be0 100644 --- a/goosebit/ui/templates/rollouts.html.jinja +++ b/goosebit/ui/templates/rollouts.html.jinja @@ -9,8 +9,8 @@ {% if compare_permissions(["rollout.write"], request.user.permissions) %} - +
+
+

Welcome to gooseBit. Please create an admin user.

+
+
Date: Mon, 21 Apr 2025 09:01:01 -0600 Subject: [PATCH 16/19] refactor: auto redirect a user to any page they are authorized to access --- goosebit/ui/nav.py | 11 ++++++++- goosebit/ui/routes.py | 49 +++++++++++++++++++++++++++++++------- goosebit/users/__init__.py | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/goosebit/ui/nav.py b/goosebit/ui/nav.py index 62370ca6..7025d987 100644 --- a/goosebit/ui/nav.py +++ b/goosebit/ui/nav.py @@ -1,10 +1,19 @@ +from pydantic import BaseModel + + +class NavigationItem(BaseModel): + function: str + text: str + permissions: list[str] + + class Navigation: def __init__(self): self.items = [] def route(self, text: str, permissions: list[str] | None = None): def decorator(func): - self.items.append({"function": func.__name__, "text": text, "permissions": permissions}) + self.items.append(NavigationItem(function=func.__name__, text=text, permissions=permissions)) return func return decorator diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index f14a2433..225c3052 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -1,17 +1,48 @@ -from fastapi import APIRouter, Depends, Security -from fastapi.requests import Request +import logging + +from fastapi import APIRouter, Depends, HTTPException, Security +from fastapi.requests import HTTPConnection, Request from fastapi.responses import RedirectResponse +from fastapi.security import SecurityScopes -from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions +from goosebit.auth import ( + check_permissions, + get_current_user, + redirect_if_unauthenticated, +) from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.ui.nav import nav +from ..db.models import User from . import bff from .templates import templates router = APIRouter(prefix="/ui", include_in_schema=False) router.include_router(bff.router) +logger = logging.getLogger(__name__) + + +def validate_user_permissions_with_nav_redirect( + connection: HTTPConnection, + security: SecurityScopes, + user: User = Depends(get_current_user), +): + if not check_permissions(security.scopes, user.permissions): + logger.warning(f"{user.username} does not have sufficient permissions") + for item in nav.items: + if check_permissions(item.permissions, user.permissions): + raise HTTPException( + status_code=302, + headers={"location": str(connection.url_for(item.function))}, + ) + raise HTTPException( + status_code=403, + detail="Not enough permissions", + headers={"WWW-Authenticate": "Bearer"}, + ) + return connection + @router.get("", dependencies=[Depends(redirect_if_unauthenticated)]) async def ui_root(request: Request): @@ -22,7 +53,7 @@ async def ui_root(request: Request): "/devices", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), ], ) @nav.route("Devices", permissions=[GOOSEBIT_PERMISSIONS["device"]["read"]()]) @@ -34,7 +65,7 @@ async def devices_ui(request: Request): "/software", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()]), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()]), ], ) @nav.route("Software", permissions=[GOOSEBIT_PERMISSIONS["software"]["read"]()]) @@ -46,10 +77,10 @@ async def software_ui(request: Request): "/rollouts", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=GOOSEBIT_PERMISSIONS["rollout"]["read"]()), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()]), ], ) -@nav.route("Rollouts", permissions=GOOSEBIT_PERMISSIONS["rollout"]["read"]()) +@nav.route("Rollouts", permissions=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()]) async def rollouts_ui(request: Request): return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"}) @@ -58,7 +89,7 @@ async def rollouts_ui(request: Request): "/logs/{dev_id}", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), ], ) async def logs_ui(request: Request, dev_id: str): @@ -69,7 +100,7 @@ async def logs_ui(request: Request, dev_id: str): "/settings", dependencies=[ Depends(redirect_if_unauthenticated), - Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]()]), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["settings"]()]), ], ) async def settings_ui(request: Request): diff --git a/goosebit/users/__init__.py b/goosebit/users/__init__.py index ceb9943f..9d20394f 100644 --- a/goosebit/users/__init__.py +++ b/goosebit/users/__init__.py @@ -60,5 +60,5 @@ async def delete_users(usernames: list[str]): await User.filter(username__in=usernames).delete() for username in usernames: result = await caches.get("default").delete(username) - assert result == 1, "device has been cached" + assert result, "user has been cached" users_count.set(await User.all().count()) From d693a12361dff3eb079889d450c48cceb497c506 Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:07:22 -0600 Subject: [PATCH 17/19] refactor: show settings if ser has enough permissions, and allow redirecting to settings Added settings as a nav route with the `show=False` parameter so that we can render it on a custom location on the navbar. --- goosebit/ui/nav.py | 5 +++-- goosebit/ui/routes.py | 1 + goosebit/ui/templates/nav.html.jinja | 14 +++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/goosebit/ui/nav.py b/goosebit/ui/nav.py index 7025d987..fadbd09a 100644 --- a/goosebit/ui/nav.py +++ b/goosebit/ui/nav.py @@ -5,15 +5,16 @@ class NavigationItem(BaseModel): function: str text: str permissions: list[str] + show: bool class Navigation: def __init__(self): self.items = [] - def route(self, text: str, permissions: list[str] | None = None): + def route(self, text: str, permissions: list[str] | None = None, show: bool = True): def decorator(func): - self.items.append(NavigationItem(function=func.__name__, text=text, permissions=permissions)) + self.items.append(NavigationItem(function=func.__name__, text=text, permissions=permissions, show=show)) return func return decorator diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 225c3052..e529d08e 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -103,5 +103,6 @@ async def logs_ui(request: Request, dev_id: str): Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["settings"]()]), ], ) +@nav.route("Settings", permissions=[GOOSEBIT_PERMISSIONS["settings"]()], show=False) async def settings_ui(request: Request): return templates.TemplateResponse(request, "settings.html.jinja", context={"title": "Settings"}) diff --git a/goosebit/ui/templates/nav.html.jinja b/goosebit/ui/templates/nav.html.jinja index a23ea0d1..bc4f24cb 100644 --- a/goosebit/ui/templates/nav.html.jinja +++ b/goosebit/ui/templates/nav.html.jinja @@ -98,15 +98,19 @@