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):