From e2bc50b67bdb77b846788899b8341f94cfae002d Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 6 Jun 2026 23:25:35 +0100 Subject: [PATCH 1/5] Auto-grant full RBAC access to cue type creator (#1154) * Auto-grant full RBAC access to cue type creator on creation When a user creates a new cue type, immediately grant them READ, WRITE, and EXECUTE roles on that resource so they have per-instance access without requiring a separate admin grant. Also sends a GET_CURRENT_RBAC WebSocket message to the creator's active connections so both frontends refresh their RBAC state without a reload. Co-Authored-By: Claude Sonnet 4.6 * Fix ruff formatting in test_cues.py Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- server/controllers/api/show/cues.py | 10 +++ server/test/controllers/api/show/test_cues.py | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/server/controllers/api/show/cues.py b/server/controllers/api/show/cues.py index 2dca6fb2..3bc28afe 100644 --- a/server/controllers/api/show/cues.py +++ b/server/controllers/api/show/cues.py @@ -21,6 +21,7 @@ from models.cue import Cue, CueAssociation, CueType from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision from models.show import Show +from models.user import User from rbac.role import Role from schemas.schemas import CueSchema, CueTypeSchema from utils.web.base_controller import BaseAPIController @@ -81,6 +82,15 @@ async def post(self): session.add(new_cuetype) session.commit() + user = session.get(User, self.current_user["id"]) + self.application.rbac.give_role( + user, new_cuetype, Role.READ | Role.WRITE | Role.EXECUTE + ) + for socket in self.application.get_all_ws(user.id): + await socket.write_message( + {"OP": "NOOP", "DATA": {}, "ACTION": "GET_CURRENT_RBAC"} + ) + self.set_status(200) await self.finish( {"id": new_cuetype.id, "message": "Successfully added cue type"} diff --git a/server/test/controllers/api/show/test_cues.py b/server/test/controllers/api/show/test_cues.py index be510b48..6b9412c1 100644 --- a/server/test/controllers/api/show/test_cues.py +++ b/server/test/controllers/api/show/test_cues.py @@ -11,6 +11,7 @@ ) from models.show import Act, Scene, Show, ShowScriptType from models.user import User +from rbac.role import Role from test.conftest import DigiScriptTestCase @@ -936,3 +937,74 @@ def test_get_import_returns_correct_structure(self): self.assertIn("name", group) self.assertIn("cue_types", group) self.assertIsInstance(group["cue_types"], list) + + +class TestCueTypesController(DigiScriptTestCase): + """Test suite for POST /api/v1/show/cues/types endpoint.""" + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + show = Show(name="Test Show", script_mode=ShowScriptType.FULL) + session.add(show) + session.flush() + self.show_id = show.id + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + self.admin_token = self._create_and_login_admin() + self.user_token = self._create_and_login_user(self.admin_token) + + with self._app.get_db().sessionmaker() as session: + admin = session.scalars( + select(User).where(User.username == "admin") + ).first() + self.admin_id = admin.id + user = session.scalars(select(User).where(User.username == "user")).first() + self.user_id = user.id + show = session.get(Show, self.show_id) + self._app.rbac.give_role(user, show, Role.WRITE) + + def test_post_cue_type_grants_all_roles_to_creator(self): + """Creating a cue type grants READ|WRITE|EXECUTE to the creating user.""" + response = self.fetch( + "/api/v1/show/cues/types", + method="POST", + body=tornado.escape.json_encode( + {"prefix": "LX", "description": "Lighting", "colour": "#ff0000"} + ), + headers={"Authorization": f"Bearer {self.user_token}"}, + ) + self.assertEqual(200, response.code) + cue_type_id = tornado.escape.json_decode(response.body)["id"] + + with self._app.get_db().sessionmaker() as session: + user = session.get(User, self.user_id) + cue_type = session.get(CueType, cue_type_id) + self.assertTrue( + self._app.rbac.has_role( + user, cue_type, Role.READ | Role.WRITE | Role.EXECUTE + ) + ) + + def test_post_cue_type_admin_also_gets_grant(self): + """Creating a cue type as an admin still writes the RBAC grant.""" + response = self.fetch( + "/api/v1/show/cues/types", + method="POST", + body=tornado.escape.json_encode( + {"prefix": "SQ", "description": "Sound", "colour": "#00ff00"} + ), + headers={"Authorization": f"Bearer {self.admin_token}"}, + ) + self.assertEqual(200, response.code) + cue_type_id = tornado.escape.json_decode(response.body)["id"] + + with self._app.get_db().sessionmaker() as session: + admin = session.get(User, self.admin_id) + cue_type = session.get(CueType, cue_type_id) + self.assertTrue( + self._app.rbac.has_role( + admin, cue_type, Role.READ | Role.WRITE | Role.EXECUTE + ) + ) From 49dfdbf42aacc404bccfb5bd4c44c32beade6674 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 6 Jun 2026 23:58:30 +0100 Subject: [PATCH 2/5] Add created on date to users (#1156) --- .../src/components/config/ConfigUsers.vue | 4 +++ client-v3/src/types/api/user.ts | 1 + client/src/types/api/user.ts | 1 + .../src/vue_components/config/ConfigUsers.vue | 12 ++++++- ...8a936_add_created_on_timestamp_tp_users.py | 35 +++++++++++++++++++ server/controllers/api/auth/user.py | 1 + server/models/user.py | 1 + 7 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 server/alembic_config/versions/3f2343a8a936_add_created_on_timestamp_tp_users.py diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue index 62b3fa83..0b1b10c9 100644 --- a/client-v3/src/components/config/ConfigUsers.vue +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -10,6 +10,9 @@ + @@ -105,6 +108,7 @@ const selectedUser = ref<{ id: number; username: string } | null>(null); const userFields = [ 'username', + 'created_on', 'last_login', 'last_seen', { key: 'is_admin', label: 'User Type' }, diff --git a/client-v3/src/types/api/user.ts b/client-v3/src/types/api/user.ts index e37f8f3b..bdb8d311 100644 --- a/client-v3/src/types/api/user.ts +++ b/client-v3/src/types/api/user.ts @@ -2,6 +2,7 @@ export interface User { id: number; username: string | null; is_admin: boolean | null; + created_on: string | null; last_login: string | null; last_seen: string | null; requires_password_change: boolean; diff --git a/client/src/types/api/user.ts b/client/src/types/api/user.ts index 98e204a2..d0ee6261 100644 --- a/client/src/types/api/user.ts +++ b/client/src/types/api/user.ts @@ -2,6 +2,7 @@ export interface User { id: number; username: string | null; is_admin: boolean | null; + created_on: string | null; last_login: string | null; last_seen: string | null; requires_password_change: boolean; diff --git a/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue index c045c580..3edb0e1a 100644 --- a/client/src/vue_components/config/ConfigUsers.vue +++ b/client/src/vue_components/config/ConfigUsers.vue @@ -10,6 +10,9 @@ + @@ -93,7 +96,14 @@ export default defineComponent({ components: { CreateUser, ConfigRbac, ResetPassword }, data() { return { - userFields: ['username', 'last_login', 'last_seen', 'is_admin', { key: 'btn', label: '' }], + userFields: [ + 'username', + 'created_on', + 'last_login', + 'last_seen', + 'is_admin', + { key: 'btn', label: '' }, + ], editUser: null as number | null, resetUser: null as { id: number; username: string } | null, clientTimeout: null as ReturnType | null, diff --git a/server/alembic_config/versions/3f2343a8a936_add_created_on_timestamp_tp_users.py b/server/alembic_config/versions/3f2343a8a936_add_created_on_timestamp_tp_users.py new file mode 100644 index 00000000..34a4ca80 --- /dev/null +++ b/server/alembic_config/versions/3f2343a8a936_add_created_on_timestamp_tp_users.py @@ -0,0 +1,35 @@ +"""Add created on timestamp tp users + +Revision ID: 3f2343a8a936 +Revises: 11311df29aa4 +Create Date: 2026-06-06 23:49:09.107684 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "3f2343a8a936" +down_revision: Union[str, None] = "11311df29aa4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("created_on", sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("created_on") + + # ### end Alembic commands ### diff --git a/server/controllers/api/auth/user.py b/server/controllers/api/auth/user.py index d7248d23..4e3be9bf 100644 --- a/server/controllers/api/auth/user.py +++ b/server/controllers/api/auth/user.py @@ -72,6 +72,7 @@ async def post(self): username=username, password=hashed_password, is_admin=is_admin, + created_on=datetime.now(tz=timezone.utc), ) ) session.commit() diff --git a/server/models/user.py b/server/models/user.py index 089080ac..4371dbb4 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -67,6 +67,7 @@ class User(db.Model): username: Mapped[str | None] = mapped_column(index=True) password: Mapped[str | None] = mapped_column() is_admin: Mapped[bool | None] = mapped_column() + created_on: Mapped[datetime.datetime | None] = mapped_column() last_login: Mapped[datetime.datetime | None] = mapped_column() last_seen: Mapped[datetime.datetime | None] = mapped_column() api_token: Mapped[str | None] = mapped_column(index=True) From bbe7180db71d02c1567248d9f0aec5fec1c00ea7 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 00:37:02 +0100 Subject: [PATCH 3/5] Add username attribution to server app logs and log viewer filter (#1158) - app_server.py: override log_request() to include [username] in access log message and pass structured extra fields (username, user_id, remote_ip) so the server log buffer matches the existing client-log pattern - base_controller.py: add _log_extra() helper; on_finish() appends [username] to request-body debug lines and passes structured extra - logs_viewer.py: remove source == 'client' guard so username filter applies to both server and client log sources - ws_controller.py: store current_username on instance after auth; include it in on_message/on_close logs and emit a new auth-success log line - ConfigLogs.vue (both clients): remove v-if and source guards so the User filter field is always visible and always sent to the API - test_logs_viewer.py: replace the 'ignored for server source' test with two new tests asserting correct server-source filter behaviour - docs: add Log Viewer section to system config page Co-authored-by: Claude Sonnet 4.6 --- .../src/components/config/ConfigLogs.vue | 7 ++-- .../src/vue_components/config/ConfigLogs.vue | 10 ++---- docs/pages/user_config.md | 20 +++++++++++ server/controllers/api/logs_viewer.py | 9 +++-- server/controllers/ws_controller.py | 19 ++++++++-- server/digi_server/app_server.py | 36 +++++++++++++++++-- .../test/controllers/api/test_logs_viewer.py | 25 +++++++++---- server/utils/web/base_controller.py | 18 ++++++++-- 8 files changed, 113 insertions(+), 31 deletions(-) diff --git a/client-v3/src/components/config/ConfigLogs.vue b/client-v3/src/components/config/ConfigLogs.vue index 40d46355..182d26ae 100644 --- a/client-v3/src/components/config/ConfigLogs.vue +++ b/client-v3/src/components/config/ConfigLogs.vue @@ -26,7 +26,7 @@ /> - + { limit: String(limit.value), offset: '0', }); - if (source.value === 'client' && usernameInput.value) - params.set('username', usernameInput.value); + if (usernameInput.value) params.set('username', usernameInput.value); const response = await fetch(`${makeURL('/api/v1/logs/view')}?${params}`); if (!response.ok) { @@ -209,7 +208,7 @@ function buildStreamUrl(): string { level: levelFilter.value, search: searchInput.value, }); - if (source.value === 'client' && usernameInput.value) params.set('username', usernameInput.value); + if (usernameInput.value) params.set('username', usernameInput.value); return `${makeURL('/api/v1/logs/stream')}?${params}`; } diff --git a/client/src/vue_components/config/ConfigLogs.vue b/client/src/vue_components/config/ConfigLogs.vue index 6c8189b8..8fc5370a 100644 --- a/client/src/vue_components/config/ConfigLogs.vue +++ b/client/src/vue_components/config/ConfigLogs.vue @@ -31,7 +31,7 @@ /> - + { diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index 32b23a5b..6355ca3b 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -68,6 +68,26 @@ Once users have been created, their permissions can be configured by clicking th RBAC configuration determines what shows a user can access and what actions they can perform within those shows. +### Log Viewer + +The **Logs** tab (admin only) provides a real-time view of server and client log entries stored in the in-memory log buffer. + +#### Sources + +- **Server** — Application-level logs from the DigiScript backend: HTTP request access logs, request body debug logs, WebSocket connection events, and general application messages. +- **Client** — Logs forwarded from connected browser clients via the `/api/v1/logs/batch` endpoint. + +#### Username Attribution + +All log entries are attributed to the logged-in user where one is present: + +- **Server logs**: Each HTTP access log line and request-body debug line includes `[username]` in the message (e.g. `200 GET /api/v1/user/settings (127.0.0.1) [alice] 5.23ms`). WebSocket messages and close events also include the username once the connection has been authenticated. +- **Client logs**: The server extracts the username from the JWT token on each batch submission, so all client log entries are attributed even if the client sends no user information itself. + +#### Filtering + +Use the **Username** filter field to show only entries from a specific user. This filter applies to both Server and Client sources. Combined with the **Level** and **Search** filters, you can quickly isolate activity from a particular user across the full log stream. + ### Backup Management The **Backups** tab allows admin users to view and manage database backup files. DigiScript automatically creates a timestamped copy of the database file before running any database migration, ensuring you can recover data if a migration causes issues. diff --git a/server/controllers/api/logs_viewer.py b/server/controllers/api/logs_viewer.py index dcd63e0a..e1da0584 100644 --- a/server/controllers/api/logs_viewer.py +++ b/server/controllers/api/logs_viewer.py @@ -59,8 +59,7 @@ def _filter_entries( :param level_name: Uppercase level name (e.g. ``"ERROR"``); empty means no level filter. :param search: Lowercase search string; empty means no search filter. - :param username_filter: Lowercase username substring; only applied when - *source* is ``"client"``; empty means no filter. + :param username_filter: Lowercase username substring; empty means no filter. :param source: ``"server"`` or ``"client"``. :returns: Filtered list of entry dicts. """ @@ -72,7 +71,7 @@ def _filter_entries( if search: entries = [e for e in entries if search in e["message"].lower()] - if username_filter and source == "client": + if username_filter: entries = [ e for e in entries @@ -97,8 +96,8 @@ class LogViewerController(BaseAPIController): search : str Case-insensitive substring match on the ``message`` field. username : str - (Client source only) case-insensitive substring match on the - ``username`` field. + Case-insensitive substring match on the ``username`` field. + Applies to both client and server sources. limit : int Maximum number of entries to return (capped at 1000, default 500). offset : int diff --git a/server/controllers/ws_controller.py b/server/controllers/ws_controller.py index d202f753..ab57bac8 100644 --- a/server/controllers/ws_controller.py +++ b/server/controllers/ws_controller.py @@ -29,6 +29,7 @@ def __init__(self, application, request, **kwargs): super().__init__(application, request, **kwargs) self.application: DigiScriptServer = application self.current_user_id = None + self.current_username: str | None = None self._last_ping = 0.0 self._last_pong = 0.0 @@ -158,7 +159,12 @@ def on_close(self) -> None: } ) - get_logger().info(f"WebSocket closed from: {self.request.remote_ip}") + user_part = ( + f"{self.current_username} ({self.request.remote_ip})" + if self.current_username + else self.request.remote_ip + ) + get_logger().info(f"WebSocket closed from: {user_part}") async def authenticate_with_token(self, token): """Authenticate using JWT token""" @@ -184,6 +190,10 @@ async def authenticate_with_token(self, token): # Update the user ID for this connection self.current_user_id = user.id + self.current_username = user.username + get_logger().info( + f"WebSocket authenticated: {user.username} from {self.request.remote_ip}" + ) # Update the session with the user ID self.update_session(user_id=user.id) @@ -198,9 +208,12 @@ async def authenticate_with_token(self, token): return True async def on_message(self, message: Union[str, bytes]): - get_logger().debug( - f"WebSocket received data from {self.request.remote_ip}: {message}" + user_part = ( + f"{self.current_username} ({self.request.remote_ip})" + if self.current_username + else self.request.remote_ip ) + get_logger().debug(f"WebSocket message from {user_part}: {message}") message = json.loads(message) ws_op = message["OP"] diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index a63d407e..f118fe2a 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -290,10 +290,40 @@ class AlembicVersion(self._db.Model): ) def log_request(self, handler): - ignored_routes = Route.ignored_logging_routes() - if handler.request.path in ignored_routes: + from tornado.log import access_log # noqa: PLC0415 + + if handler.request.path in Route.ignored_logging_routes(): return - super().log_request(handler) + + username = None + user_id = None + if handler.current_user: + username = handler.current_user.get("username") + user_id = handler.current_user.get("id") + + if handler.get_status() < 400: + log_method = access_log.info + elif handler.get_status() < 500: + log_method = access_log.warning + else: + log_method = access_log.error + + request_time = 1000.0 * handler.request.request_time() + request_summary = f"{handler.request.method} {handler.request.uri} ({handler.request.remote_ip})" + user_suffix = f" [{username}]" if username else "" + + log_method( + "%d %s%s %.2fms", + handler.get_status(), + request_summary, + user_suffix, + request_time, + extra={ + "username": username, + "user_id": user_id, + "remote_ip": handler.request.remote_ip, + }, + ) @property def _alembic_config(self): diff --git a/server/test/controllers/api/test_logs_viewer.py b/server/test/controllers/api/test_logs_viewer.py index 05586ffb..974aa6f8 100644 --- a/server/test/controllers/api/test_logs_viewer.py +++ b/server/test/controllers/api/test_logs_viewer.py @@ -238,18 +238,29 @@ def test_username_filter_client_source(self): self.assertIn("alice log", messages) self.assertNotIn("bob log", messages) - def test_username_filter_ignored_for_server_source(self): - """username param should have no effect on the server source.""" - self._inject_server_entry("server_entry_for_username_test") + def test_username_filter_applies_to_server_source(self): + """username filter should work for server source, matching the client behaviour.""" + get_server_buffer().emit(_make_record("alice server log", username="alice")) + get_server_buffer().emit(_make_record("bob server log", username="bob")) token = self._create_and_login_admin() - resp_no_filter = escape.json_decode( - self._fetch_view(token=token, source="server").body + resp = escape.json_decode( + self._fetch_view(token=token, source="server", username="alice").body ) - resp_with_filter = escape.json_decode( + messages = [e["message"] for e in resp["entries"]] + self.assertIn("alice server log", messages) + self.assertNotIn("bob server log", messages) + + def test_username_filter_excludes_entries_without_username(self): + """Server entries that carry no username field are excluded by a username filter.""" + self._inject_server_entry("no_user_server_entry") + token = self._create_and_login_admin() + + resp = escape.json_decode( self._fetch_view(token=token, source="server", username="alice").body ) - self.assertEqual(resp_no_filter["total"], resp_with_filter["total"]) + messages = [e["message"] for e in resp["entries"]] + self.assertNotIn("no_user_server_entry", messages) # ------------------------------------------------------------------ # Pagination diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py index 3ecd84f7..9f34cca2 100644 --- a/server/utils/web/base_controller.py +++ b/server/utils/web/base_controller.py @@ -171,6 +171,13 @@ def _unimplemented_method(self, *args: str, **kwargs: str) -> None: self.set_status(405) self.write({"message": "405 not allowed"}) + def _log_extra(self) -> dict: + extra: dict = {"remote_ip": self.request.remote_ip} + if self.current_user: + extra["username"] = self.current_user.get("username") + extra["user_id"] = self.current_user.get("id") + return extra + def on_finish(self): from utils.web.route import Route # noqa: PLC0415 @@ -180,6 +187,9 @@ def on_finish(self): log_method = get_logger().debug if self.request.body: + username = self.current_user.get("username") if self.current_user else None + user_suffix = f" [{username}]" if username else "" + method_name = self.request.method.lower() handler_method = getattr(self, method_name, None) redacted_data_paths = getattr(handler_method, "_redacted_data_paths", None) @@ -187,7 +197,8 @@ def on_finish(self): body = escape.json_decode(self.request.body) except BaseException: get_logger().debug( - f"{self.request.method} {self.request.path} {self.request.body}" + f"{self.request.method} {self.request.path} {self.request.body}{user_suffix}", + extra=self._log_extra(), ) else: if ( @@ -199,6 +210,9 @@ def on_finish(self): body = deepcopy(body) redacted_data_paths.apply(body) - log_method(f"{self.request.method} {self.request.path} {body}") + log_method( + f"{self.request.method} {self.request.path} {body}{user_suffix}", + extra=self._log_extra(), + ) super().on_finish() From 7d79ca6850edec81bcdb30cc82567a999c464ecc Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 13:17:54 +0100 Subject: [PATCH 4/5] Add V2 user API with REST semantics + edit user feature (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add V2 user API with proper REST semantics + edit user feature Introduces a versioned V2 API layer for user management, moving all existing controllers into an explicit `api/v1/` package and adding `api/v2/` with RESTful resource-oriented endpoints (GET/POST/PATCH/DELETE on `/api/v2/users`). Adds a new edit-user feature allowing admins to toggle user admin status, wired into both the Vue 2 and Vue 3 frontends with an extensible modal design (adding a new editable field requires only one line in EDITABLE_FIELDS and one form group in the template). Co-Authored-By: Claude Sonnet 4.6 * Restructure test package to mirror controllers/api/ hierarchy Moves all V1 controller tests from test/controllers/api/ into test/controllers/api/v1/ (with auth/ subpackage), mirroring the structure introduced when V1 controllers moved to controllers/api/v1/. The existing test/controllers/api/v2/ tests remain in place. Co-Authored-By: Claude Sonnet 4.6 * Remove unnecessary V2 auth controllers; revert frontends to V1 auth The V1 auth endpoints (login, logout, refresh-token, get current user) already used correct HTTP verbs and needed no refactoring. The V2 duplicates added no value and only increased maintenance burden. V2 remains exclusively for user management endpoints where V1 violated REST conventions. Co-Authored-By: Claude Sonnet 4.6 * Remove V2 password controllers; revert frontends to V1 password endpoints Password change and reset endpoints already used correct HTTP verbs in V1 (PATCH and POST respectively) and contained no logic changes in V2 — only a URL namespace move. Keeping only the V2 token controller where the POST /revoke → DELETE fix was a genuine REST improvement. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/e2e/tests/03-system-config.spec.ts | 43 +++ .../src/components/config/ConfigUsers.vue | 40 +++ client-v3/src/js/http-interceptor.ts | 6 +- client-v3/src/stores/user.ts | 38 ++- client/src/js/http-interceptor.ts | 4 +- client/src/store/modules/user/user.ts | 37 ++- .../src/vue_components/config/ConfigUsers.vue | 38 ++- docs/pages/user_config.md | 8 + .../controllers/api/{auth => v1}/__init__.py | 0 .../api/{show => v1/auth}/__init__.py | 0 server/controllers/api/{ => v1}/auth/token.py | 0 server/controllers/api/{ => v1}/auth/user.py | 0 server/controllers/api/{ => v1}/db_backups.py | 0 server/controllers/api/{ => v1}/health.py | 0 server/controllers/api/{ => v1}/logging.py | 0 .../controllers/api/{ => v1}/logs_viewer.py | 0 server/controllers/api/{ => v1}/rbac.py | 0 server/controllers/api/{ => v1}/settings.py | 0 .../api/{show/script => v1/show}/__init__.py | 0 server/controllers/api/{ => v1}/show/acts.py | 0 server/controllers/api/{ => v1}/show/cast.py | 0 .../api/{ => v1}/show/characters.py | 0 server/controllers/api/{ => v1}/show/cues.py | 0 .../api/{ => v1}/show/microphones.py | 0 .../controllers/api/{ => v1}/show/scenes.py | 0 .../session => v1/show/script}/__init__.py | 0 .../api/{ => v1}/show/script/compiled.py | 0 .../api/{ => v1}/show/script/config.py | 0 .../api/{ => v1}/show/script/revisions.py | 0 .../api/{ => v1}/show/script/script.py | 0 .../show/script/stage_direction_styles.py | 0 .../stage => v1/show/session}/__init__.py | 0 .../api/{ => v1}/show/session/assign_tags.py | 0 .../api/{ => v1}/show/session/sessions.py | 0 .../api/{ => v1}/show/session/tags.py | 0 server/controllers/api/{ => v1}/show/shows.py | 0 .../api/{user => v1/show/stage}/__init__.py | 0 .../api/{ => v1}/show/stage/crew.py | 0 .../{ => v1}/show/stage/crew_assignments.py | 0 .../api/{ => v1}/show/stage/helpers.py | 0 .../api/{ => v1}/show/stage/props.py | 2 +- .../api/{ => v1}/show/stage/scenery.py | 2 +- .../controllers/api/{ => v1}/system_info.py | 0 .../api/v1/user}/__init__.py | 0 .../api/{ => v1}/user/overrides.py | 0 .../controllers/api/{ => v1}/user/settings.py | 0 server/controllers/api/{ => v1}/version.py | 0 server/controllers/api/{ => v1}/websocket.py | 0 .../script => controllers/api/v2}/__init__.py | 0 .../api/v2/users}/__init__.py | 0 server/controllers/api/v2/users/token.py | 64 ++++ server/controllers/api/v2/users/users.py | 182 ++++++++++ .../api/{show/stage => v1}/__init__.py | 0 .../api/{user => v1/auth}/__init__.py | 0 .../api/{ => v1/auth}/test_auth.py | 0 .../test/controllers/api/v1/show/__init__.py | 0 .../api/v1/show/script/__init__.py | 0 .../show/script/test_compiled_scripts.py | 0 .../api/{ => v1}/show/script/test_config.py | 0 .../show/script/test_orphan_deletion.py | 0 .../script/test_revision_fk_constraints.py | 0 .../{ => v1}/show/script/test_revisions.py | 0 .../api/{ => v1}/show/script/test_script.py | 0 .../script/test_stage_direction_styles.py | 0 .../api/v1/show/session/__init__.py | 0 .../{ => v1}/show/session/test_assign_tags.py | 0 .../{ => v1}/show/session/test_sessions.py | 0 .../api/{ => v1}/show/session/test_tags.py | 0 .../controllers/api/v1/show/stage/__init__.py | 0 .../api/{ => v1}/show/stage/test_crew.py | 0 .../show/stage/test_crew_assignments.py | 0 .../api/{ => v1}/show/stage/test_props.py | 0 .../api/{ => v1}/show/stage/test_scenery.py | 0 .../api/{ => v1}/show/test_acts.py | 0 .../api/{ => v1}/show/test_cast.py | 0 .../api/{ => v1}/show/test_characters.py | 0 .../api/{ => v1}/show/test_cues.py | 0 .../api/{ => v1}/show/test_microphones.py | 0 .../api/{ => v1}/show/test_scenes.py | 0 .../api/{ => v1}/show/test_shows.py | 0 .../api/{ => v1}/test_db_backups.py | 0 .../controllers/api/{ => v1}/test_health.py | 0 .../controllers/api/{ => v1}/test_logging.py | 0 .../api/{ => v1}/test_logs_viewer.py | 0 .../controllers/api/{ => v1}/test_rbac.py | 0 .../controllers/api/{ => v1}/test_settings.py | 0 .../api/{ => v1}/test_system_info.py | 0 .../api/{ => v1}/test_websocket.py | 0 .../test/controllers/api/v1/user/__init__.py | 0 .../api/{ => v1}/user/test_overrides.py | 0 server/test/controllers/api/v2/__init__.py | 0 server/test/controllers/api/v2/test_users.py | 312 ++++++++++++++++++ server/utils/web/route.py | 1 + 93 files changed, 745 insertions(+), 32 deletions(-) rename server/controllers/api/{auth => v1}/__init__.py (100%) rename server/controllers/api/{show => v1/auth}/__init__.py (100%) rename server/controllers/api/{ => v1}/auth/token.py (100%) rename server/controllers/api/{ => v1}/auth/user.py (100%) rename server/controllers/api/{ => v1}/db_backups.py (100%) rename server/controllers/api/{ => v1}/health.py (100%) rename server/controllers/api/{ => v1}/logging.py (100%) rename server/controllers/api/{ => v1}/logs_viewer.py (100%) rename server/controllers/api/{ => v1}/rbac.py (100%) rename server/controllers/api/{ => v1}/settings.py (100%) rename server/controllers/api/{show/script => v1/show}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/acts.py (100%) rename server/controllers/api/{ => v1}/show/cast.py (100%) rename server/controllers/api/{ => v1}/show/characters.py (100%) rename server/controllers/api/{ => v1}/show/cues.py (100%) rename server/controllers/api/{ => v1}/show/microphones.py (100%) rename server/controllers/api/{ => v1}/show/scenes.py (100%) rename server/controllers/api/{show/session => v1/show/script}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/script/compiled.py (100%) rename server/controllers/api/{ => v1}/show/script/config.py (100%) rename server/controllers/api/{ => v1}/show/script/revisions.py (100%) rename server/controllers/api/{ => v1}/show/script/script.py (100%) rename server/controllers/api/{ => v1}/show/script/stage_direction_styles.py (100%) rename server/controllers/api/{show/stage => v1/show/session}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/session/assign_tags.py (100%) rename server/controllers/api/{ => v1}/show/session/sessions.py (100%) rename server/controllers/api/{ => v1}/show/session/tags.py (100%) rename server/controllers/api/{ => v1}/show/shows.py (100%) rename server/controllers/api/{user => v1/show/stage}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/stage/crew.py (100%) rename server/controllers/api/{ => v1}/show/stage/crew_assignments.py (100%) rename server/controllers/api/{ => v1}/show/stage/helpers.py (100%) rename server/controllers/api/{ => v1}/show/stage/props.py (99%) rename server/controllers/api/{ => v1}/show/stage/scenery.py (99%) rename server/controllers/api/{ => v1}/system_info.py (100%) rename server/{test/controllers/api/show => controllers/api/v1/user}/__init__.py (100%) rename server/controllers/api/{ => v1}/user/overrides.py (100%) rename server/controllers/api/{ => v1}/user/settings.py (100%) rename server/controllers/api/{ => v1}/version.py (100%) rename server/controllers/api/{ => v1}/websocket.py (100%) rename server/{test/controllers/api/show/script => controllers/api/v2}/__init__.py (100%) rename server/{test/controllers/api/show/session => controllers/api/v2/users}/__init__.py (100%) create mode 100644 server/controllers/api/v2/users/token.py create mode 100644 server/controllers/api/v2/users/users.py rename server/test/controllers/api/{show/stage => v1}/__init__.py (100%) rename server/test/controllers/api/{user => v1/auth}/__init__.py (100%) rename server/test/controllers/api/{ => v1/auth}/test_auth.py (100%) create mode 100644 server/test/controllers/api/v1/show/__init__.py create mode 100644 server/test/controllers/api/v1/show/script/__init__.py rename server/test/controllers/api/{ => v1}/show/script/test_compiled_scripts.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_config.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_orphan_deletion.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_revision_fk_constraints.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_revisions.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_script.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_stage_direction_styles.py (100%) create mode 100644 server/test/controllers/api/v1/show/session/__init__.py rename server/test/controllers/api/{ => v1}/show/session/test_assign_tags.py (100%) rename server/test/controllers/api/{ => v1}/show/session/test_sessions.py (100%) rename server/test/controllers/api/{ => v1}/show/session/test_tags.py (100%) create mode 100644 server/test/controllers/api/v1/show/stage/__init__.py rename server/test/controllers/api/{ => v1}/show/stage/test_crew.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_crew_assignments.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_props.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_scenery.py (100%) rename server/test/controllers/api/{ => v1}/show/test_acts.py (100%) rename server/test/controllers/api/{ => v1}/show/test_cast.py (100%) rename server/test/controllers/api/{ => v1}/show/test_characters.py (100%) rename server/test/controllers/api/{ => v1}/show/test_cues.py (100%) rename server/test/controllers/api/{ => v1}/show/test_microphones.py (100%) rename server/test/controllers/api/{ => v1}/show/test_scenes.py (100%) rename server/test/controllers/api/{ => v1}/show/test_shows.py (100%) rename server/test/controllers/api/{ => v1}/test_db_backups.py (100%) rename server/test/controllers/api/{ => v1}/test_health.py (100%) rename server/test/controllers/api/{ => v1}/test_logging.py (100%) rename server/test/controllers/api/{ => v1}/test_logs_viewer.py (100%) rename server/test/controllers/api/{ => v1}/test_rbac.py (100%) rename server/test/controllers/api/{ => v1}/test_settings.py (100%) rename server/test/controllers/api/{ => v1}/test_system_info.py (100%) rename server/test/controllers/api/{ => v1}/test_websocket.py (100%) create mode 100644 server/test/controllers/api/v1/user/__init__.py rename server/test/controllers/api/{ => v1}/user/test_overrides.py (100%) create mode 100644 server/test/controllers/api/v2/__init__.py create mode 100644 server/test/controllers/api/v2/test_users.py diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 66b8cf2d..74b2b8fc 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -9,6 +9,7 @@ import { waitForAppReady, waitForModal, waitForModalClosed, + confirmModal, confirmDialog, } from '../helpers.js'; import { registerRetryHooks } from '../db-snapshot.js'; @@ -172,6 +173,48 @@ test('can configure RBAC permissions for testuser', async () => { await waitForModalClosed(page); }); +test('can edit a user to promote to admin', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + + // Verify the badge shows "User" before edit + await expect(userRow.locator('.badge:has-text("User")')).toBeVisible(); + + await userRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit User'); + + // Toggle the admin switch on + await page.locator('.modal.show .form-check-input').click(); + await expect(page.locator('.modal.show')).toContainText('Admin'); + + await confirmModal(page); + await waitForModalClosed(page); + + // Badge should now show "Admin" + await expect(userRow.locator('.badge:has-text("Admin")')).toBeVisible({ timeout: 5_000 }); +}); + +test('can edit a user to demote from admin', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + + await userRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit User'); + + // Toggle the admin switch off + await page.locator('.modal.show .form-check-input').click(); + await expect(page.locator('.modal.show')).toContainText('Standard User'); + + await confirmModal(page); + await waitForModalClosed(page); + + // Badge should show "User" again + await expect(userRow.locator('.badge:has-text("User")')).toBeVisible({ timeout: 5_000 }); +}); + +test('edit button is disabled for the current user', async () => { + const adminRow = page.locator('tr', { has: page.locator('td:has-text("admin")') }); + await expect(adminRow.locator('button:has-text("Edit")')).toBeDisabled(); +}); + test('resets the non-admin user password', async () => { // Ensure testuser row is stable before interacting await expect(page.locator('td:has-text("testuser")')).toBeVisible({ timeout: 5_000 }); diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue index 0b1b10c9..08efc6a0 100644 --- a/client-v3/src/components/config/ConfigUsers.vue +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -32,6 +32,13 @@ > RBAC + + Edit + + + + + + {{ editFormState.is_admin ? 'Admin' : 'Standard User' }} + + + + + >(); const newAdminModal = ref>(); const rbacModal = ref>(); +const editUserModal = ref>(); const resetPasswordModal = ref>(); const selectedUserId = ref(null); const selectedUser = ref<{ id: number; username: string } | null>(null); +const editFormState = ref | null>(null); const userFields = [ 'username', @@ -132,6 +157,21 @@ function openResetPassword(user: { id: number; username: string }): void { resetPasswordModal.value?.show(); } +function openEditUser(user: Record): void { + editFormState.value = { ...user }; + editUserModal.value?.show(); +} + +function clearEditUser(): void { + editFormState.value = null; +} + +async function submitEditUser(): Promise { + if (editFormState.value) { + await userStore.editUser(editFormState.value as { id: number }); + } +} + async function deleteUser(item: { id: number; username: string }): Promise { const confirmed = await confirm(`Are you sure you want to delete ${item.username}?`, { title: 'Delete User', diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index 933c4ac2..057307d5 100644 --- a/client-v3/src/js/http-interceptor.ts +++ b/client-v3/src/js/http-interceptor.ts @@ -111,9 +111,9 @@ export default function setupHttpInterceptor(): void { const userStore = useUserStore(); const newOptions = buildAuthenticatedOptions(options, userStore.authToken); - const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); - const isLoginRequest = resource.endsWith('/api/v1/auth/login'); - const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + const isLogoutRequest = resource.includes('/api/v1/auth/logout'); + const isLoginRequest = resource.includes('/api/v1/auth/login'); + const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token'); try { const response = await originalFetch(resource, newOptions); diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index a2044700..ecf925e4 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -176,7 +176,7 @@ export const useUserStore = defineStore('user', { async getUsers(): Promise { if (!this.currentUser?.is_admin) return; - const response = await fetch(makeURL('/api/v1/auth/users')); + const response = await fetch(makeURL('/api/v2/users')); if (response.ok) { const data = await response.json(); this.users = data.users; @@ -187,7 +187,7 @@ export const useUserStore = defineStore('user', { }, async createUser(user: Record): Promise { - const response = await fetch(makeURL('/api/v1/auth/create'), { + const response = await fetch(makeURL('/api/v2/users'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(user), @@ -203,10 +203,9 @@ export const useUserStore = defineStore('user', { }, async deleteUser(userId: number): Promise { - const response = await fetch(makeURL('/api/v1/auth/delete'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: userId }), + const params = new URLSearchParams({ id: String(userId) }); + const response = await fetch(makeURL(`/api/v2/users?${params}`), { + method: 'DELETE', }); if (response.ok) { await this.getUsers(); @@ -264,7 +263,7 @@ export const useUserStore = defineStore('user', { }, async generateApiToken(): Promise | null> { - const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), { + const response = await fetch(makeURL('/api/v2/users/token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -279,10 +278,8 @@ export const useUserStore = defineStore('user', { }, async revokeApiToken(): Promise { - const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + const response = await fetch(makeURL('/api/v2/users/token'), { + method: 'DELETE', }); if (response.ok) { toast.success('API token revoked successfully!'); @@ -294,12 +291,29 @@ export const useUserStore = defineStore('user', { }, async getApiToken(): Promise | null> { - const response = await fetch(makeURL('/api/v1/auth/api-token')); + const response = await fetch(makeURL('/api/v2/users/token')); if (response.ok) return response.json(); toast.error('Unable to get API token!'); return null; }, + async editUser(user: { id: number; [key: string]: unknown }): Promise { + const params = new URLSearchParams({ id: String(user.id) }); + const response = await fetch(makeURL(`/api/v2/users?${params}`), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user), + }); + if (response.ok) { + await this.getUsers(); + toast.success('User updated!'); + } else { + const body = await response.json(); + log.error('Unable to update user'); + toast.error(`Unable to update user: ${body.message || 'Unknown error'}`); + } + }, + async getStageDirectionStyleOverrides(): Promise { const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides')); if (response.ok) { diff --git a/client/src/js/http-interceptor.ts b/client/src/js/http-interceptor.ts index 913abd9e..8491ea4b 100644 --- a/client/src/js/http-interceptor.ts +++ b/client/src/js/http-interceptor.ts @@ -18,8 +18,8 @@ export default function setupHttpInterceptor(): void { // Only intercept our own API requests if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { const token = store.getters.AUTH_TOKEN; - const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); - const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + const isLogoutRequest = resource.includes('/api/v1/auth/logout'); + const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token'); // Clone the options const newOptions = { diff --git a/client/src/store/modules/user/user.ts b/client/src/store/modules/user/user.ts index a70089e1..2a6ac182 100644 --- a/client/src/store/modules/user/user.ts +++ b/client/src/store/modules/user/user.ts @@ -62,7 +62,7 @@ const module: Module = { if (context.getters.CURRENT_USER == null || !context.getters.CURRENT_USER.is_admin) { return; } - const response = await fetch(makeURL('/api/v1/auth/users')); + const response = await fetch(makeURL('/api/v2/users')); if (response.ok) { const users = await response.json(); await context.commit('SET_USERS', users.users); @@ -72,7 +72,7 @@ const module: Module = { } }, async CREATE_USER(context, user) { - const response = await fetch(makeURL('/api/v1/auth/create'), { + const response = await fetch(makeURL('/api/v2/users'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(user), @@ -87,10 +87,9 @@ const module: Module = { } }, async DELETE_USER(context, userId: number) { - const response = await fetch(makeURL('/api/v1/auth/delete'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: userId }), + const params = new URLSearchParams({ id: String(userId) }); + const response = await fetch(makeURL(`/api/v2/users?${params}`), { + method: 'DELETE', }); if (response.ok) { await context.dispatch('GET_USERS'); @@ -229,7 +228,7 @@ const module: Module = { await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval); }, async GENERATE_API_TOKEN() { - const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), { + const response = await fetch(makeURL('/api/v2/users/token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -247,10 +246,8 @@ const module: Module = { return null; }, async REVOKE_API_TOKEN() { - const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + const response = await fetch(makeURL('/api/v2/users/token'), { + method: 'DELETE', }); if (response.ok) { VueToast.$toast.success('API token revoked successfully!'); @@ -264,7 +261,7 @@ const module: Module = { return false; }, async GET_API_TOKEN() { - const response = await fetch(makeURL('/api/v1/auth/api-token'), { + const response = await fetch(makeURL('/api/v2/users/token'), { method: 'GET', }); if (response.ok) { @@ -274,6 +271,22 @@ const module: Module = { VueToast.$toast.error('Unable to get API token!'); return null; }, + async EDIT_USER(context, user: { id: number; [key: string]: unknown }) { + const params = new URLSearchParams({ id: String(user.id) }); + const response = await fetch(makeURL(`/api/v2/users?${params}`), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user), + }); + if (response.ok) { + await context.dispatch('GET_USERS'); + VueToast.$toast.success('User updated!'); + } else { + const responseBody = await response.json(); + log.error('Unable to update user'); + VueToast.$toast.error(`Unable to update user: ${responseBody.message || 'Unknown error'}`); + } + }, }, getters: { CURRENT_USER(state: UserState) { diff --git a/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue index 3edb0e1a..aa086957 100644 --- a/client/src/vue_components/config/ConfigUsers.vue +++ b/client/src/vue_components/config/ConfigUsers.vue @@ -33,6 +33,14 @@ > RBAC + + Edit + + + + + + {{ editFormState.is_admin ? 'Admin' : 'Standard User' }} + + + + | null, resetUser: null as { id: number; username: string } | null, clientTimeout: null as ReturnType | null, }; @@ -148,11 +173,22 @@ export default defineComponent({ await (this as any).DELETE_USER(data.item.id); } }, + openEditUser(user: Record): void { + this.editFormState = { ...user }; + }, + clearEditUser(): void { + this.editFormState = null; + }, + async submitEditUser(): Promise { + if (this.editFormState) { + await (this as any).EDIT_USER(this.editFormState); + } + }, async getUsers(): Promise { await (this as any).GET_USERS(); this.clientTimeout = setTimeout(this.getUsers, 5000); }, - ...mapActions(['GET_USERS', 'DELETE_USER']), + ...mapActions(['GET_USERS', 'DELETE_USER', 'EDIT_USER']), }, }); diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index 6355ca3b..7918b47f 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -59,6 +59,14 @@ Click the **New User** button to add a new user. You'll need to provide: Users are created at the system level and are not tied to individual shows. Their access to specific shows and resources is controlled through RBAC configuration. +#### Editing Users + +To change a user's properties, click the **Edit** button next to their row. This opens a modal where you can: + +- Toggle the user's account type between **Admin** and **Standard User** + +> **Note:** You cannot edit your own account via this interface. Admin status changes take effect immediately — the user's next API request will reflect the updated role. + #### Configuring RBAC Once users have been created, their permissions can be configured by clicking the **RBAC** button next to each user. This opens a detailed permissions interface where you can: diff --git a/server/controllers/api/auth/__init__.py b/server/controllers/api/v1/__init__.py similarity index 100% rename from server/controllers/api/auth/__init__.py rename to server/controllers/api/v1/__init__.py diff --git a/server/controllers/api/show/__init__.py b/server/controllers/api/v1/auth/__init__.py similarity index 100% rename from server/controllers/api/show/__init__.py rename to server/controllers/api/v1/auth/__init__.py diff --git a/server/controllers/api/auth/token.py b/server/controllers/api/v1/auth/token.py similarity index 100% rename from server/controllers/api/auth/token.py rename to server/controllers/api/v1/auth/token.py diff --git a/server/controllers/api/auth/user.py b/server/controllers/api/v1/auth/user.py similarity index 100% rename from server/controllers/api/auth/user.py rename to server/controllers/api/v1/auth/user.py diff --git a/server/controllers/api/db_backups.py b/server/controllers/api/v1/db_backups.py similarity index 100% rename from server/controllers/api/db_backups.py rename to server/controllers/api/v1/db_backups.py diff --git a/server/controllers/api/health.py b/server/controllers/api/v1/health.py similarity index 100% rename from server/controllers/api/health.py rename to server/controllers/api/v1/health.py diff --git a/server/controllers/api/logging.py b/server/controllers/api/v1/logging.py similarity index 100% rename from server/controllers/api/logging.py rename to server/controllers/api/v1/logging.py diff --git a/server/controllers/api/logs_viewer.py b/server/controllers/api/v1/logs_viewer.py similarity index 100% rename from server/controllers/api/logs_viewer.py rename to server/controllers/api/v1/logs_viewer.py diff --git a/server/controllers/api/rbac.py b/server/controllers/api/v1/rbac.py similarity index 100% rename from server/controllers/api/rbac.py rename to server/controllers/api/v1/rbac.py diff --git a/server/controllers/api/settings.py b/server/controllers/api/v1/settings.py similarity index 100% rename from server/controllers/api/settings.py rename to server/controllers/api/v1/settings.py diff --git a/server/controllers/api/show/script/__init__.py b/server/controllers/api/v1/show/__init__.py similarity index 100% rename from server/controllers/api/show/script/__init__.py rename to server/controllers/api/v1/show/__init__.py diff --git a/server/controllers/api/show/acts.py b/server/controllers/api/v1/show/acts.py similarity index 100% rename from server/controllers/api/show/acts.py rename to server/controllers/api/v1/show/acts.py diff --git a/server/controllers/api/show/cast.py b/server/controllers/api/v1/show/cast.py similarity index 100% rename from server/controllers/api/show/cast.py rename to server/controllers/api/v1/show/cast.py diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/v1/show/characters.py similarity index 100% rename from server/controllers/api/show/characters.py rename to server/controllers/api/v1/show/characters.py diff --git a/server/controllers/api/show/cues.py b/server/controllers/api/v1/show/cues.py similarity index 100% rename from server/controllers/api/show/cues.py rename to server/controllers/api/v1/show/cues.py diff --git a/server/controllers/api/show/microphones.py b/server/controllers/api/v1/show/microphones.py similarity index 100% rename from server/controllers/api/show/microphones.py rename to server/controllers/api/v1/show/microphones.py diff --git a/server/controllers/api/show/scenes.py b/server/controllers/api/v1/show/scenes.py similarity index 100% rename from server/controllers/api/show/scenes.py rename to server/controllers/api/v1/show/scenes.py diff --git a/server/controllers/api/show/session/__init__.py b/server/controllers/api/v1/show/script/__init__.py similarity index 100% rename from server/controllers/api/show/session/__init__.py rename to server/controllers/api/v1/show/script/__init__.py diff --git a/server/controllers/api/show/script/compiled.py b/server/controllers/api/v1/show/script/compiled.py similarity index 100% rename from server/controllers/api/show/script/compiled.py rename to server/controllers/api/v1/show/script/compiled.py diff --git a/server/controllers/api/show/script/config.py b/server/controllers/api/v1/show/script/config.py similarity index 100% rename from server/controllers/api/show/script/config.py rename to server/controllers/api/v1/show/script/config.py diff --git a/server/controllers/api/show/script/revisions.py b/server/controllers/api/v1/show/script/revisions.py similarity index 100% rename from server/controllers/api/show/script/revisions.py rename to server/controllers/api/v1/show/script/revisions.py diff --git a/server/controllers/api/show/script/script.py b/server/controllers/api/v1/show/script/script.py similarity index 100% rename from server/controllers/api/show/script/script.py rename to server/controllers/api/v1/show/script/script.py diff --git a/server/controllers/api/show/script/stage_direction_styles.py b/server/controllers/api/v1/show/script/stage_direction_styles.py similarity index 100% rename from server/controllers/api/show/script/stage_direction_styles.py rename to server/controllers/api/v1/show/script/stage_direction_styles.py diff --git a/server/controllers/api/show/stage/__init__.py b/server/controllers/api/v1/show/session/__init__.py similarity index 100% rename from server/controllers/api/show/stage/__init__.py rename to server/controllers/api/v1/show/session/__init__.py diff --git a/server/controllers/api/show/session/assign_tags.py b/server/controllers/api/v1/show/session/assign_tags.py similarity index 100% rename from server/controllers/api/show/session/assign_tags.py rename to server/controllers/api/v1/show/session/assign_tags.py diff --git a/server/controllers/api/show/session/sessions.py b/server/controllers/api/v1/show/session/sessions.py similarity index 100% rename from server/controllers/api/show/session/sessions.py rename to server/controllers/api/v1/show/session/sessions.py diff --git a/server/controllers/api/show/session/tags.py b/server/controllers/api/v1/show/session/tags.py similarity index 100% rename from server/controllers/api/show/session/tags.py rename to server/controllers/api/v1/show/session/tags.py diff --git a/server/controllers/api/show/shows.py b/server/controllers/api/v1/show/shows.py similarity index 100% rename from server/controllers/api/show/shows.py rename to server/controllers/api/v1/show/shows.py diff --git a/server/controllers/api/user/__init__.py b/server/controllers/api/v1/show/stage/__init__.py similarity index 100% rename from server/controllers/api/user/__init__.py rename to server/controllers/api/v1/show/stage/__init__.py diff --git a/server/controllers/api/show/stage/crew.py b/server/controllers/api/v1/show/stage/crew.py similarity index 100% rename from server/controllers/api/show/stage/crew.py rename to server/controllers/api/v1/show/stage/crew.py diff --git a/server/controllers/api/show/stage/crew_assignments.py b/server/controllers/api/v1/show/stage/crew_assignments.py similarity index 100% rename from server/controllers/api/show/stage/crew_assignments.py rename to server/controllers/api/v1/show/stage/crew_assignments.py diff --git a/server/controllers/api/show/stage/helpers.py b/server/controllers/api/v1/show/stage/helpers.py similarity index 100% rename from server/controllers/api/show/stage/helpers.py rename to server/controllers/api/v1/show/stage/helpers.py diff --git a/server/controllers/api/show/stage/props.py b/server/controllers/api/v1/show/stage/props.py similarity index 99% rename from server/controllers/api/show/stage/props.py rename to server/controllers/api/v1/show/stage/props.py index 67d3332d..e961d385 100644 --- a/server/controllers/api/show/stage/props.py +++ b/server/controllers/api/v1/show/stage/props.py @@ -12,7 +12,7 @@ ERROR_PROPS_NOT_FOUND, ERROR_SHOW_NOT_FOUND, ) -from controllers.api.show.stage.helpers import ( +from controllers.api.v1.show.stage.helpers import ( handle_allocation_delete, handle_allocation_post, handle_type_delete, diff --git a/server/controllers/api/show/stage/scenery.py b/server/controllers/api/v1/show/stage/scenery.py similarity index 99% rename from server/controllers/api/show/stage/scenery.py rename to server/controllers/api/v1/show/stage/scenery.py index aa673e5e..b1c1673d 100644 --- a/server/controllers/api/show/stage/scenery.py +++ b/server/controllers/api/v1/show/stage/scenery.py @@ -11,7 +11,7 @@ ERROR_SCENERY_TYPE_NOT_FOUND, ERROR_SHOW_NOT_FOUND, ) -from controllers.api.show.stage.helpers import ( +from controllers.api.v1.show.stage.helpers import ( handle_allocation_delete, handle_allocation_post, handle_type_delete, diff --git a/server/controllers/api/system_info.py b/server/controllers/api/v1/system_info.py similarity index 100% rename from server/controllers/api/system_info.py rename to server/controllers/api/v1/system_info.py diff --git a/server/test/controllers/api/show/__init__.py b/server/controllers/api/v1/user/__init__.py similarity index 100% rename from server/test/controllers/api/show/__init__.py rename to server/controllers/api/v1/user/__init__.py diff --git a/server/controllers/api/user/overrides.py b/server/controllers/api/v1/user/overrides.py similarity index 100% rename from server/controllers/api/user/overrides.py rename to server/controllers/api/v1/user/overrides.py diff --git a/server/controllers/api/user/settings.py b/server/controllers/api/v1/user/settings.py similarity index 100% rename from server/controllers/api/user/settings.py rename to server/controllers/api/v1/user/settings.py diff --git a/server/controllers/api/version.py b/server/controllers/api/v1/version.py similarity index 100% rename from server/controllers/api/version.py rename to server/controllers/api/v1/version.py diff --git a/server/controllers/api/websocket.py b/server/controllers/api/v1/websocket.py similarity index 100% rename from server/controllers/api/websocket.py rename to server/controllers/api/v1/websocket.py diff --git a/server/test/controllers/api/show/script/__init__.py b/server/controllers/api/v2/__init__.py similarity index 100% rename from server/test/controllers/api/show/script/__init__.py rename to server/controllers/api/v2/__init__.py diff --git a/server/test/controllers/api/show/session/__init__.py b/server/controllers/api/v2/users/__init__.py similarity index 100% rename from server/test/controllers/api/show/session/__init__.py rename to server/controllers/api/v2/users/__init__.py diff --git a/server/controllers/api/v2/users/token.py b/server/controllers/api/v2/users/token.py new file mode 100644 index 00000000..704900b9 --- /dev/null +++ b/server/controllers/api/v2/users/token.py @@ -0,0 +1,64 @@ +import secrets + +from models.user import User +from services.password_service import PasswordService +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import api_authenticated + + +@ApiRoute("users/token", ApiVersion.V2) +class UsersTokenV2Controller(BaseAPIController): + @api_authenticated + def get(self): + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + self.finish({"message": "User not found"}) + return + + self.set_status(200) + self.finish({"has_token": user.api_token is not None}) + + @api_authenticated + async def post(self): + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + new_token = secrets.token_urlsafe(32) + hashed_token = await PasswordService.hash_password(new_token) + user.api_token = hashed_token + session.commit() + + self.set_status(200) + await self.finish( + { + "message": "API token generated successfully", + "api_token": new_token, + } + ) + + @api_authenticated + async def delete(self): + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if not user.api_token: + self.set_status(400) + await self.finish({"message": "No API token to revoke"}) + return + + user.api_token = None + session.commit() + + self.set_status(200) + await self.finish({"message": "API token revoked successfully"}) diff --git a/server/controllers/api/v2/users/users.py b/server/controllers/api/v2/users/users.py new file mode 100644 index 00000000..71860c35 --- /dev/null +++ b/server/controllers/api/v2/users/users.py @@ -0,0 +1,182 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from tornado import escape + +from models.user import User +from registry.named_locks import NamedLockRegistry +from schemas.schemas import UserSchema +from services.password_service import PasswordService +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import ( + api_authenticated, + no_live_session, + redact_data_paths, + require_admin, +) + + +@ApiRoute("users", ApiVersion.V2) +class UsersV2Controller(BaseAPIController): + EDITABLE_FIELDS = {"is_admin"} + + @api_authenticated + @require_admin + def get(self): + user_schema = UserSchema() + with self.make_session() as session: + users = session.scalars(select(User)).all() + self.set_status(200) + self.finish({"users": [user_schema.dump(u) for u in users]}) + + @redact_data_paths(paths=["/password", "/confirmPassword"]) + async def post(self): + with self.make_session() as session: + has_any_users = session.scalars(select(User)).first() is not None + if has_any_users: + self.requires_admin() + + data = escape.json_decode(self.request.body) + + username = data.get("username", "") + if not username: + self.set_status(400) + await self.finish({"message": "Username missing"}) + return + + password = data.get("password", "") + if not password: + self.set_status(400) + await self.finish({"message": "Password missing"}) + return + + is_admin = data.get("is_admin", False) + if not has_any_users and not is_admin: + self.set_status(400) + await self.finish({"message": "First user must be an admin"}) + return + + is_valid, error_msg = PasswordService.validate_password_strength(password) + if not is_valid: + self.set_status(400) + await self.finish({"message": error_msg}) + return + + async with NamedLockRegistry.acquire(f"UserLock::{username}"): + conflict_user = session.scalars( + select(User).where(User.username == username) + ).first() + if conflict_user: + self.set_status(400) + await self.finish({"message": "Username already taken"}) + return + + hashed_password = await PasswordService.hash_password(password) + + session.add( + User( + username=username, + password=hashed_password, + is_admin=is_admin, + created_on=datetime.now(tz=timezone.utc), + ) + ) + session.commit() + + if is_admin: + await self.application.digi_settings.set("has_admin_user", True) + + self.set_status(200) + await self.application.ws_send_to_all("NOOP", "GET_USERS", {}) + await self.finish({"message": "Successfully created user"}) + + @api_authenticated + @require_admin + async def patch(self): + user_id = self.get_argument("id", None) + if not user_id: + self.set_status(400) + await self.finish({"message": "Id missing"}) + return + + data = escape.json_decode(self.request.body) + + with self.make_session() as session: + user: User = session.get(User, int(user_id)) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if user.id == self.current_user["id"]: + self.set_status(400) + await self.finish({"message": "Cannot edit your own account"}) + return + + if "is_admin" in data and not data["is_admin"] and user.is_admin: + all_admins = session.scalars( + select(User).where(User.is_admin.is_(True)) + ).all() + if len(all_admins) <= 1: + self.set_status(400) + await self.finish( + {"message": "Cannot remove admin from the only admin user"} + ) + return + + for field in self.EDITABLE_FIELDS: + if field in data: + setattr(user, field, data[field]) + + session.commit() + + self.set_status(200) + await self.application.ws_send_to_all("NOOP", "GET_USERS", {}) + await self.finish({"message": "Successfully updated user"}) + + @api_authenticated + @require_admin + @no_live_session + async def delete(self): + user_id = self.get_argument("id", None) + if not user_id: + self.set_status(400) + await self.finish({"message": "Id missing"}) + return + + with self.make_session() as session: + user_to_delete: User = session.get(User, int(user_id)) + if not user_to_delete: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if user_to_delete.id == self.current_user["id"]: + self.set_status(400) + await self.finish( + {"message": "Cannot delete currently authenticated user"} + ) + return + + all_admins = session.scalars( + select(User).where(User.is_admin.is_(True)) + ).all() + if user_to_delete.is_admin and len(all_admins) <= 1: + self.set_status(400) + await self.finish({"message": "Cannot delete the only admin user"}) + return + + async with NamedLockRegistry.acquire( + f"UserLock::{user_to_delete.username}" + ): + await self.application.user_service.force_logout_all_sessions( + session, user_to_delete + ) + self.application.rbac.delete_actor(user_to_delete) + session.delete(user_to_delete) + session.commit() + + self.set_status(200) + await self.application.ws_send_to_all("NOOP", "GET_USERS", {}) + await self.finish({"message": "Successfully deleted user"}) diff --git a/server/test/controllers/api/show/stage/__init__.py b/server/test/controllers/api/v1/__init__.py similarity index 100% rename from server/test/controllers/api/show/stage/__init__.py rename to server/test/controllers/api/v1/__init__.py diff --git a/server/test/controllers/api/user/__init__.py b/server/test/controllers/api/v1/auth/__init__.py similarity index 100% rename from server/test/controllers/api/user/__init__.py rename to server/test/controllers/api/v1/auth/__init__.py diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/v1/auth/test_auth.py similarity index 100% rename from server/test/controllers/api/test_auth.py rename to server/test/controllers/api/v1/auth/test_auth.py diff --git a/server/test/controllers/api/v1/show/__init__.py b/server/test/controllers/api/v1/show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/v1/show/script/__init__.py b/server/test/controllers/api/v1/show/script/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/show/script/test_compiled_scripts.py b/server/test/controllers/api/v1/show/script/test_compiled_scripts.py similarity index 100% rename from server/test/controllers/api/show/script/test_compiled_scripts.py rename to server/test/controllers/api/v1/show/script/test_compiled_scripts.py diff --git a/server/test/controllers/api/show/script/test_config.py b/server/test/controllers/api/v1/show/script/test_config.py similarity index 100% rename from server/test/controllers/api/show/script/test_config.py rename to server/test/controllers/api/v1/show/script/test_config.py diff --git a/server/test/controllers/api/show/script/test_orphan_deletion.py b/server/test/controllers/api/v1/show/script/test_orphan_deletion.py similarity index 100% rename from server/test/controllers/api/show/script/test_orphan_deletion.py rename to server/test/controllers/api/v1/show/script/test_orphan_deletion.py diff --git a/server/test/controllers/api/show/script/test_revision_fk_constraints.py b/server/test/controllers/api/v1/show/script/test_revision_fk_constraints.py similarity index 100% rename from server/test/controllers/api/show/script/test_revision_fk_constraints.py rename to server/test/controllers/api/v1/show/script/test_revision_fk_constraints.py diff --git a/server/test/controllers/api/show/script/test_revisions.py b/server/test/controllers/api/v1/show/script/test_revisions.py similarity index 100% rename from server/test/controllers/api/show/script/test_revisions.py rename to server/test/controllers/api/v1/show/script/test_revisions.py diff --git a/server/test/controllers/api/show/script/test_script.py b/server/test/controllers/api/v1/show/script/test_script.py similarity index 100% rename from server/test/controllers/api/show/script/test_script.py rename to server/test/controllers/api/v1/show/script/test_script.py diff --git a/server/test/controllers/api/show/script/test_stage_direction_styles.py b/server/test/controllers/api/v1/show/script/test_stage_direction_styles.py similarity index 100% rename from server/test/controllers/api/show/script/test_stage_direction_styles.py rename to server/test/controllers/api/v1/show/script/test_stage_direction_styles.py diff --git a/server/test/controllers/api/v1/show/session/__init__.py b/server/test/controllers/api/v1/show/session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/show/session/test_assign_tags.py b/server/test/controllers/api/v1/show/session/test_assign_tags.py similarity index 100% rename from server/test/controllers/api/show/session/test_assign_tags.py rename to server/test/controllers/api/v1/show/session/test_assign_tags.py diff --git a/server/test/controllers/api/show/session/test_sessions.py b/server/test/controllers/api/v1/show/session/test_sessions.py similarity index 100% rename from server/test/controllers/api/show/session/test_sessions.py rename to server/test/controllers/api/v1/show/session/test_sessions.py diff --git a/server/test/controllers/api/show/session/test_tags.py b/server/test/controllers/api/v1/show/session/test_tags.py similarity index 100% rename from server/test/controllers/api/show/session/test_tags.py rename to server/test/controllers/api/v1/show/session/test_tags.py diff --git a/server/test/controllers/api/v1/show/stage/__init__.py b/server/test/controllers/api/v1/show/stage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/show/stage/test_crew.py b/server/test/controllers/api/v1/show/stage/test_crew.py similarity index 100% rename from server/test/controllers/api/show/stage/test_crew.py rename to server/test/controllers/api/v1/show/stage/test_crew.py diff --git a/server/test/controllers/api/show/stage/test_crew_assignments.py b/server/test/controllers/api/v1/show/stage/test_crew_assignments.py similarity index 100% rename from server/test/controllers/api/show/stage/test_crew_assignments.py rename to server/test/controllers/api/v1/show/stage/test_crew_assignments.py diff --git a/server/test/controllers/api/show/stage/test_props.py b/server/test/controllers/api/v1/show/stage/test_props.py similarity index 100% rename from server/test/controllers/api/show/stage/test_props.py rename to server/test/controllers/api/v1/show/stage/test_props.py diff --git a/server/test/controllers/api/show/stage/test_scenery.py b/server/test/controllers/api/v1/show/stage/test_scenery.py similarity index 100% rename from server/test/controllers/api/show/stage/test_scenery.py rename to server/test/controllers/api/v1/show/stage/test_scenery.py diff --git a/server/test/controllers/api/show/test_acts.py b/server/test/controllers/api/v1/show/test_acts.py similarity index 100% rename from server/test/controllers/api/show/test_acts.py rename to server/test/controllers/api/v1/show/test_acts.py diff --git a/server/test/controllers/api/show/test_cast.py b/server/test/controllers/api/v1/show/test_cast.py similarity index 100% rename from server/test/controllers/api/show/test_cast.py rename to server/test/controllers/api/v1/show/test_cast.py diff --git a/server/test/controllers/api/show/test_characters.py b/server/test/controllers/api/v1/show/test_characters.py similarity index 100% rename from server/test/controllers/api/show/test_characters.py rename to server/test/controllers/api/v1/show/test_characters.py diff --git a/server/test/controllers/api/show/test_cues.py b/server/test/controllers/api/v1/show/test_cues.py similarity index 100% rename from server/test/controllers/api/show/test_cues.py rename to server/test/controllers/api/v1/show/test_cues.py diff --git a/server/test/controllers/api/show/test_microphones.py b/server/test/controllers/api/v1/show/test_microphones.py similarity index 100% rename from server/test/controllers/api/show/test_microphones.py rename to server/test/controllers/api/v1/show/test_microphones.py diff --git a/server/test/controllers/api/show/test_scenes.py b/server/test/controllers/api/v1/show/test_scenes.py similarity index 100% rename from server/test/controllers/api/show/test_scenes.py rename to server/test/controllers/api/v1/show/test_scenes.py diff --git a/server/test/controllers/api/show/test_shows.py b/server/test/controllers/api/v1/show/test_shows.py similarity index 100% rename from server/test/controllers/api/show/test_shows.py rename to server/test/controllers/api/v1/show/test_shows.py diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/v1/test_db_backups.py similarity index 100% rename from server/test/controllers/api/test_db_backups.py rename to server/test/controllers/api/v1/test_db_backups.py diff --git a/server/test/controllers/api/test_health.py b/server/test/controllers/api/v1/test_health.py similarity index 100% rename from server/test/controllers/api/test_health.py rename to server/test/controllers/api/v1/test_health.py diff --git a/server/test/controllers/api/test_logging.py b/server/test/controllers/api/v1/test_logging.py similarity index 100% rename from server/test/controllers/api/test_logging.py rename to server/test/controllers/api/v1/test_logging.py diff --git a/server/test/controllers/api/test_logs_viewer.py b/server/test/controllers/api/v1/test_logs_viewer.py similarity index 100% rename from server/test/controllers/api/test_logs_viewer.py rename to server/test/controllers/api/v1/test_logs_viewer.py diff --git a/server/test/controllers/api/test_rbac.py b/server/test/controllers/api/v1/test_rbac.py similarity index 100% rename from server/test/controllers/api/test_rbac.py rename to server/test/controllers/api/v1/test_rbac.py diff --git a/server/test/controllers/api/test_settings.py b/server/test/controllers/api/v1/test_settings.py similarity index 100% rename from server/test/controllers/api/test_settings.py rename to server/test/controllers/api/v1/test_settings.py diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/v1/test_system_info.py similarity index 100% rename from server/test/controllers/api/test_system_info.py rename to server/test/controllers/api/v1/test_system_info.py diff --git a/server/test/controllers/api/test_websocket.py b/server/test/controllers/api/v1/test_websocket.py similarity index 100% rename from server/test/controllers/api/test_websocket.py rename to server/test/controllers/api/v1/test_websocket.py diff --git a/server/test/controllers/api/v1/user/__init__.py b/server/test/controllers/api/v1/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/user/test_overrides.py b/server/test/controllers/api/v1/user/test_overrides.py similarity index 100% rename from server/test/controllers/api/user/test_overrides.py rename to server/test/controllers/api/v1/user/test_overrides.py diff --git a/server/test/controllers/api/v2/__init__.py b/server/test/controllers/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/test/controllers/api/v2/test_users.py b/server/test/controllers/api/v2/test_users.py new file mode 100644 index 00000000..decdbb72 --- /dev/null +++ b/server/test/controllers/api/v2/test_users.py @@ -0,0 +1,312 @@ +from sqlalchemy import select +from tornado import escape + +from models.user import User +from test.conftest import DigiScriptTestCase + + +class TestUsersV2Controller(DigiScriptTestCase): + """Tests for GET/POST/PATCH/DELETE /api/v2/users""" + + def _setup_admin(self, username="admin", password="adminpass"): + self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _create_user( + self, admin_token, username="user", password="userpass", is_admin=False + ): + resp = self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": is_admin} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + with self._app.get_db().sessionmaker() as session: + u = session.scalars(select(User).where(User.username == username)).first() + return u.id + + # ─── GET /api/v2/users ───────────────────────────────────────────────────── + + def test_get_users_returns_all_users(self): + admin_token = self._setup_admin() + self._create_user(admin_token, username="user1") + self._create_user(admin_token, username="user2") + + resp = self.fetch( + "/api/v2/users", + method="GET", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + body = escape.json_decode(resp.body) + self.assertIn("users", body) + usernames = [u["username"] for u in body["users"]] + self.assertIn("admin", usernames) + self.assertIn("user1", usernames) + self.assertIn("user2", usernames) + + def test_get_users_requires_admin(self): + admin_token = self._setup_admin() + self._create_user(admin_token, username="nonadmin") + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "nonadmin", "password": "userpass"}), + ) + nonadmin_token = escape.json_decode(resp.body)["access_token"] + + resp = self.fetch( + "/api/v2/users", + method="GET", + headers={"Authorization": f"Bearer {nonadmin_token}"}, + ) + self.assertEqual(401, resp.code) + + # ─── POST /api/v2/users ──────────────────────────────────────────────────── + + def test_post_create_user_success(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode( + {"username": "newuser", "password": "password", "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + self.assertEqual( + "Successfully created user", escape.json_decode(resp.body)["message"] + ) + + def test_post_first_user_must_be_admin(self): + resp = self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode( + {"username": "user", "password": "password", "is_admin": False} + ), + ) + self.assertEqual(400, resp.code) + self.assertEqual( + "First user must be an admin", escape.json_decode(resp.body)["message"] + ) + + def test_post_missing_username(self): + resp = self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode({"password": "password", "is_admin": True}), + ) + self.assertEqual(400, resp.code) + self.assertEqual("Username missing", escape.json_decode(resp.body)["message"]) + + def test_post_duplicate_username(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users", + method="POST", + body=escape.json_encode( + {"username": "admin", "password": "password", "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual( + "Username already taken", escape.json_decode(resp.body)["message"] + ) + + # ─── PATCH /api/v2/users?id={n} ─────────────────────────────────────────── + + def test_patch_toggle_admin_success(self): + admin_token = self._setup_admin() + second_admin_id = self._create_user( + admin_token, username="admin2", password="adminpass2", is_admin=True + ) + + resp = self.fetch( + f"/api/v2/users?id={second_admin_id}", + method="PATCH", + body=escape.json_encode({"is_admin": False}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + self.assertEqual( + "Successfully updated user", escape.json_decode(resp.body)["message"] + ) + + with self._app.get_db().sessionmaker() as session: + self.assertFalse(session.get(User, second_admin_id).is_admin) + + def test_patch_promote_user_to_admin(self): + admin_token = self._setup_admin() + user_id = self._create_user(admin_token) + + resp = self.fetch( + f"/api/v2/users?id={user_id}", + method="PATCH", + body=escape.json_encode({"is_admin": True}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + + with self._app.get_db().sessionmaker() as session: + self.assertTrue(session.get(User, user_id).is_admin) + + def test_patch_requires_admin(self): + admin_token = self._setup_admin() + user_id = self._create_user(admin_token) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "user", "password": "userpass"}), + ) + user_token = escape.json_decode(resp.body)["access_token"] + + resp = self.fetch( + f"/api/v2/users?id={user_id}", + method="PATCH", + body=escape.json_encode({"is_admin": True}), + headers={"Authorization": f"Bearer {user_token}"}, + ) + self.assertEqual(401, resp.code) + + def test_patch_missing_id(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users", + method="PATCH", + body=escape.json_encode({"is_admin": False}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual("Id missing", escape.json_decode(resp.body)["message"]) + + def test_patch_user_not_found(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users?id=99999", + method="PATCH", + body=escape.json_encode({"is_admin": False}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(404, resp.code) + self.assertEqual("User not found", escape.json_decode(resp.body)["message"]) + + def test_patch_self_edit_rejected(self): + admin_token = self._setup_admin() + with self._app.get_db().sessionmaker() as session: + admin_id = ( + session.scalars(select(User).where(User.username == "admin")).first().id + ) + + resp = self.fetch( + f"/api/v2/users?id={admin_id}", + method="PATCH", + body=escape.json_encode({"is_admin": False}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual( + "Cannot edit your own account", escape.json_decode(resp.body)["message"] + ) + + # note: the last-admin demotion guard (400 when target is the only admin) can only be + # triggered by a concurrent race condition — current_user["is_admin"] is always re-fetched + # from the DB on every request, so a synchronous test cannot reach that branch. + + # ─── DELETE /api/v2/users?id={n} ────────────────────────────────────────── + + def test_delete_user_success(self): + admin_token = self._setup_admin() + user_id = self._create_user(admin_token) + + resp = self.fetch( + f"/api/v2/users?id={user_id}", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(200, resp.code) + self.assertEqual( + "Successfully deleted user", escape.json_decode(resp.body)["message"] + ) + + with self._app.get_db().sessionmaker() as session: + self.assertIsNone(session.get(User, user_id)) + + def test_delete_requires_admin(self): + admin_token = self._setup_admin() + user_id = self._create_user(admin_token) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "user", "password": "userpass"}), + ) + user_token = escape.json_decode(resp.body)["access_token"] + + resp = self.fetch( + f"/api/v2/users?id={user_id}", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {user_token}"}, + ) + self.assertEqual(401, resp.code) + + def test_delete_missing_id(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual("Id missing", escape.json_decode(resp.body)["message"]) + + def test_delete_user_not_found(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users?id=99999", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(404, resp.code) + self.assertEqual("User not found", escape.json_decode(resp.body)["message"]) + + def test_delete_self_rejected(self): + admin_token = self._setup_admin() + with self._app.get_db().sessionmaker() as session: + admin_id = ( + session.scalars(select(User).where(User.username == "admin")).first().id + ) + + resp = self.fetch( + f"/api/v2/users?id={admin_id}", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual( + "Cannot delete currently authenticated user", + escape.json_decode(resp.body)["message"], + ) + + # note: the last-admin delete guard (400 when target is the only admin) suffers the same + # race-condition constraint as the PATCH guard — not reachable in a synchronous test. diff --git a/server/utils/web/route.py b/server/utils/web/route.py index 464b455d..d679be45 100644 --- a/server/utils/web/route.py +++ b/server/utils/web/route.py @@ -53,6 +53,7 @@ def make(cls, _name, **kwargs): class ApiVersion(Enum): V1 = 1 + V2 = 2 class ApiRoute(Route): From 811cecb764400767f36392a9a84eb11a9ef97bb0 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 13:19:34 +0100 Subject: [PATCH 5/5] Bump version to 0.31.0 --- client-v3/package-lock.json | 4 ++-- client-v3/package.json | 2 +- client/package-lock.json | 4 ++-- client/package.json | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- server/pyproject.toml | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 54e74601..a69f4afe 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "client-v3", - "version": "0.30.8", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client-v3", - "version": "0.30.8", + "version": "0.31.0", "dependencies": { "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", diff --git a/client-v3/package.json b/client-v3/package.json index 540895fb..878b13f4 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -1,6 +1,6 @@ { "name": "client-v3", - "version": "0.30.8", + "version": "0.31.0", "description": "DigiScript front end (Vue 3)", "author": "DreamTeamProd", "private": true, diff --git a/client/package-lock.json b/client/package-lock.json index 51744132..ed528a73 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.30.8", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.30.8", + "version": "0.31.0", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index f580b7f6..79ec494a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.30.8", + "version": "0.31.0", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, diff --git a/electron/package-lock.json b/electron/package-lock.json index 9e7de479..a585bcb5 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.30.8", + "version": "0.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.30.8", + "version": "0.31.0", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.4.0", diff --git a/electron/package.json b/electron/package.json index 54c1430f..bffa4162 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.30.8", + "version": "0.31.0", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/pyproject.toml b/server/pyproject.toml index 518e552b..f830a098 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.30.8" +version = "0.31.0" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13"