From 3555c448736775e18e5db6018cd4cab902f7a324 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 11:48:20 +0100 Subject: [PATCH 1/4] Add V2 user API with proper REST semantics + edit user feature Introduces a versioned V2 API layer for user management, moving all existing controllers into an explicit `api/v1/` package and adding `api/v2/` with RESTful resource-oriented endpoints (GET/POST/PATCH/DELETE on `/api/v2/users`). Adds a new edit-user feature allowing admins to toggle user admin status, wired into both the Vue 2 and Vue 3 frontends with an extensible modal design (adding a new editable field requires only one line in EDITABLE_FIELDS and one form group in the template). Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/03-system-config.spec.ts | 43 +++ .../src/components/config/ConfigUsers.vue | 40 +++ .../src/components/user/ResetPassword.vue | 5 +- client-v3/src/js/http-interceptor.ts | 6 +- client-v3/src/stores/user.ts | 48 ++- client/src/js/http-interceptor.ts | 4 +- client/src/store/modules/user/user.ts | 45 ++- .../views/user/ForcePasswordChangeView.vue | 2 +- .../src/vue_components/config/ConfigUsers.vue | 38 ++- .../src/vue_components/user/ResetPassword.vue | 5 +- .../user/settings/ChangePassword.vue | 2 +- docs/pages/user_config.md | 8 + .../controllers/api/{auth => v1}/__init__.py | 0 .../api/{show => v1/auth}/__init__.py | 0 server/controllers/api/{ => v1}/auth/token.py | 0 server/controllers/api/{ => v1}/auth/user.py | 0 server/controllers/api/{ => v1}/db_backups.py | 0 server/controllers/api/{ => v1}/health.py | 0 server/controllers/api/{ => v1}/logging.py | 0 .../controllers/api/{ => v1}/logs_viewer.py | 0 server/controllers/api/{ => v1}/rbac.py | 0 server/controllers/api/{ => v1}/settings.py | 0 .../api/{show/script => v1/show}/__init__.py | 0 server/controllers/api/{ => v1}/show/acts.py | 0 server/controllers/api/{ => v1}/show/cast.py | 0 .../api/{ => v1}/show/characters.py | 0 server/controllers/api/{ => v1}/show/cues.py | 0 .../api/{ => v1}/show/microphones.py | 0 .../controllers/api/{ => v1}/show/scenes.py | 0 .../session => v1/show/script}/__init__.py | 0 .../api/{ => v1}/show/script/compiled.py | 0 .../api/{ => v1}/show/script/config.py | 0 .../api/{ => v1}/show/script/revisions.py | 0 .../api/{ => v1}/show/script/script.py | 0 .../show/script/stage_direction_styles.py | 0 .../stage => v1/show/session}/__init__.py | 0 .../api/{ => v1}/show/session/assign_tags.py | 0 .../api/{ => v1}/show/session/sessions.py | 0 .../api/{ => v1}/show/session/tags.py | 0 server/controllers/api/{ => v1}/show/shows.py | 0 .../api/{user => v1/show/stage}/__init__.py | 0 .../api/{ => v1}/show/stage/crew.py | 0 .../{ => v1}/show/stage/crew_assignments.py | 0 .../api/{ => v1}/show/stage/helpers.py | 0 .../api/{ => v1}/show/stage/props.py | 2 +- .../api/{ => v1}/show/stage/scenery.py | 2 +- .../controllers/api/{ => v1}/system_info.py | 0 server/controllers/api/v1/user/__init__.py | 0 .../api/{ => v1}/user/overrides.py | 0 .../controllers/api/{ => v1}/user/settings.py | 0 server/controllers/api/{ => v1}/version.py | 0 server/controllers/api/{ => v1}/websocket.py | 0 server/controllers/api/v2/__init__.py | 0 server/controllers/api/v2/auth/__init__.py | 0 server/controllers/api/v2/auth/user.py | 128 +++++++ server/controllers/api/v2/users/__init__.py | 0 server/controllers/api/v2/users/password.py | 126 +++++++ server/controllers/api/v2/users/token.py | 64 ++++ server/controllers/api/v2/users/users.py | 182 ++++++++++ server/test/controllers/api/v2/__init__.py | 0 server/test/controllers/api/v2/test_users.py | 312 ++++++++++++++++++ server/utils/web/route.py | 1 + 62 files changed, 1016 insertions(+), 47 deletions(-) rename server/controllers/api/{auth => v1}/__init__.py (100%) rename server/controllers/api/{show => v1/auth}/__init__.py (100%) rename server/controllers/api/{ => v1}/auth/token.py (100%) rename server/controllers/api/{ => v1}/auth/user.py (100%) rename server/controllers/api/{ => v1}/db_backups.py (100%) rename server/controllers/api/{ => v1}/health.py (100%) rename server/controllers/api/{ => v1}/logging.py (100%) rename server/controllers/api/{ => v1}/logs_viewer.py (100%) rename server/controllers/api/{ => v1}/rbac.py (100%) rename server/controllers/api/{ => v1}/settings.py (100%) rename server/controllers/api/{show/script => v1/show}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/acts.py (100%) rename server/controllers/api/{ => v1}/show/cast.py (100%) rename server/controllers/api/{ => v1}/show/characters.py (100%) rename server/controllers/api/{ => v1}/show/cues.py (100%) rename server/controllers/api/{ => v1}/show/microphones.py (100%) rename server/controllers/api/{ => v1}/show/scenes.py (100%) rename server/controllers/api/{show/session => v1/show/script}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/script/compiled.py (100%) rename server/controllers/api/{ => v1}/show/script/config.py (100%) rename server/controllers/api/{ => v1}/show/script/revisions.py (100%) rename server/controllers/api/{ => v1}/show/script/script.py (100%) rename server/controllers/api/{ => v1}/show/script/stage_direction_styles.py (100%) rename server/controllers/api/{show/stage => v1/show/session}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/session/assign_tags.py (100%) rename server/controllers/api/{ => v1}/show/session/sessions.py (100%) rename server/controllers/api/{ => v1}/show/session/tags.py (100%) rename server/controllers/api/{ => v1}/show/shows.py (100%) rename server/controllers/api/{user => v1/show/stage}/__init__.py (100%) rename server/controllers/api/{ => v1}/show/stage/crew.py (100%) rename server/controllers/api/{ => v1}/show/stage/crew_assignments.py (100%) rename server/controllers/api/{ => v1}/show/stage/helpers.py (100%) rename server/controllers/api/{ => v1}/show/stage/props.py (99%) rename server/controllers/api/{ => v1}/show/stage/scenery.py (99%) rename server/controllers/api/{ => v1}/system_info.py (100%) create mode 100644 server/controllers/api/v1/user/__init__.py rename server/controllers/api/{ => v1}/user/overrides.py (100%) rename server/controllers/api/{ => v1}/user/settings.py (100%) rename server/controllers/api/{ => v1}/version.py (100%) rename server/controllers/api/{ => v1}/websocket.py (100%) create mode 100644 server/controllers/api/v2/__init__.py create mode 100644 server/controllers/api/v2/auth/__init__.py create mode 100644 server/controllers/api/v2/auth/user.py create mode 100644 server/controllers/api/v2/users/__init__.py create mode 100644 server/controllers/api/v2/users/password.py create mode 100644 server/controllers/api/v2/users/token.py create mode 100644 server/controllers/api/v2/users/users.py create mode 100644 server/test/controllers/api/v2/__init__.py create mode 100644 server/test/controllers/api/v2/test_users.py diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 66b8cf2d..74b2b8fc 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -9,6 +9,7 @@ import { waitForAppReady, waitForModal, waitForModalClosed, + confirmModal, confirmDialog, } from '../helpers.js'; import { registerRetryHooks } from '../db-snapshot.js'; @@ -172,6 +173,48 @@ test('can configure RBAC permissions for testuser', async () => { await waitForModalClosed(page); }); +test('can edit a user to promote to admin', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + + // Verify the badge shows "User" before edit + await expect(userRow.locator('.badge:has-text("User")')).toBeVisible(); + + await userRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit User'); + + // Toggle the admin switch on + await page.locator('.modal.show .form-check-input').click(); + await expect(page.locator('.modal.show')).toContainText('Admin'); + + await confirmModal(page); + await waitForModalClosed(page); + + // Badge should now show "Admin" + await expect(userRow.locator('.badge:has-text("Admin")')).toBeVisible({ timeout: 5_000 }); +}); + +test('can edit a user to demote from admin', async () => { + const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') }); + + await userRow.locator('button:has-text("Edit")').click(); + await waitForModal(page, 'Edit User'); + + // Toggle the admin switch off + await page.locator('.modal.show .form-check-input').click(); + await expect(page.locator('.modal.show')).toContainText('Standard User'); + + await confirmModal(page); + await waitForModalClosed(page); + + // Badge should show "User" again + await expect(userRow.locator('.badge:has-text("User")')).toBeVisible({ timeout: 5_000 }); +}); + +test('edit button is disabled for the current user', async () => { + const adminRow = page.locator('tr', { has: page.locator('td:has-text("admin")') }); + await expect(adminRow.locator('button:has-text("Edit")')).toBeDisabled(); +}); + test('resets the non-admin user password', async () => { // Ensure testuser row is stable before interacting await expect(page.locator('td:has-text("testuser")')).toBeVisible({ timeout: 5_000 }); diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue index 0b1b10c9..08efc6a0 100644 --- a/client-v3/src/components/config/ConfigUsers.vue +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -32,6 +32,13 @@ > RBAC + + Edit + + + + + + {{ editFormState.is_admin ? 'Admin' : 'Standard User' }} + + + + + >(); const newAdminModal = ref>(); const rbacModal = ref>(); +const editUserModal = ref>(); const resetPasswordModal = ref>(); const selectedUserId = ref(null); const selectedUser = ref<{ id: number; username: string } | null>(null); +const editFormState = ref | null>(null); const userFields = [ 'username', @@ -132,6 +157,21 @@ function openResetPassword(user: { id: number; username: string }): void { resetPasswordModal.value?.show(); } +function openEditUser(user: Record): void { + editFormState.value = { ...user }; + editUserModal.value?.show(); +} + +function clearEditUser(): void { + editFormState.value = null; +} + +async function submitEditUser(): Promise { + if (editFormState.value) { + await userStore.editUser(editFormState.value as { id: number }); + } +} + async function deleteUser(item: { id: number; username: string }): Promise { const confirmed = await confirm(`Are you sure you want to delete ${item.username}?`, { title: 'Delete User', diff --git a/client-v3/src/components/user/ResetPassword.vue b/client-v3/src/components/user/ResetPassword.vue index deec1cfc..f23868d2 100644 --- a/client-v3/src/components/user/ResetPassword.vue +++ b/client-v3/src/components/user/ResetPassword.vue @@ -71,10 +71,11 @@ const showPassword = ref(false); async function handleReset(): Promise { loading.value = true; try { - const response = await fetch(makeURL('/api/v1/auth/reset-password'), { + const params = new URLSearchParams({ id: String(props.userId) }); + const response = await fetch(makeURL(`/api/v2/users/password/reset?${params}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: props.userId }), + body: JSON.stringify({}), }); if (response.ok) { const data = await response.json(); diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index 933c4ac2..de808cb6 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/v2/auth/logout'); + const isLoginRequest = resource.includes('/api/v2/auth/login'); + const isRefreshRequest = resource.includes('/api/v2/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..edd74721 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -41,7 +41,7 @@ export const useUserStore = defineStore('user', { async login(username: string, password: string): Promise { const wsStore = useWebSocketStore(); - const response = await fetch(makeURL('/api/v1/auth/login'), { + const response = await fetch(makeURL('/api/v2/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -99,7 +99,7 @@ export const useUserStore = defineStore('user', { if (token) { try { - const response = await fetch(makeURL('/api/v1/auth/logout'), { + const response = await fetch(makeURL('/api/v2/auth/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -125,7 +125,7 @@ export const useUserStore = defineStore('user', { async refreshToken(): Promise { if (!this.authToken) return false; try { - const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + const response = await fetch(makeURL('/api/v2/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -155,7 +155,7 @@ export const useUserStore = defineStore('user', { }, async getCurrentUser(): Promise { - const response = await fetch(makeURL('/api/v1/auth')); + const response = await fetch(makeURL('/api/v2/auth')); if (response.ok) { const user = await response.json(); this.currentUser = isEmpty(user) ? null : user; @@ -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(); @@ -221,7 +220,7 @@ export const useUserStore = defineStore('user', { async changePassword(newPassword: string, oldPassword?: string): Promise { const body: Record = { new_password: newPassword }; if (oldPassword) body.old_password = oldPassword; - const response = await fetch(makeURL('/api/v1/auth/change-password'), { + const response = await fetch(makeURL('/api/v2/users/password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -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..a97d8dd1 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/v2/auth/logout'); + const isRefreshRequest = resource.includes('/api/v2/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..6f18561e 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'); @@ -102,7 +101,7 @@ const module: Module = { } }, async USER_LOGIN(context, user: { username: string; password: string }) { - const response = await fetch(makeURL('/api/v1/auth/login'), { + const response = await fetch(makeURL('/api/v2/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -153,7 +152,7 @@ const module: Module = { await context.commit('CLEAR_WS_AUTHENTICATION'); if (token) { try { - const response = await fetch(makeURL('/api/v1/auth/logout'), { + const response = await fetch(makeURL('/api/v2/auth/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -176,7 +175,7 @@ const module: Module = { } }, async GET_CURRENT_USER(context) { - const response = await fetch(makeURL('/api/v1/auth')); + const response = await fetch(makeURL('/api/v2/auth')); if (response.ok) { const user = await response.json(); const userJson = isEmpty(user) ? null : user; @@ -187,7 +186,7 @@ const module: Module = { }, async REFRESH_TOKEN(context) { if (!context.getters.AUTH_TOKEN) return false; - const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + const response = await fetch(makeURL('/api/v2/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -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/views/user/ForcePasswordChangeView.vue b/client/src/views/user/ForcePasswordChangeView.vue index 425921bc..3c2bf034 100644 --- a/client/src/views/user/ForcePasswordChangeView.vue +++ b/client/src/views/user/ForcePasswordChangeView.vue @@ -116,7 +116,7 @@ export default defineComponent({ this.loading = true; try { - const response = await fetch(makeURL('/api/v1/auth/change-password'), { + const response = await fetch(makeURL('/api/v2/users/password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_password: this.state.newPassword }), 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/client/src/vue_components/user/ResetPassword.vue b/client/src/vue_components/user/ResetPassword.vue index 46b1336c..b2532c62 100644 --- a/client/src/vue_components/user/ResetPassword.vue +++ b/client/src/vue_components/user/ResetPassword.vue @@ -87,10 +87,11 @@ export default defineComponent({ this.loading = true; try { - const response = await fetch(makeURL('/api/v1/auth/reset-password'), { + const params = new URLSearchParams({ id: String(this.userId) }); + const response = await fetch(makeURL(`/api/v2/users/password/reset?${params}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: this.userId }), + body: JSON.stringify({}), }); if (response.ok) { diff --git a/client/src/vue_components/user/settings/ChangePassword.vue b/client/src/vue_components/user/settings/ChangePassword.vue index 2e755ee8..421b9f75 100644 --- a/client/src/vue_components/user/settings/ChangePassword.vue +++ b/client/src/vue_components/user/settings/ChangePassword.vue @@ -113,7 +113,7 @@ export default defineComponent({ this.loading = true; try { - const response = await fetch(makeURL('/api/v1/auth/change-password'), { + const response = await fetch(makeURL('/api/v2/users/password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json', 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/controllers/api/v1/user/__init__.py b/server/controllers/api/v1/user/__init__.py new file mode 100644 index 00000000..e69de29b 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/controllers/api/v2/__init__.py b/server/controllers/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controllers/api/v2/auth/__init__.py b/server/controllers/api/v2/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controllers/api/v2/auth/user.py b/server/controllers/api/v2/auth/user.py new file mode 100644 index 00000000..d79a9fb2 --- /dev/null +++ b/server/controllers/api/v2/auth/user.py @@ -0,0 +1,128 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from tornado import escape + +from models.session import Session +from models.user import User +from registry.named_locks import NamedLockRegistry +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 ( + allow_when_password_required, + api_authenticated, + redact_data_paths, +) + + +@ApiRoute("auth", ApiVersion.V2) +class AuthV2Controller(BaseAPIController): + @allow_when_password_required + def get(self): + self.set_status(200) + self.finish(self.current_user if self.current_user else {}) + + +@ApiRoute("auth/login", ApiVersion.V2) +class AuthLoginV2Controller(BaseAPIController): + @redact_data_paths(paths=["/password"]) + async def post(self): + 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 + + with self.make_session() as session: + async with NamedLockRegistry.acquire(f"UserLock::{username}"): + user = session.scalars( + select(User).where(User.username == username) + ).first() + if not user: + self.set_status(401) + await self.finish({"message": "Invalid username/password"}) + return + + password_equal = await PasswordService.verify_password( + password, user.password + ) + + if password_equal: + session_id = data.get("session_id", "") + if session_id: + ws_session: Session = session.get(Session, session_id) + if ws_session: + ws_session.user = user + user.last_login = datetime.now(tz=timezone.utc) + user.last_seen = datetime.now(tz=timezone.utc) + session.commit() + + access_token = self.application.jwt_service.create_access_token( + data={"user_id": user.id} + ) + + self.set_status(200) + await self.finish( + { + "message": "Successful log in", + "access_token": access_token, + "token_type": "bearer", + } + ) + else: + self.set_status(401) + await self.finish({"message": "Invalid username/password"}) + + +@ApiRoute("auth/logout", ApiVersion.V2) +class AuthLogoutV2Controller(BaseAPIController): + @api_authenticated + @allow_when_password_required + async def post(self): + data = escape.json_decode(self.request.body) + + if self.current_user: + session_id = data.get("session_id", "") + if session_id: + with self.make_session() as session: + ws_session: Session = session.get(Session, session_id) + if ws_session: + ws_session.user = None + session.commit() + + ws_controller = self.application.get_ws(session_id) + if ws_controller and hasattr(ws_controller, "current_user_id"): + ws_controller.current_user_id = None + + auth_header = self.request.headers.get("Authorization", "") + token = self.application.jwt_service.get_token_from_authorization_header( + auth_header + ) + await self.application.jwt_service.revoke_token(token) + + self.set_status(200) + await self.finish({"message": "Successfully logged out"}) + else: + self.set_status(401) + await self.finish({"message": "No user logged in"}) + + +@ApiRoute("auth/refresh-token", ApiVersion.V2) +class AuthRefreshTokenV2Controller(BaseAPIController): + @api_authenticated + async def post(self): + access_token = self.application.jwt_service.create_access_token( + data={"user_id": self.current_user["id"]} + ) + + self.set_status(200) + await self.finish({"access_token": access_token, "token_type": "bearer"}) diff --git a/server/controllers/api/v2/users/__init__.py b/server/controllers/api/v2/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controllers/api/v2/users/password.py b/server/controllers/api/v2/users/password.py new file mode 100644 index 00000000..d913f7dc --- /dev/null +++ b/server/controllers/api/v2/users/password.py @@ -0,0 +1,126 @@ +from tornado import escape + +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 ( + allow_when_password_required, + api_authenticated, + redact_data_paths, + require_admin, +) + + +@ApiRoute("users/password", ApiVersion.V2) +class UsersPasswordV2Controller(BaseAPIController): + @api_authenticated + @allow_when_password_required + @redact_data_paths(paths=["/old_password", "/new_password"]) + async def patch(self): + data = escape.json_decode(self.request.body) + old_password = data.get("old_password", "") + new_password = data.get("new_password", "") + + if not new_password: + self.set_status(400) + await self.finish({"message": "New password is required"}) + return + + 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.requires_password_change: + if not old_password: + self.set_status(400) + await self.finish({"message": "Old password is required"}) + return + + password_matches = await PasswordService.verify_password( + old_password, user.password + ) + if not password_matches: + self.set_status(401) + await self.finish({"message": "Current password is incorrect"}) + return + + try: + await self.application.user_service.change_password( + session, + user, + new_password, + invalidate_tokens=True, + force_logout_sessions=False, + ) + except ValueError as e: + self.set_status(400) + await self.finish({"message": str(e)}) + return + + new_token = self.application.jwt_service.create_access_token( + data={"user_id": user.id} + ) + await self.application.user_service.refresh_token_all_sessions( + user, new_token + ) + + self.set_status(200) + await self.finish( + { + "message": "Password changed successfully", + "access_token": new_token, + "token_type": "bearer", + } + ) + + +@ApiRoute("users/password/reset", ApiVersion.V2) +class UsersPasswordResetV2Controller(BaseAPIController): + @api_authenticated + @require_admin + async def post(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: + target_user = session.get(User, int(user_id)) + if not target_user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if target_user.id == self.current_user["id"]: + self.set_status(400) + await self.finish( + {"message": "Cannot reset your own password via admin endpoint"} + ) + return + + temp_password = PasswordService.generate_temporary_password() + + try: + await self.application.user_service.change_password( + session, target_user, temp_password, invalidate_tokens=True + ) + except ValueError as e: + self.set_status(400) + await self.finish({"message": str(e)}) + return + + target_user.requires_password_change = True + session.commit() + + self.set_status(200) + await self.finish( + { + "message": "Password reset successfully", + "temporary_password": temp_password, + } + ) 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/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..e07248e6 --- /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/v2/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/v2/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/v2/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/v2/auth/login", + method="POST", + body=escape.json_encode({"username": "user", "password": "userpass"}), + ) + user_token = escape.json_decode(resp.body)["access_token"] + + resp = self.fetch( + f"/api/v2/users?id={user_id}", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {user_token}"}, + ) + self.assertEqual(401, resp.code) + + def test_delete_missing_id(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual("Id missing", escape.json_decode(resp.body)["message"]) + + def test_delete_user_not_found(self): + admin_token = self._setup_admin() + resp = self.fetch( + "/api/v2/users?id=99999", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(404, resp.code) + self.assertEqual("User not found", escape.json_decode(resp.body)["message"]) + + def test_delete_self_rejected(self): + admin_token = self._setup_admin() + with self._app.get_db().sessionmaker() as session: + admin_id = ( + session.scalars(select(User).where(User.username == "admin")).first().id + ) + + resp = self.fetch( + f"/api/v2/users?id={admin_id}", + method="DELETE", + allow_nonstandard_methods=True, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + self.assertEqual(400, resp.code) + self.assertEqual( + "Cannot delete currently authenticated user", + escape.json_decode(resp.body)["message"], + ) + + # note: the last-admin delete guard (400 when target is the only admin) suffers the same + # race-condition constraint as the PATCH guard — not reachable in a synchronous test. diff --git a/server/utils/web/route.py b/server/utils/web/route.py index 464b455d..d679be45 100644 --- a/server/utils/web/route.py +++ b/server/utils/web/route.py @@ -53,6 +53,7 @@ def make(cls, _name, **kwargs): class ApiVersion(Enum): V1 = 1 + V2 = 2 class ApiRoute(Route): From a2a6b59ed547fbf7c65e271a037f153e6b72bb13 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 11:53:23 +0100 Subject: [PATCH 2/4] Restructure test package to mirror controllers/api/ hierarchy Moves all V1 controller tests from test/controllers/api/ into test/controllers/api/v1/ (with auth/ subpackage), mirroring the structure introduced when V1 controllers moved to controllers/api/v1/. The existing test/controllers/api/v2/ tests remain in place. Co-Authored-By: Claude Sonnet 4.6 --- server/test/controllers/api/{show => v1}/__init__.py | 0 server/test/controllers/api/{show/script => v1/auth}/__init__.py | 0 server/test/controllers/api/{ => v1/auth}/test_auth.py | 0 server/test/controllers/api/{show/session => v1/show}/__init__.py | 0 .../controllers/api/{show/stage => v1/show/script}/__init__.py | 0 .../controllers/api/{ => v1}/show/script/test_compiled_scripts.py | 0 server/test/controllers/api/{ => v1}/show/script/test_config.py | 0 .../controllers/api/{ => v1}/show/script/test_orphan_deletion.py | 0 .../api/{ => v1}/show/script/test_revision_fk_constraints.py | 0 .../test/controllers/api/{ => v1}/show/script/test_revisions.py | 0 server/test/controllers/api/{ => v1}/show/script/test_script.py | 0 .../api/{ => v1}/show/script/test_stage_direction_styles.py | 0 server/test/controllers/api/{user => v1/show/session}/__init__.py | 0 .../controllers/api/{ => v1}/show/session/test_assign_tags.py | 0 .../test/controllers/api/{ => v1}/show/session/test_sessions.py | 0 server/test/controllers/api/{ => v1}/show/session/test_tags.py | 0 server/test/controllers/api/v1/show/stage/__init__.py | 0 server/test/controllers/api/{ => v1}/show/stage/test_crew.py | 0 .../controllers/api/{ => v1}/show/stage/test_crew_assignments.py | 0 server/test/controllers/api/{ => v1}/show/stage/test_props.py | 0 server/test/controllers/api/{ => v1}/show/stage/test_scenery.py | 0 server/test/controllers/api/{ => v1}/show/test_acts.py | 0 server/test/controllers/api/{ => v1}/show/test_cast.py | 0 server/test/controllers/api/{ => v1}/show/test_characters.py | 0 server/test/controllers/api/{ => v1}/show/test_cues.py | 0 server/test/controllers/api/{ => v1}/show/test_microphones.py | 0 server/test/controllers/api/{ => v1}/show/test_scenes.py | 0 server/test/controllers/api/{ => v1}/show/test_shows.py | 0 server/test/controllers/api/{ => v1}/test_db_backups.py | 0 server/test/controllers/api/{ => v1}/test_health.py | 0 server/test/controllers/api/{ => v1}/test_logging.py | 0 server/test/controllers/api/{ => v1}/test_logs_viewer.py | 0 server/test/controllers/api/{ => v1}/test_rbac.py | 0 server/test/controllers/api/{ => v1}/test_settings.py | 0 server/test/controllers/api/{ => v1}/test_system_info.py | 0 server/test/controllers/api/{ => v1}/test_websocket.py | 0 server/test/controllers/api/v1/user/__init__.py | 0 server/test/controllers/api/{ => v1}/user/test_overrides.py | 0 38 files changed, 0 insertions(+), 0 deletions(-) rename server/test/controllers/api/{show => v1}/__init__.py (100%) rename server/test/controllers/api/{show/script => v1/auth}/__init__.py (100%) rename server/test/controllers/api/{ => v1/auth}/test_auth.py (100%) rename server/test/controllers/api/{show/session => v1/show}/__init__.py (100%) rename server/test/controllers/api/{show/stage => v1/show/script}/__init__.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_compiled_scripts.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_config.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_orphan_deletion.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_revision_fk_constraints.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_revisions.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_script.py (100%) rename server/test/controllers/api/{ => v1}/show/script/test_stage_direction_styles.py (100%) rename server/test/controllers/api/{user => v1/show/session}/__init__.py (100%) rename server/test/controllers/api/{ => v1}/show/session/test_assign_tags.py (100%) rename server/test/controllers/api/{ => v1}/show/session/test_sessions.py (100%) rename server/test/controllers/api/{ => v1}/show/session/test_tags.py (100%) create mode 100644 server/test/controllers/api/v1/show/stage/__init__.py rename server/test/controllers/api/{ => v1}/show/stage/test_crew.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_crew_assignments.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_props.py (100%) rename server/test/controllers/api/{ => v1}/show/stage/test_scenery.py (100%) rename server/test/controllers/api/{ => v1}/show/test_acts.py (100%) rename server/test/controllers/api/{ => v1}/show/test_cast.py (100%) rename server/test/controllers/api/{ => v1}/show/test_characters.py (100%) rename server/test/controllers/api/{ => v1}/show/test_cues.py (100%) rename server/test/controllers/api/{ => v1}/show/test_microphones.py (100%) rename server/test/controllers/api/{ => v1}/show/test_scenes.py (100%) rename server/test/controllers/api/{ => v1}/show/test_shows.py (100%) rename server/test/controllers/api/{ => v1}/test_db_backups.py (100%) rename server/test/controllers/api/{ => v1}/test_health.py (100%) rename server/test/controllers/api/{ => v1}/test_logging.py (100%) rename server/test/controllers/api/{ => v1}/test_logs_viewer.py (100%) rename server/test/controllers/api/{ => v1}/test_rbac.py (100%) rename server/test/controllers/api/{ => v1}/test_settings.py (100%) rename server/test/controllers/api/{ => v1}/test_system_info.py (100%) rename server/test/controllers/api/{ => v1}/test_websocket.py (100%) create mode 100644 server/test/controllers/api/v1/user/__init__.py rename server/test/controllers/api/{ => v1}/user/test_overrides.py (100%) diff --git a/server/test/controllers/api/show/__init__.py b/server/test/controllers/api/v1/__init__.py similarity index 100% rename from server/test/controllers/api/show/__init__.py rename to server/test/controllers/api/v1/__init__.py diff --git a/server/test/controllers/api/show/script/__init__.py b/server/test/controllers/api/v1/auth/__init__.py similarity index 100% rename from server/test/controllers/api/show/script/__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/show/session/__init__.py b/server/test/controllers/api/v1/show/__init__.py similarity index 100% rename from server/test/controllers/api/show/session/__init__.py rename to server/test/controllers/api/v1/show/__init__.py diff --git a/server/test/controllers/api/show/stage/__init__.py b/server/test/controllers/api/v1/show/script/__init__.py similarity index 100% rename from server/test/controllers/api/show/stage/__init__.py rename to server/test/controllers/api/v1/show/script/__init__.py 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/user/__init__.py b/server/test/controllers/api/v1/show/session/__init__.py similarity index 100% rename from server/test/controllers/api/user/__init__.py rename to server/test/controllers/api/v1/show/session/__init__.py 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 From 288450bf1d099c4de5b1bf8f461a879c20d25c72 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 12:17:23 +0100 Subject: [PATCH 3/4] Remove unnecessary V2 auth controllers; revert frontends to V1 auth The V1 auth endpoints (login, logout, refresh-token, get current user) already used correct HTTP verbs and needed no refactoring. The V2 duplicates added no value and only increased maintenance burden. V2 remains exclusively for user management endpoints where V1 violated REST conventions. Co-Authored-By: Claude Sonnet 4.6 --- client-v3/src/js/http-interceptor.ts | 6 +- client-v3/src/stores/user.ts | 8 +- client/src/js/http-interceptor.ts | 4 +- client/src/store/modules/user/user.ts | 8 +- server/controllers/api/v2/auth/__init__.py | 0 server/controllers/api/v2/auth/user.py | 128 ------------------- server/test/controllers/api/v2/test_users.py | 8 +- 7 files changed, 17 insertions(+), 145 deletions(-) delete mode 100644 server/controllers/api/v2/auth/__init__.py delete mode 100644 server/controllers/api/v2/auth/user.py diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index de808cb6..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.includes('/api/v2/auth/logout'); - const isLoginRequest = resource.includes('/api/v2/auth/login'); - const isRefreshRequest = resource.includes('/api/v2/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 edd74721..784491fc 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -41,7 +41,7 @@ export const useUserStore = defineStore('user', { async login(username: string, password: string): Promise { const wsStore = useWebSocketStore(); - const response = await fetch(makeURL('/api/v2/auth/login'), { + const response = await fetch(makeURL('/api/v1/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -99,7 +99,7 @@ export const useUserStore = defineStore('user', { if (token) { try { - const response = await fetch(makeURL('/api/v2/auth/logout'), { + const response = await fetch(makeURL('/api/v1/auth/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -125,7 +125,7 @@ export const useUserStore = defineStore('user', { async refreshToken(): Promise { if (!this.authToken) return false; try { - const response = await fetch(makeURL('/api/v2/auth/refresh-token'), { + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -155,7 +155,7 @@ export const useUserStore = defineStore('user', { }, async getCurrentUser(): Promise { - const response = await fetch(makeURL('/api/v2/auth')); + const response = await fetch(makeURL('/api/v1/auth')); if (response.ok) { const user = await response.json(); this.currentUser = isEmpty(user) ? null : user; diff --git a/client/src/js/http-interceptor.ts b/client/src/js/http-interceptor.ts index a97d8dd1..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.includes('/api/v2/auth/logout'); - const isRefreshRequest = resource.includes('/api/v2/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 6f18561e..2a6ac182 100644 --- a/client/src/store/modules/user/user.ts +++ b/client/src/store/modules/user/user.ts @@ -101,7 +101,7 @@ const module: Module = { } }, async USER_LOGIN(context, user: { username: string; password: string }) { - const response = await fetch(makeURL('/api/v2/auth/login'), { + const response = await fetch(makeURL('/api/v1/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -152,7 +152,7 @@ const module: Module = { await context.commit('CLEAR_WS_AUTHENTICATION'); if (token) { try { - const response = await fetch(makeURL('/api/v2/auth/logout'), { + const response = await fetch(makeURL('/api/v1/auth/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -175,7 +175,7 @@ const module: Module = { } }, async GET_CURRENT_USER(context) { - const response = await fetch(makeURL('/api/v2/auth')); + const response = await fetch(makeURL('/api/v1/auth')); if (response.ok) { const user = await response.json(); const userJson = isEmpty(user) ? null : user; @@ -186,7 +186,7 @@ const module: Module = { }, async REFRESH_TOKEN(context) { if (!context.getters.AUTH_TOKEN) return false; - const response = await fetch(makeURL('/api/v2/auth/refresh-token'), { + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), diff --git a/server/controllers/api/v2/auth/__init__.py b/server/controllers/api/v2/auth/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/server/controllers/api/v2/auth/user.py b/server/controllers/api/v2/auth/user.py deleted file mode 100644 index d79a9fb2..00000000 --- a/server/controllers/api/v2/auth/user.py +++ /dev/null @@ -1,128 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy import select -from tornado import escape - -from models.session import Session -from models.user import User -from registry.named_locks import NamedLockRegistry -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 ( - allow_when_password_required, - api_authenticated, - redact_data_paths, -) - - -@ApiRoute("auth", ApiVersion.V2) -class AuthV2Controller(BaseAPIController): - @allow_when_password_required - def get(self): - self.set_status(200) - self.finish(self.current_user if self.current_user else {}) - - -@ApiRoute("auth/login", ApiVersion.V2) -class AuthLoginV2Controller(BaseAPIController): - @redact_data_paths(paths=["/password"]) - async def post(self): - 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 - - with self.make_session() as session: - async with NamedLockRegistry.acquire(f"UserLock::{username}"): - user = session.scalars( - select(User).where(User.username == username) - ).first() - if not user: - self.set_status(401) - await self.finish({"message": "Invalid username/password"}) - return - - password_equal = await PasswordService.verify_password( - password, user.password - ) - - if password_equal: - session_id = data.get("session_id", "") - if session_id: - ws_session: Session = session.get(Session, session_id) - if ws_session: - ws_session.user = user - user.last_login = datetime.now(tz=timezone.utc) - user.last_seen = datetime.now(tz=timezone.utc) - session.commit() - - access_token = self.application.jwt_service.create_access_token( - data={"user_id": user.id} - ) - - self.set_status(200) - await self.finish( - { - "message": "Successful log in", - "access_token": access_token, - "token_type": "bearer", - } - ) - else: - self.set_status(401) - await self.finish({"message": "Invalid username/password"}) - - -@ApiRoute("auth/logout", ApiVersion.V2) -class AuthLogoutV2Controller(BaseAPIController): - @api_authenticated - @allow_when_password_required - async def post(self): - data = escape.json_decode(self.request.body) - - if self.current_user: - session_id = data.get("session_id", "") - if session_id: - with self.make_session() as session: - ws_session: Session = session.get(Session, session_id) - if ws_session: - ws_session.user = None - session.commit() - - ws_controller = self.application.get_ws(session_id) - if ws_controller and hasattr(ws_controller, "current_user_id"): - ws_controller.current_user_id = None - - auth_header = self.request.headers.get("Authorization", "") - token = self.application.jwt_service.get_token_from_authorization_header( - auth_header - ) - await self.application.jwt_service.revoke_token(token) - - self.set_status(200) - await self.finish({"message": "Successfully logged out"}) - else: - self.set_status(401) - await self.finish({"message": "No user logged in"}) - - -@ApiRoute("auth/refresh-token", ApiVersion.V2) -class AuthRefreshTokenV2Controller(BaseAPIController): - @api_authenticated - async def post(self): - access_token = self.application.jwt_service.create_access_token( - data={"user_id": self.current_user["id"]} - ) - - self.set_status(200) - await self.finish({"access_token": access_token, "token_type": "bearer"}) diff --git a/server/test/controllers/api/v2/test_users.py b/server/test/controllers/api/v2/test_users.py index e07248e6..decdbb72 100644 --- a/server/test/controllers/api/v2/test_users.py +++ b/server/test/controllers/api/v2/test_users.py @@ -17,7 +17,7 @@ def _setup_admin(self, username="admin", password="adminpass"): ), ) resp = self.fetch( - "/api/v2/auth/login", + "/api/v1/auth/login", method="POST", body=escape.json_encode({"username": username, "password": password}), ) @@ -63,7 +63,7 @@ def test_get_users_requires_admin(self): admin_token = self._setup_admin() self._create_user(admin_token, username="nonadmin") resp = self.fetch( - "/api/v2/auth/login", + "/api/v1/auth/login", method="POST", body=escape.json_encode({"username": "nonadmin", "password": "userpass"}), ) @@ -171,7 +171,7 @@ def test_patch_requires_admin(self): admin_token = self._setup_admin() user_id = self._create_user(admin_token) resp = self.fetch( - "/api/v2/auth/login", + "/api/v1/auth/login", method="POST", body=escape.json_encode({"username": "user", "password": "userpass"}), ) @@ -253,7 +253,7 @@ def test_delete_requires_admin(self): admin_token = self._setup_admin() user_id = self._create_user(admin_token) resp = self.fetch( - "/api/v2/auth/login", + "/api/v1/auth/login", method="POST", body=escape.json_encode({"username": "user", "password": "userpass"}), ) From ce59b2ba7de222ce023debbac046815f53acb1ad Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 7 Jun 2026 12:50:53 +0100 Subject: [PATCH 4/4] Remove V2 password controllers; revert frontends to V1 password endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Password change and reset endpoints already used correct HTTP verbs in V1 (PATCH and POST respectively) and contained no logic changes in V2 — only a URL namespace move. Keeping only the V2 token controller where the POST /revoke → DELETE fix was a genuine REST improvement. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/user/ResetPassword.vue | 5 +- client-v3/src/stores/user.ts | 2 +- .../views/user/ForcePasswordChangeView.vue | 2 +- .../src/vue_components/user/ResetPassword.vue | 5 +- .../user/settings/ChangePassword.vue | 2 +- server/controllers/api/v2/users/password.py | 126 ------------------ 6 files changed, 7 insertions(+), 135 deletions(-) delete mode 100644 server/controllers/api/v2/users/password.py diff --git a/client-v3/src/components/user/ResetPassword.vue b/client-v3/src/components/user/ResetPassword.vue index f23868d2..deec1cfc 100644 --- a/client-v3/src/components/user/ResetPassword.vue +++ b/client-v3/src/components/user/ResetPassword.vue @@ -71,11 +71,10 @@ const showPassword = ref(false); async function handleReset(): Promise { loading.value = true; try { - const params = new URLSearchParams({ id: String(props.userId) }); - const response = await fetch(makeURL(`/api/v2/users/password/reset?${params}`), { + const response = await fetch(makeURL('/api/v1/auth/reset-password'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ user_id: props.userId }), }); if (response.ok) { const data = await response.json(); diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index 784491fc..ecf925e4 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -220,7 +220,7 @@ export const useUserStore = defineStore('user', { async changePassword(newPassword: string, oldPassword?: string): Promise { const body: Record = { new_password: newPassword }; if (oldPassword) body.old_password = oldPassword; - const response = await fetch(makeURL('/api/v2/users/password'), { + const response = await fetch(makeURL('/api/v1/auth/change-password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/client/src/views/user/ForcePasswordChangeView.vue b/client/src/views/user/ForcePasswordChangeView.vue index 3c2bf034..425921bc 100644 --- a/client/src/views/user/ForcePasswordChangeView.vue +++ b/client/src/views/user/ForcePasswordChangeView.vue @@ -116,7 +116,7 @@ export default defineComponent({ this.loading = true; try { - const response = await fetch(makeURL('/api/v2/users/password'), { + const response = await fetch(makeURL('/api/v1/auth/change-password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_password: this.state.newPassword }), diff --git a/client/src/vue_components/user/ResetPassword.vue b/client/src/vue_components/user/ResetPassword.vue index b2532c62..46b1336c 100644 --- a/client/src/vue_components/user/ResetPassword.vue +++ b/client/src/vue_components/user/ResetPassword.vue @@ -87,11 +87,10 @@ export default defineComponent({ this.loading = true; try { - const params = new URLSearchParams({ id: String(this.userId) }); - const response = await fetch(makeURL(`/api/v2/users/password/reset?${params}`), { + const response = await fetch(makeURL('/api/v1/auth/reset-password'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ user_id: this.userId }), }); if (response.ok) { diff --git a/client/src/vue_components/user/settings/ChangePassword.vue b/client/src/vue_components/user/settings/ChangePassword.vue index 421b9f75..2e755ee8 100644 --- a/client/src/vue_components/user/settings/ChangePassword.vue +++ b/client/src/vue_components/user/settings/ChangePassword.vue @@ -113,7 +113,7 @@ export default defineComponent({ this.loading = true; try { - const response = await fetch(makeURL('/api/v2/users/password'), { + const response = await fetch(makeURL('/api/v1/auth/change-password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json', diff --git a/server/controllers/api/v2/users/password.py b/server/controllers/api/v2/users/password.py deleted file mode 100644 index d913f7dc..00000000 --- a/server/controllers/api/v2/users/password.py +++ /dev/null @@ -1,126 +0,0 @@ -from tornado import escape - -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 ( - allow_when_password_required, - api_authenticated, - redact_data_paths, - require_admin, -) - - -@ApiRoute("users/password", ApiVersion.V2) -class UsersPasswordV2Controller(BaseAPIController): - @api_authenticated - @allow_when_password_required - @redact_data_paths(paths=["/old_password", "/new_password"]) - async def patch(self): - data = escape.json_decode(self.request.body) - old_password = data.get("old_password", "") - new_password = data.get("new_password", "") - - if not new_password: - self.set_status(400) - await self.finish({"message": "New password is required"}) - return - - 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.requires_password_change: - if not old_password: - self.set_status(400) - await self.finish({"message": "Old password is required"}) - return - - password_matches = await PasswordService.verify_password( - old_password, user.password - ) - if not password_matches: - self.set_status(401) - await self.finish({"message": "Current password is incorrect"}) - return - - try: - await self.application.user_service.change_password( - session, - user, - new_password, - invalidate_tokens=True, - force_logout_sessions=False, - ) - except ValueError as e: - self.set_status(400) - await self.finish({"message": str(e)}) - return - - new_token = self.application.jwt_service.create_access_token( - data={"user_id": user.id} - ) - await self.application.user_service.refresh_token_all_sessions( - user, new_token - ) - - self.set_status(200) - await self.finish( - { - "message": "Password changed successfully", - "access_token": new_token, - "token_type": "bearer", - } - ) - - -@ApiRoute("users/password/reset", ApiVersion.V2) -class UsersPasswordResetV2Controller(BaseAPIController): - @api_authenticated - @require_admin - async def post(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: - target_user = session.get(User, int(user_id)) - if not target_user: - self.set_status(404) - await self.finish({"message": "User not found"}) - return - - if target_user.id == self.current_user["id"]: - self.set_status(400) - await self.finish( - {"message": "Cannot reset your own password via admin endpoint"} - ) - return - - temp_password = PasswordService.generate_temporary_password() - - try: - await self.application.user_service.change_password( - session, target_user, temp_password, invalidate_tokens=True - ) - except ValueError as e: - self.set_status(400) - await self.finish({"message": str(e)}) - return - - target_user.requires_password_change = True - session.commit() - - self.set_status(200) - await self.finish( - { - "message": "Password reset successfully", - "temporary_password": temp_password, - } - )