diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts
index 66b8cf2d..74b2b8fc 100644
--- a/client-v3/e2e/tests/03-system-config.spec.ts
+++ b/client-v3/e2e/tests/03-system-config.spec.ts
@@ -9,6 +9,7 @@ import {
waitForAppReady,
waitForModal,
waitForModalClosed,
+ confirmModal,
confirmDialog,
} from '../helpers.js';
import { registerRetryHooks } from '../db-snapshot.js';
@@ -172,6 +173,48 @@ test('can configure RBAC permissions for testuser', async () => {
await waitForModalClosed(page);
});
+test('can edit a user to promote to admin', async () => {
+ const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') });
+
+ // Verify the badge shows "User" before edit
+ await expect(userRow.locator('.badge:has-text("User")')).toBeVisible();
+
+ await userRow.locator('button:has-text("Edit")').click();
+ await waitForModal(page, 'Edit User');
+
+ // Toggle the admin switch on
+ await page.locator('.modal.show .form-check-input').click();
+ await expect(page.locator('.modal.show')).toContainText('Admin');
+
+ await confirmModal(page);
+ await waitForModalClosed(page);
+
+ // Badge should now show "Admin"
+ await expect(userRow.locator('.badge:has-text("Admin")')).toBeVisible({ timeout: 5_000 });
+});
+
+test('can edit a user to demote from admin', async () => {
+ const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') });
+
+ await userRow.locator('button:has-text("Edit")').click();
+ await waitForModal(page, 'Edit User');
+
+ // Toggle the admin switch off
+ await page.locator('.modal.show .form-check-input').click();
+ await expect(page.locator('.modal.show')).toContainText('Standard User');
+
+ await confirmModal(page);
+ await waitForModalClosed(page);
+
+ // Badge should show "User" again
+ await expect(userRow.locator('.badge:has-text("User")')).toBeVisible({ timeout: 5_000 });
+});
+
+test('edit button is disabled for the current user', async () => {
+ const adminRow = page.locator('tr', { has: page.locator('td:has-text("admin")') });
+ await expect(adminRow.locator('button:has-text("Edit")')).toBeDisabled();
+});
+
test('resets the non-admin user password', async () => {
// Ensure testuser row is stable before interacting
await expect(page.locator('td:has-text("testuser")')).toBeVisible({ timeout: 5_000 });
diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue
index 0b1b10c9..08efc6a0 100644
--- a/client-v3/src/components/config/ConfigUsers.vue
+++ b/client-v3/src/components/config/ConfigUsers.vue
@@ -32,6 +32,13 @@
>
RBAC
+
+ Edit
+
+
+
+
+
+ {{ editFormState.is_admin ? 'Admin' : 'Standard User' }}
+
+
+
+
+
>();
const newAdminModal = ref>();
const rbacModal = ref>();
+const editUserModal = ref>();
const resetPasswordModal = ref>();
const selectedUserId = ref(null);
const selectedUser = ref<{ id: number; username: string } | null>(null);
+const editFormState = ref | null>(null);
const userFields = [
'username',
@@ -132,6 +157,21 @@ function openResetPassword(user: { id: number; username: string }): void {
resetPasswordModal.value?.show();
}
+function openEditUser(user: Record): void {
+ editFormState.value = { ...user };
+ editUserModal.value?.show();
+}
+
+function clearEditUser(): void {
+ editFormState.value = null;
+}
+
+async function submitEditUser(): Promise {
+ if (editFormState.value) {
+ await userStore.editUser(editFormState.value as { id: number });
+ }
+}
+
async function deleteUser(item: { id: number; username: string }): Promise {
const confirmed = await confirm(`Are you sure you want to delete ${item.username}?`, {
title: 'Delete User',
diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts
index 933c4ac2..057307d5 100644
--- a/client-v3/src/js/http-interceptor.ts
+++ b/client-v3/src/js/http-interceptor.ts
@@ -111,9 +111,9 @@ export default function setupHttpInterceptor(): void {
const userStore = useUserStore();
const newOptions = buildAuthenticatedOptions(options, userStore.authToken);
- const isLogoutRequest = resource.endsWith('/api/v1/auth/logout');
- const isLoginRequest = resource.endsWith('/api/v1/auth/login');
- const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token');
+ const isLogoutRequest = resource.includes('/api/v1/auth/logout');
+ const isLoginRequest = resource.includes('/api/v1/auth/login');
+ const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token');
try {
const response = await originalFetch(resource, newOptions);
diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts
index a2044700..ecf925e4 100644
--- a/client-v3/src/stores/user.ts
+++ b/client-v3/src/stores/user.ts
@@ -176,7 +176,7 @@ export const useUserStore = defineStore('user', {
async getUsers(): Promise {
if (!this.currentUser?.is_admin) return;
- const response = await fetch(makeURL('/api/v1/auth/users'));
+ const response = await fetch(makeURL('/api/v2/users'));
if (response.ok) {
const data = await response.json();
this.users = data.users;
@@ -187,7 +187,7 @@ export const useUserStore = defineStore('user', {
},
async createUser(user: Record): Promise {
- const response = await fetch(makeURL('/api/v1/auth/create'), {
+ const response = await fetch(makeURL('/api/v2/users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
@@ -203,10 +203,9 @@ export const useUserStore = defineStore('user', {
},
async deleteUser(userId: number): Promise {
- const response = await fetch(makeURL('/api/v1/auth/delete'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: userId }),
+ const params = new URLSearchParams({ id: String(userId) });
+ const response = await fetch(makeURL(`/api/v2/users?${params}`), {
+ method: 'DELETE',
});
if (response.ok) {
await this.getUsers();
@@ -264,7 +263,7 @@ export const useUserStore = defineStore('user', {
},
async generateApiToken(): Promise | null> {
- const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
+ const response = await fetch(makeURL('/api/v2/users/token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
@@ -279,10 +278,8 @@ export const useUserStore = defineStore('user', {
},
async revokeApiToken(): Promise {
- const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
+ const response = await fetch(makeURL('/api/v2/users/token'), {
+ method: 'DELETE',
});
if (response.ok) {
toast.success('API token revoked successfully!');
@@ -294,12 +291,29 @@ export const useUserStore = defineStore('user', {
},
async getApiToken(): Promise | null> {
- const response = await fetch(makeURL('/api/v1/auth/api-token'));
+ const response = await fetch(makeURL('/api/v2/users/token'));
if (response.ok) return response.json();
toast.error('Unable to get API token!');
return null;
},
+ async editUser(user: { id: number; [key: string]: unknown }): Promise {
+ const params = new URLSearchParams({ id: String(user.id) });
+ const response = await fetch(makeURL(`/api/v2/users?${params}`), {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(user),
+ });
+ if (response.ok) {
+ await this.getUsers();
+ toast.success('User updated!');
+ } else {
+ const body = await response.json();
+ log.error('Unable to update user');
+ toast.error(`Unable to update user: ${body.message || 'Unknown error'}`);
+ }
+ },
+
async getStageDirectionStyleOverrides(): Promise {
const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'));
if (response.ok) {
diff --git a/client/src/js/http-interceptor.ts b/client/src/js/http-interceptor.ts
index 913abd9e..8491ea4b 100644
--- a/client/src/js/http-interceptor.ts
+++ b/client/src/js/http-interceptor.ts
@@ -18,8 +18,8 @@ export default function setupHttpInterceptor(): void {
// Only intercept our own API requests
if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) {
const token = store.getters.AUTH_TOKEN;
- const isLogoutRequest = resource.endsWith('/api/v1/auth/logout');
- const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token');
+ const isLogoutRequest = resource.includes('/api/v1/auth/logout');
+ const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token');
// Clone the options
const newOptions = {
diff --git a/client/src/store/modules/user/user.ts b/client/src/store/modules/user/user.ts
index a70089e1..2a6ac182 100644
--- a/client/src/store/modules/user/user.ts
+++ b/client/src/store/modules/user/user.ts
@@ -62,7 +62,7 @@ const module: Module = {
if (context.getters.CURRENT_USER == null || !context.getters.CURRENT_USER.is_admin) {
return;
}
- const response = await fetch(makeURL('/api/v1/auth/users'));
+ const response = await fetch(makeURL('/api/v2/users'));
if (response.ok) {
const users = await response.json();
await context.commit('SET_USERS', users.users);
@@ -72,7 +72,7 @@ const module: Module = {
}
},
async CREATE_USER(context, user) {
- const response = await fetch(makeURL('/api/v1/auth/create'), {
+ const response = await fetch(makeURL('/api/v2/users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
@@ -87,10 +87,9 @@ const module: Module = {
}
},
async DELETE_USER(context, userId: number) {
- const response = await fetch(makeURL('/api/v1/auth/delete'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: userId }),
+ const params = new URLSearchParams({ id: String(userId) });
+ const response = await fetch(makeURL(`/api/v2/users?${params}`), {
+ method: 'DELETE',
});
if (response.ok) {
await context.dispatch('GET_USERS');
@@ -229,7 +228,7 @@ const module: Module = {
await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval);
},
async GENERATE_API_TOKEN() {
- const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
+ const response = await fetch(makeURL('/api/v2/users/token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
@@ -247,10 +246,8 @@ const module: Module = {
return null;
},
async REVOKE_API_TOKEN() {
- const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
+ const response = await fetch(makeURL('/api/v2/users/token'), {
+ method: 'DELETE',
});
if (response.ok) {
VueToast.$toast.success('API token revoked successfully!');
@@ -264,7 +261,7 @@ const module: Module = {
return false;
},
async GET_API_TOKEN() {
- const response = await fetch(makeURL('/api/v1/auth/api-token'), {
+ const response = await fetch(makeURL('/api/v2/users/token'), {
method: 'GET',
});
if (response.ok) {
@@ -274,6 +271,22 @@ const module: Module = {
VueToast.$toast.error('Unable to get API token!');
return null;
},
+ async EDIT_USER(context, user: { id: number; [key: string]: unknown }) {
+ const params = new URLSearchParams({ id: String(user.id) });
+ const response = await fetch(makeURL(`/api/v2/users?${params}`), {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(user),
+ });
+ if (response.ok) {
+ await context.dispatch('GET_USERS');
+ VueToast.$toast.success('User updated!');
+ } else {
+ const responseBody = await response.json();
+ log.error('Unable to update user');
+ VueToast.$toast.error(`Unable to update user: ${responseBody.message || 'Unknown error'}`);
+ }
+ },
},
getters: {
CURRENT_USER(state: UserState) {
diff --git a/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue
index 3edb0e1a..aa086957 100644
--- a/client/src/vue_components/config/ConfigUsers.vue
+++ b/client/src/vue_components/config/ConfigUsers.vue
@@ -33,6 +33,14 @@
>
RBAC
+
+ Edit
+
+
+
+
+
+ {{ editFormState.is_admin ? 'Admin' : 'Standard User' }}
+
+
+
+
| null,
resetUser: null as { id: number; username: string } | null,
clientTimeout: null as ReturnType | null,
};
@@ -148,11 +173,22 @@ export default defineComponent({
await (this as any).DELETE_USER(data.item.id);
}
},
+ openEditUser(user: Record): void {
+ this.editFormState = { ...user };
+ },
+ clearEditUser(): void {
+ this.editFormState = null;
+ },
+ async submitEditUser(): Promise {
+ if (this.editFormState) {
+ await (this as any).EDIT_USER(this.editFormState);
+ }
+ },
async getUsers(): Promise {
await (this as any).GET_USERS();
this.clientTimeout = setTimeout(this.getUsers, 5000);
},
- ...mapActions(['GET_USERS', 'DELETE_USER']),
+ ...mapActions(['GET_USERS', 'DELETE_USER', 'EDIT_USER']),
},
});
diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md
index 6355ca3b..7918b47f 100644
--- a/docs/pages/user_config.md
+++ b/docs/pages/user_config.md
@@ -59,6 +59,14 @@ Click the **New User** button to add a new user. You'll need to provide:
Users are created at the system level and are not tied to individual shows. Their access to specific shows and resources is controlled through RBAC configuration.
+#### Editing Users
+
+To change a user's properties, click the **Edit** button next to their row. This opens a modal where you can:
+
+- Toggle the user's account type between **Admin** and **Standard User**
+
+> **Note:** You cannot edit your own account via this interface. Admin status changes take effect immediately — the user's next API request will reflect the updated role.
+
#### Configuring RBAC
Once users have been created, their permissions can be configured by clicking the **RBAC** button next to each user. This opens a detailed permissions interface where you can:
diff --git a/server/controllers/api/auth/__init__.py b/server/controllers/api/v1/__init__.py
similarity index 100%
rename from server/controllers/api/auth/__init__.py
rename to server/controllers/api/v1/__init__.py
diff --git a/server/controllers/api/show/__init__.py b/server/controllers/api/v1/auth/__init__.py
similarity index 100%
rename from server/controllers/api/show/__init__.py
rename to server/controllers/api/v1/auth/__init__.py
diff --git a/server/controllers/api/auth/token.py b/server/controllers/api/v1/auth/token.py
similarity index 100%
rename from server/controllers/api/auth/token.py
rename to server/controllers/api/v1/auth/token.py
diff --git a/server/controllers/api/auth/user.py b/server/controllers/api/v1/auth/user.py
similarity index 100%
rename from server/controllers/api/auth/user.py
rename to server/controllers/api/v1/auth/user.py
diff --git a/server/controllers/api/db_backups.py b/server/controllers/api/v1/db_backups.py
similarity index 100%
rename from server/controllers/api/db_backups.py
rename to server/controllers/api/v1/db_backups.py
diff --git a/server/controllers/api/health.py b/server/controllers/api/v1/health.py
similarity index 100%
rename from server/controllers/api/health.py
rename to server/controllers/api/v1/health.py
diff --git a/server/controllers/api/logging.py b/server/controllers/api/v1/logging.py
similarity index 100%
rename from server/controllers/api/logging.py
rename to server/controllers/api/v1/logging.py
diff --git a/server/controllers/api/logs_viewer.py b/server/controllers/api/v1/logs_viewer.py
similarity index 100%
rename from server/controllers/api/logs_viewer.py
rename to server/controllers/api/v1/logs_viewer.py
diff --git a/server/controllers/api/rbac.py b/server/controllers/api/v1/rbac.py
similarity index 100%
rename from server/controllers/api/rbac.py
rename to server/controllers/api/v1/rbac.py
diff --git a/server/controllers/api/settings.py b/server/controllers/api/v1/settings.py
similarity index 100%
rename from server/controllers/api/settings.py
rename to server/controllers/api/v1/settings.py
diff --git a/server/controllers/api/show/script/__init__.py b/server/controllers/api/v1/show/__init__.py
similarity index 100%
rename from server/controllers/api/show/script/__init__.py
rename to server/controllers/api/v1/show/__init__.py
diff --git a/server/controllers/api/show/acts.py b/server/controllers/api/v1/show/acts.py
similarity index 100%
rename from server/controllers/api/show/acts.py
rename to server/controllers/api/v1/show/acts.py
diff --git a/server/controllers/api/show/cast.py b/server/controllers/api/v1/show/cast.py
similarity index 100%
rename from server/controllers/api/show/cast.py
rename to server/controllers/api/v1/show/cast.py
diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/v1/show/characters.py
similarity index 100%
rename from server/controllers/api/show/characters.py
rename to server/controllers/api/v1/show/characters.py
diff --git a/server/controllers/api/show/cues.py b/server/controllers/api/v1/show/cues.py
similarity index 100%
rename from server/controllers/api/show/cues.py
rename to server/controllers/api/v1/show/cues.py
diff --git a/server/controllers/api/show/microphones.py b/server/controllers/api/v1/show/microphones.py
similarity index 100%
rename from server/controllers/api/show/microphones.py
rename to server/controllers/api/v1/show/microphones.py
diff --git a/server/controllers/api/show/scenes.py b/server/controllers/api/v1/show/scenes.py
similarity index 100%
rename from server/controllers/api/show/scenes.py
rename to server/controllers/api/v1/show/scenes.py
diff --git a/server/controllers/api/show/session/__init__.py b/server/controllers/api/v1/show/script/__init__.py
similarity index 100%
rename from server/controllers/api/show/session/__init__.py
rename to server/controllers/api/v1/show/script/__init__.py
diff --git a/server/controllers/api/show/script/compiled.py b/server/controllers/api/v1/show/script/compiled.py
similarity index 100%
rename from server/controllers/api/show/script/compiled.py
rename to server/controllers/api/v1/show/script/compiled.py
diff --git a/server/controllers/api/show/script/config.py b/server/controllers/api/v1/show/script/config.py
similarity index 100%
rename from server/controllers/api/show/script/config.py
rename to server/controllers/api/v1/show/script/config.py
diff --git a/server/controllers/api/show/script/revisions.py b/server/controllers/api/v1/show/script/revisions.py
similarity index 100%
rename from server/controllers/api/show/script/revisions.py
rename to server/controllers/api/v1/show/script/revisions.py
diff --git a/server/controllers/api/show/script/script.py b/server/controllers/api/v1/show/script/script.py
similarity index 100%
rename from server/controllers/api/show/script/script.py
rename to server/controllers/api/v1/show/script/script.py
diff --git a/server/controllers/api/show/script/stage_direction_styles.py b/server/controllers/api/v1/show/script/stage_direction_styles.py
similarity index 100%
rename from server/controllers/api/show/script/stage_direction_styles.py
rename to server/controllers/api/v1/show/script/stage_direction_styles.py
diff --git a/server/controllers/api/show/stage/__init__.py b/server/controllers/api/v1/show/session/__init__.py
similarity index 100%
rename from server/controllers/api/show/stage/__init__.py
rename to server/controllers/api/v1/show/session/__init__.py
diff --git a/server/controllers/api/show/session/assign_tags.py b/server/controllers/api/v1/show/session/assign_tags.py
similarity index 100%
rename from server/controllers/api/show/session/assign_tags.py
rename to server/controllers/api/v1/show/session/assign_tags.py
diff --git a/server/controllers/api/show/session/sessions.py b/server/controllers/api/v1/show/session/sessions.py
similarity index 100%
rename from server/controllers/api/show/session/sessions.py
rename to server/controllers/api/v1/show/session/sessions.py
diff --git a/server/controllers/api/show/session/tags.py b/server/controllers/api/v1/show/session/tags.py
similarity index 100%
rename from server/controllers/api/show/session/tags.py
rename to server/controllers/api/v1/show/session/tags.py
diff --git a/server/controllers/api/show/shows.py b/server/controllers/api/v1/show/shows.py
similarity index 100%
rename from server/controllers/api/show/shows.py
rename to server/controllers/api/v1/show/shows.py
diff --git a/server/controllers/api/user/__init__.py b/server/controllers/api/v1/show/stage/__init__.py
similarity index 100%
rename from server/controllers/api/user/__init__.py
rename to server/controllers/api/v1/show/stage/__init__.py
diff --git a/server/controllers/api/show/stage/crew.py b/server/controllers/api/v1/show/stage/crew.py
similarity index 100%
rename from server/controllers/api/show/stage/crew.py
rename to server/controllers/api/v1/show/stage/crew.py
diff --git a/server/controllers/api/show/stage/crew_assignments.py b/server/controllers/api/v1/show/stage/crew_assignments.py
similarity index 100%
rename from server/controllers/api/show/stage/crew_assignments.py
rename to server/controllers/api/v1/show/stage/crew_assignments.py
diff --git a/server/controllers/api/show/stage/helpers.py b/server/controllers/api/v1/show/stage/helpers.py
similarity index 100%
rename from server/controllers/api/show/stage/helpers.py
rename to server/controllers/api/v1/show/stage/helpers.py
diff --git a/server/controllers/api/show/stage/props.py b/server/controllers/api/v1/show/stage/props.py
similarity index 99%
rename from server/controllers/api/show/stage/props.py
rename to server/controllers/api/v1/show/stage/props.py
index 67d3332d..e961d385 100644
--- a/server/controllers/api/show/stage/props.py
+++ b/server/controllers/api/v1/show/stage/props.py
@@ -12,7 +12,7 @@
ERROR_PROPS_NOT_FOUND,
ERROR_SHOW_NOT_FOUND,
)
-from controllers.api.show.stage.helpers import (
+from controllers.api.v1.show.stage.helpers import (
handle_allocation_delete,
handle_allocation_post,
handle_type_delete,
diff --git a/server/controllers/api/show/stage/scenery.py b/server/controllers/api/v1/show/stage/scenery.py
similarity index 99%
rename from server/controllers/api/show/stage/scenery.py
rename to server/controllers/api/v1/show/stage/scenery.py
index aa673e5e..b1c1673d 100644
--- a/server/controllers/api/show/stage/scenery.py
+++ b/server/controllers/api/v1/show/stage/scenery.py
@@ -11,7 +11,7 @@
ERROR_SCENERY_TYPE_NOT_FOUND,
ERROR_SHOW_NOT_FOUND,
)
-from controllers.api.show.stage.helpers import (
+from controllers.api.v1.show.stage.helpers import (
handle_allocation_delete,
handle_allocation_post,
handle_type_delete,
diff --git a/server/controllers/api/system_info.py b/server/controllers/api/v1/system_info.py
similarity index 100%
rename from server/controllers/api/system_info.py
rename to server/controllers/api/v1/system_info.py
diff --git a/server/test/controllers/api/show/__init__.py b/server/controllers/api/v1/user/__init__.py
similarity index 100%
rename from server/test/controllers/api/show/__init__.py
rename to server/controllers/api/v1/user/__init__.py
diff --git a/server/controllers/api/user/overrides.py b/server/controllers/api/v1/user/overrides.py
similarity index 100%
rename from server/controllers/api/user/overrides.py
rename to server/controllers/api/v1/user/overrides.py
diff --git a/server/controllers/api/user/settings.py b/server/controllers/api/v1/user/settings.py
similarity index 100%
rename from server/controllers/api/user/settings.py
rename to server/controllers/api/v1/user/settings.py
diff --git a/server/controllers/api/version.py b/server/controllers/api/v1/version.py
similarity index 100%
rename from server/controllers/api/version.py
rename to server/controllers/api/v1/version.py
diff --git a/server/controllers/api/websocket.py b/server/controllers/api/v1/websocket.py
similarity index 100%
rename from server/controllers/api/websocket.py
rename to server/controllers/api/v1/websocket.py
diff --git a/server/test/controllers/api/show/script/__init__.py b/server/controllers/api/v2/__init__.py
similarity index 100%
rename from server/test/controllers/api/show/script/__init__.py
rename to server/controllers/api/v2/__init__.py
diff --git a/server/test/controllers/api/show/session/__init__.py b/server/controllers/api/v2/users/__init__.py
similarity index 100%
rename from server/test/controllers/api/show/session/__init__.py
rename to server/controllers/api/v2/users/__init__.py
diff --git a/server/controllers/api/v2/users/token.py b/server/controllers/api/v2/users/token.py
new file mode 100644
index 00000000..704900b9
--- /dev/null
+++ b/server/controllers/api/v2/users/token.py
@@ -0,0 +1,64 @@
+import secrets
+
+from models.user import User
+from services.password_service import PasswordService
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import api_authenticated
+
+
+@ApiRoute("users/token", ApiVersion.V2)
+class UsersTokenV2Controller(BaseAPIController):
+ @api_authenticated
+ def get(self):
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ self.finish({"message": "User not found"})
+ return
+
+ self.set_status(200)
+ self.finish({"has_token": user.api_token is not None})
+
+ @api_authenticated
+ async def post(self):
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ new_token = secrets.token_urlsafe(32)
+ hashed_token = await PasswordService.hash_password(new_token)
+ user.api_token = hashed_token
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {
+ "message": "API token generated successfully",
+ "api_token": new_token,
+ }
+ )
+
+ @api_authenticated
+ async def delete(self):
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ if not user.api_token:
+ self.set_status(400)
+ await self.finish({"message": "No API token to revoke"})
+ return
+
+ user.api_token = None
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "API token revoked successfully"})
diff --git a/server/controllers/api/v2/users/users.py b/server/controllers/api/v2/users/users.py
new file mode 100644
index 00000000..71860c35
--- /dev/null
+++ b/server/controllers/api/v2/users/users.py
@@ -0,0 +1,182 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from tornado import escape
+
+from models.user import User
+from registry.named_locks import NamedLockRegistry
+from schemas.schemas import UserSchema
+from services.password_service import PasswordService
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import (
+ api_authenticated,
+ no_live_session,
+ redact_data_paths,
+ require_admin,
+)
+
+
+@ApiRoute("users", ApiVersion.V2)
+class UsersV2Controller(BaseAPIController):
+ EDITABLE_FIELDS = {"is_admin"}
+
+ @api_authenticated
+ @require_admin
+ def get(self):
+ user_schema = UserSchema()
+ with self.make_session() as session:
+ users = session.scalars(select(User)).all()
+ self.set_status(200)
+ self.finish({"users": [user_schema.dump(u) for u in users]})
+
+ @redact_data_paths(paths=["/password", "/confirmPassword"])
+ async def post(self):
+ with self.make_session() as session:
+ has_any_users = session.scalars(select(User)).first() is not None
+ if has_any_users:
+ self.requires_admin()
+
+ data = escape.json_decode(self.request.body)
+
+ username = data.get("username", "")
+ if not username:
+ self.set_status(400)
+ await self.finish({"message": "Username missing"})
+ return
+
+ password = data.get("password", "")
+ if not password:
+ self.set_status(400)
+ await self.finish({"message": "Password missing"})
+ return
+
+ is_admin = data.get("is_admin", False)
+ if not has_any_users and not is_admin:
+ self.set_status(400)
+ await self.finish({"message": "First user must be an admin"})
+ return
+
+ is_valid, error_msg = PasswordService.validate_password_strength(password)
+ if not is_valid:
+ self.set_status(400)
+ await self.finish({"message": error_msg})
+ return
+
+ async with NamedLockRegistry.acquire(f"UserLock::{username}"):
+ conflict_user = session.scalars(
+ select(User).where(User.username == username)
+ ).first()
+ if conflict_user:
+ self.set_status(400)
+ await self.finish({"message": "Username already taken"})
+ return
+
+ hashed_password = await PasswordService.hash_password(password)
+
+ session.add(
+ User(
+ username=username,
+ password=hashed_password,
+ is_admin=is_admin,
+ created_on=datetime.now(tz=timezone.utc),
+ )
+ )
+ session.commit()
+
+ if is_admin:
+ await self.application.digi_settings.set("has_admin_user", True)
+
+ self.set_status(200)
+ await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
+ await self.finish({"message": "Successfully created user"})
+
+ @api_authenticated
+ @require_admin
+ async def patch(self):
+ user_id = self.get_argument("id", None)
+ if not user_id:
+ self.set_status(400)
+ await self.finish({"message": "Id missing"})
+ return
+
+ data = escape.json_decode(self.request.body)
+
+ with self.make_session() as session:
+ user: User = session.get(User, int(user_id))
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ if user.id == self.current_user["id"]:
+ self.set_status(400)
+ await self.finish({"message": "Cannot edit your own account"})
+ return
+
+ if "is_admin" in data and not data["is_admin"] and user.is_admin:
+ all_admins = session.scalars(
+ select(User).where(User.is_admin.is_(True))
+ ).all()
+ if len(all_admins) <= 1:
+ self.set_status(400)
+ await self.finish(
+ {"message": "Cannot remove admin from the only admin user"}
+ )
+ return
+
+ for field in self.EDITABLE_FIELDS:
+ if field in data:
+ setattr(user, field, data[field])
+
+ session.commit()
+
+ self.set_status(200)
+ await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
+ await self.finish({"message": "Successfully updated user"})
+
+ @api_authenticated
+ @require_admin
+ @no_live_session
+ async def delete(self):
+ user_id = self.get_argument("id", None)
+ if not user_id:
+ self.set_status(400)
+ await self.finish({"message": "Id missing"})
+ return
+
+ with self.make_session() as session:
+ user_to_delete: User = session.get(User, int(user_id))
+ if not user_to_delete:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ if user_to_delete.id == self.current_user["id"]:
+ self.set_status(400)
+ await self.finish(
+ {"message": "Cannot delete currently authenticated user"}
+ )
+ return
+
+ all_admins = session.scalars(
+ select(User).where(User.is_admin.is_(True))
+ ).all()
+ if user_to_delete.is_admin and len(all_admins) <= 1:
+ self.set_status(400)
+ await self.finish({"message": "Cannot delete the only admin user"})
+ return
+
+ async with NamedLockRegistry.acquire(
+ f"UserLock::{user_to_delete.username}"
+ ):
+ await self.application.user_service.force_logout_all_sessions(
+ session, user_to_delete
+ )
+ self.application.rbac.delete_actor(user_to_delete)
+ session.delete(user_to_delete)
+ session.commit()
+
+ self.set_status(200)
+ await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
+ await self.finish({"message": "Successfully deleted user"})
diff --git a/server/test/controllers/api/show/stage/__init__.py b/server/test/controllers/api/v1/__init__.py
similarity index 100%
rename from server/test/controllers/api/show/stage/__init__.py
rename to server/test/controllers/api/v1/__init__.py
diff --git a/server/test/controllers/api/user/__init__.py b/server/test/controllers/api/v1/auth/__init__.py
similarity index 100%
rename from server/test/controllers/api/user/__init__.py
rename to server/test/controllers/api/v1/auth/__init__.py
diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/v1/auth/test_auth.py
similarity index 100%
rename from server/test/controllers/api/test_auth.py
rename to server/test/controllers/api/v1/auth/test_auth.py
diff --git a/server/test/controllers/api/v1/show/__init__.py b/server/test/controllers/api/v1/show/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/v1/show/script/__init__.py b/server/test/controllers/api/v1/show/script/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/show/script/test_compiled_scripts.py b/server/test/controllers/api/v1/show/script/test_compiled_scripts.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_compiled_scripts.py
rename to server/test/controllers/api/v1/show/script/test_compiled_scripts.py
diff --git a/server/test/controllers/api/show/script/test_config.py b/server/test/controllers/api/v1/show/script/test_config.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_config.py
rename to server/test/controllers/api/v1/show/script/test_config.py
diff --git a/server/test/controllers/api/show/script/test_orphan_deletion.py b/server/test/controllers/api/v1/show/script/test_orphan_deletion.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_orphan_deletion.py
rename to server/test/controllers/api/v1/show/script/test_orphan_deletion.py
diff --git a/server/test/controllers/api/show/script/test_revision_fk_constraints.py b/server/test/controllers/api/v1/show/script/test_revision_fk_constraints.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_revision_fk_constraints.py
rename to server/test/controllers/api/v1/show/script/test_revision_fk_constraints.py
diff --git a/server/test/controllers/api/show/script/test_revisions.py b/server/test/controllers/api/v1/show/script/test_revisions.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_revisions.py
rename to server/test/controllers/api/v1/show/script/test_revisions.py
diff --git a/server/test/controllers/api/show/script/test_script.py b/server/test/controllers/api/v1/show/script/test_script.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_script.py
rename to server/test/controllers/api/v1/show/script/test_script.py
diff --git a/server/test/controllers/api/show/script/test_stage_direction_styles.py b/server/test/controllers/api/v1/show/script/test_stage_direction_styles.py
similarity index 100%
rename from server/test/controllers/api/show/script/test_stage_direction_styles.py
rename to server/test/controllers/api/v1/show/script/test_stage_direction_styles.py
diff --git a/server/test/controllers/api/v1/show/session/__init__.py b/server/test/controllers/api/v1/show/session/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/show/session/test_assign_tags.py b/server/test/controllers/api/v1/show/session/test_assign_tags.py
similarity index 100%
rename from server/test/controllers/api/show/session/test_assign_tags.py
rename to server/test/controllers/api/v1/show/session/test_assign_tags.py
diff --git a/server/test/controllers/api/show/session/test_sessions.py b/server/test/controllers/api/v1/show/session/test_sessions.py
similarity index 100%
rename from server/test/controllers/api/show/session/test_sessions.py
rename to server/test/controllers/api/v1/show/session/test_sessions.py
diff --git a/server/test/controllers/api/show/session/test_tags.py b/server/test/controllers/api/v1/show/session/test_tags.py
similarity index 100%
rename from server/test/controllers/api/show/session/test_tags.py
rename to server/test/controllers/api/v1/show/session/test_tags.py
diff --git a/server/test/controllers/api/v1/show/stage/__init__.py b/server/test/controllers/api/v1/show/stage/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/show/stage/test_crew.py b/server/test/controllers/api/v1/show/stage/test_crew.py
similarity index 100%
rename from server/test/controllers/api/show/stage/test_crew.py
rename to server/test/controllers/api/v1/show/stage/test_crew.py
diff --git a/server/test/controllers/api/show/stage/test_crew_assignments.py b/server/test/controllers/api/v1/show/stage/test_crew_assignments.py
similarity index 100%
rename from server/test/controllers/api/show/stage/test_crew_assignments.py
rename to server/test/controllers/api/v1/show/stage/test_crew_assignments.py
diff --git a/server/test/controllers/api/show/stage/test_props.py b/server/test/controllers/api/v1/show/stage/test_props.py
similarity index 100%
rename from server/test/controllers/api/show/stage/test_props.py
rename to server/test/controllers/api/v1/show/stage/test_props.py
diff --git a/server/test/controllers/api/show/stage/test_scenery.py b/server/test/controllers/api/v1/show/stage/test_scenery.py
similarity index 100%
rename from server/test/controllers/api/show/stage/test_scenery.py
rename to server/test/controllers/api/v1/show/stage/test_scenery.py
diff --git a/server/test/controllers/api/show/test_acts.py b/server/test/controllers/api/v1/show/test_acts.py
similarity index 100%
rename from server/test/controllers/api/show/test_acts.py
rename to server/test/controllers/api/v1/show/test_acts.py
diff --git a/server/test/controllers/api/show/test_cast.py b/server/test/controllers/api/v1/show/test_cast.py
similarity index 100%
rename from server/test/controllers/api/show/test_cast.py
rename to server/test/controllers/api/v1/show/test_cast.py
diff --git a/server/test/controllers/api/show/test_characters.py b/server/test/controllers/api/v1/show/test_characters.py
similarity index 100%
rename from server/test/controllers/api/show/test_characters.py
rename to server/test/controllers/api/v1/show/test_characters.py
diff --git a/server/test/controllers/api/show/test_cues.py b/server/test/controllers/api/v1/show/test_cues.py
similarity index 100%
rename from server/test/controllers/api/show/test_cues.py
rename to server/test/controllers/api/v1/show/test_cues.py
diff --git a/server/test/controllers/api/show/test_microphones.py b/server/test/controllers/api/v1/show/test_microphones.py
similarity index 100%
rename from server/test/controllers/api/show/test_microphones.py
rename to server/test/controllers/api/v1/show/test_microphones.py
diff --git a/server/test/controllers/api/show/test_scenes.py b/server/test/controllers/api/v1/show/test_scenes.py
similarity index 100%
rename from server/test/controllers/api/show/test_scenes.py
rename to server/test/controllers/api/v1/show/test_scenes.py
diff --git a/server/test/controllers/api/show/test_shows.py b/server/test/controllers/api/v1/show/test_shows.py
similarity index 100%
rename from server/test/controllers/api/show/test_shows.py
rename to server/test/controllers/api/v1/show/test_shows.py
diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/v1/test_db_backups.py
similarity index 100%
rename from server/test/controllers/api/test_db_backups.py
rename to server/test/controllers/api/v1/test_db_backups.py
diff --git a/server/test/controllers/api/test_health.py b/server/test/controllers/api/v1/test_health.py
similarity index 100%
rename from server/test/controllers/api/test_health.py
rename to server/test/controllers/api/v1/test_health.py
diff --git a/server/test/controllers/api/test_logging.py b/server/test/controllers/api/v1/test_logging.py
similarity index 100%
rename from server/test/controllers/api/test_logging.py
rename to server/test/controllers/api/v1/test_logging.py
diff --git a/server/test/controllers/api/test_logs_viewer.py b/server/test/controllers/api/v1/test_logs_viewer.py
similarity index 100%
rename from server/test/controllers/api/test_logs_viewer.py
rename to server/test/controllers/api/v1/test_logs_viewer.py
diff --git a/server/test/controllers/api/test_rbac.py b/server/test/controllers/api/v1/test_rbac.py
similarity index 100%
rename from server/test/controllers/api/test_rbac.py
rename to server/test/controllers/api/v1/test_rbac.py
diff --git a/server/test/controllers/api/test_settings.py b/server/test/controllers/api/v1/test_settings.py
similarity index 100%
rename from server/test/controllers/api/test_settings.py
rename to server/test/controllers/api/v1/test_settings.py
diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/v1/test_system_info.py
similarity index 100%
rename from server/test/controllers/api/test_system_info.py
rename to server/test/controllers/api/v1/test_system_info.py
diff --git a/server/test/controllers/api/test_websocket.py b/server/test/controllers/api/v1/test_websocket.py
similarity index 100%
rename from server/test/controllers/api/test_websocket.py
rename to server/test/controllers/api/v1/test_websocket.py
diff --git a/server/test/controllers/api/v1/user/__init__.py b/server/test/controllers/api/v1/user/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/user/test_overrides.py b/server/test/controllers/api/v1/user/test_overrides.py
similarity index 100%
rename from server/test/controllers/api/user/test_overrides.py
rename to server/test/controllers/api/v1/user/test_overrides.py
diff --git a/server/test/controllers/api/v2/__init__.py b/server/test/controllers/api/v2/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/test/controllers/api/v2/test_users.py b/server/test/controllers/api/v2/test_users.py
new file mode 100644
index 00000000..decdbb72
--- /dev/null
+++ b/server/test/controllers/api/v2/test_users.py
@@ -0,0 +1,312 @@
+from sqlalchemy import select
+from tornado import escape
+
+from models.user import User
+from test.conftest import DigiScriptTestCase
+
+
+class TestUsersV2Controller(DigiScriptTestCase):
+ """Tests for GET/POST/PATCH/DELETE /api/v2/users"""
+
+ def _setup_admin(self, username="admin", password="adminpass"):
+ self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode(
+ {"username": username, "password": password, "is_admin": True}
+ ),
+ )
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": username, "password": password}),
+ )
+ return escape.json_decode(resp.body)["access_token"]
+
+ def _create_user(
+ self, admin_token, username="user", password="userpass", is_admin=False
+ ):
+ resp = self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode(
+ {"username": username, "password": password, "is_admin": is_admin}
+ ),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+ with self._app.get_db().sessionmaker() as session:
+ u = session.scalars(select(User).where(User.username == username)).first()
+ return u.id
+
+ # ─── GET /api/v2/users ─────────────────────────────────────────────────────
+
+ def test_get_users_returns_all_users(self):
+ admin_token = self._setup_admin()
+ self._create_user(admin_token, username="user1")
+ self._create_user(admin_token, username="user2")
+
+ resp = self.fetch(
+ "/api/v2/users",
+ method="GET",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+ body = escape.json_decode(resp.body)
+ self.assertIn("users", body)
+ usernames = [u["username"] for u in body["users"]]
+ self.assertIn("admin", usernames)
+ self.assertIn("user1", usernames)
+ self.assertIn("user2", usernames)
+
+ def test_get_users_requires_admin(self):
+ admin_token = self._setup_admin()
+ self._create_user(admin_token, username="nonadmin")
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "nonadmin", "password": "userpass"}),
+ )
+ nonadmin_token = escape.json_decode(resp.body)["access_token"]
+
+ resp = self.fetch(
+ "/api/v2/users",
+ method="GET",
+ headers={"Authorization": f"Bearer {nonadmin_token}"},
+ )
+ self.assertEqual(401, resp.code)
+
+ # ─── POST /api/v2/users ────────────────────────────────────────────────────
+
+ def test_post_create_user_success(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "newuser", "password": "password", "is_admin": False}
+ ),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+ self.assertEqual(
+ "Successfully created user", escape.json_decode(resp.body)["message"]
+ )
+
+ def test_post_first_user_must_be_admin(self):
+ resp = self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "user", "password": "password", "is_admin": False}
+ ),
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual(
+ "First user must be an admin", escape.json_decode(resp.body)["message"]
+ )
+
+ def test_post_missing_username(self):
+ resp = self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode({"password": "password", "is_admin": True}),
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual("Username missing", escape.json_decode(resp.body)["message"])
+
+ def test_post_duplicate_username(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "password", "is_admin": False}
+ ),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual(
+ "Username already taken", escape.json_decode(resp.body)["message"]
+ )
+
+ # ─── PATCH /api/v2/users?id={n} ───────────────────────────────────────────
+
+ def test_patch_toggle_admin_success(self):
+ admin_token = self._setup_admin()
+ second_admin_id = self._create_user(
+ admin_token, username="admin2", password="adminpass2", is_admin=True
+ )
+
+ resp = self.fetch(
+ f"/api/v2/users?id={second_admin_id}",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": False}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+ self.assertEqual(
+ "Successfully updated user", escape.json_decode(resp.body)["message"]
+ )
+
+ with self._app.get_db().sessionmaker() as session:
+ self.assertFalse(session.get(User, second_admin_id).is_admin)
+
+ def test_patch_promote_user_to_admin(self):
+ admin_token = self._setup_admin()
+ user_id = self._create_user(admin_token)
+
+ resp = self.fetch(
+ f"/api/v2/users?id={user_id}",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": True}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+
+ with self._app.get_db().sessionmaker() as session:
+ self.assertTrue(session.get(User, user_id).is_admin)
+
+ def test_patch_requires_admin(self):
+ admin_token = self._setup_admin()
+ user_id = self._create_user(admin_token)
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "user", "password": "userpass"}),
+ )
+ user_token = escape.json_decode(resp.body)["access_token"]
+
+ resp = self.fetch(
+ f"/api/v2/users?id={user_id}",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": True}),
+ headers={"Authorization": f"Bearer {user_token}"},
+ )
+ self.assertEqual(401, resp.code)
+
+ def test_patch_missing_id(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": False}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual("Id missing", escape.json_decode(resp.body)["message"])
+
+ def test_patch_user_not_found(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users?id=99999",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": False}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(404, resp.code)
+ self.assertEqual("User not found", escape.json_decode(resp.body)["message"])
+
+ def test_patch_self_edit_rejected(self):
+ admin_token = self._setup_admin()
+ with self._app.get_db().sessionmaker() as session:
+ admin_id = (
+ session.scalars(select(User).where(User.username == "admin")).first().id
+ )
+
+ resp = self.fetch(
+ f"/api/v2/users?id={admin_id}",
+ method="PATCH",
+ body=escape.json_encode({"is_admin": False}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual(
+ "Cannot edit your own account", escape.json_decode(resp.body)["message"]
+ )
+
+ # note: the last-admin demotion guard (400 when target is the only admin) can only be
+ # triggered by a concurrent race condition — current_user["is_admin"] is always re-fetched
+ # from the DB on every request, so a synchronous test cannot reach that branch.
+
+ # ─── DELETE /api/v2/users?id={n} ──────────────────────────────────────────
+
+ def test_delete_user_success(self):
+ admin_token = self._setup_admin()
+ user_id = self._create_user(admin_token)
+
+ resp = self.fetch(
+ f"/api/v2/users?id={user_id}",
+ method="DELETE",
+ allow_nonstandard_methods=True,
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(200, resp.code)
+ self.assertEqual(
+ "Successfully deleted user", escape.json_decode(resp.body)["message"]
+ )
+
+ with self._app.get_db().sessionmaker() as session:
+ self.assertIsNone(session.get(User, user_id))
+
+ def test_delete_requires_admin(self):
+ admin_token = self._setup_admin()
+ user_id = self._create_user(admin_token)
+ resp = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "user", "password": "userpass"}),
+ )
+ user_token = escape.json_decode(resp.body)["access_token"]
+
+ resp = self.fetch(
+ f"/api/v2/users?id={user_id}",
+ method="DELETE",
+ allow_nonstandard_methods=True,
+ headers={"Authorization": f"Bearer {user_token}"},
+ )
+ self.assertEqual(401, resp.code)
+
+ def test_delete_missing_id(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users",
+ method="DELETE",
+ allow_nonstandard_methods=True,
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual("Id missing", escape.json_decode(resp.body)["message"])
+
+ def test_delete_user_not_found(self):
+ admin_token = self._setup_admin()
+ resp = self.fetch(
+ "/api/v2/users?id=99999",
+ method="DELETE",
+ allow_nonstandard_methods=True,
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(404, resp.code)
+ self.assertEqual("User not found", escape.json_decode(resp.body)["message"])
+
+ def test_delete_self_rejected(self):
+ admin_token = self._setup_admin()
+ with self._app.get_db().sessionmaker() as session:
+ admin_id = (
+ session.scalars(select(User).where(User.username == "admin")).first().id
+ )
+
+ resp = self.fetch(
+ f"/api/v2/users?id={admin_id}",
+ method="DELETE",
+ allow_nonstandard_methods=True,
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ self.assertEqual(
+ "Cannot delete currently authenticated user",
+ escape.json_decode(resp.body)["message"],
+ )
+
+ # note: the last-admin delete guard (400 when target is the only admin) suffers the same
+ # race-condition constraint as the PATCH guard — not reachable in a synchronous test.
diff --git a/server/utils/web/route.py b/server/utils/web/route.py
index 464b455d..d679be45 100644
--- a/server/utils/web/route.py
+++ b/server/utils/web/route.py
@@ -53,6 +53,7 @@ def make(cls, _name, **kwargs):
class ApiVersion(Enum):
V1 = 1
+ V2 = 2
class ApiRoute(Route):