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/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-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-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue index 62b3fa83..08efc6a0 100644 --- a/client-v3/src/components/config/ConfigUsers.vue +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -10,6 +10,9 @@ + @@ -29,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', + 'created_on', 'last_login', 'last_seen', { key: 'is_admin', label: 'User Type' }, @@ -128,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-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/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/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/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/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/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue index c045c580..aa086957 100644 --- a/client/src/vue_components/config/ConfigUsers.vue +++ b/client/src/vue_components/config/ConfigUsers.vue @@ -10,6 +10,9 @@ + @@ -30,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, }; @@ -138,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 32b23a5b..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: @@ -68,6 +76,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/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/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/__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 99% rename from server/controllers/api/auth/user.py rename to server/controllers/api/v1/auth/user.py index d7248d23..4e3be9bf 100644 --- a/server/controllers/api/auth/user.py +++ b/server/controllers/api/v1/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/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 96% rename from server/controllers/api/logs_viewer.py rename to server/controllers/api/v1/logs_viewer.py index dcd63e0a..e1da0584 100644 --- a/server/controllers/api/logs_viewer.py +++ b/server/controllers/api/v1/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/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 98% rename from server/controllers/api/show/cues.py rename to server/controllers/api/v1/show/cues.py index 2dca6fb2..3bc28afe 100644 --- a/server/controllers/api/show/cues.py +++ b/server/controllers/api/v1/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/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/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/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) 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" 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 92% rename from server/test/controllers/api/show/test_cues.py rename to server/test/controllers/api/v1/show/test_cues.py index be510b48..6b9412c1 100644 --- a/server/test/controllers/api/show/test_cues.py +++ b/server/test/controllers/api/v1/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 + ) + ) 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 93% rename from server/test/controllers/api/test_logs_viewer.py rename to server/test/controllers/api/v1/test_logs_viewer.py index 05586ffb..974aa6f8 100644 --- a/server/test/controllers/api/test_logs_viewer.py +++ b/server/test/controllers/api/v1/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/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/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() 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):