diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8169854f..c035158ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous - [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself ### Added diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index 58c21fa947..256eddd9cf 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -54,7 +54,7 @@ Each user is assigned an authentication token which can be used to access the AP If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request. -To obtain a valid token, perform a GET request to `/api/user/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers. +To obtain a valid token, perform a GET request to `/api/user/me/token/`. No data are required, but a valid username / password combination must be supplied in the authentication headers. !!! info "Credentials" Ensure that a valid username:password combination are supplied as basic authorization headers. @@ -146,7 +146,7 @@ r:delete:stock Users can only perform REST API actions which align with their assigned [role permissions](../settings/permissions.md#roles). Once a user has *authenticated* via the API, a list of the available roles can be retrieved from: -`/api/user/roles/` +`/api/user/me/roles/` For example, when accessing the API from a *superuser* account: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index dc17ef357a..2de7bf0df3 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 489 +INVENTREE_API_VERSION = 490 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963 + - moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer + v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962 - Removes the "remote_image" field from the Part API endpoint - Removes the "remote_image" field from the Company API endpoint diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 93050be7ce..55fa008349 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -341,3 +341,24 @@ def schema_for_view_output_options(view_class): view_class ) return extended_view + + +def exclude_from_schema(klass: type, alternative_path: str) -> type: + """Decorator to exclude a view from the OpenAPI schema. + + This is used to hide legacy endpoints from the schema, while still retaining them for backwards compatibility. + """ + + class LegacyView(klass): + """Dummy doc.""" + + LegacyView.__name__ = klass.__name__ + ' - Legacy' + LegacyView.__doc__ = f'This is a legacy endpoint, retained for backwards compatibility. Consider migrating to the new endpoint under {alternative_path}.' + + # Exclude all default operations from the schema + for operation in ['get', 'post', 'put', 'patch', 'delete']: + if hasattr(klass, operation): + LegacyView = extend_schema_view(**{operation: extend_schema(exclude=True)})( + LegacyView + ) + return LegacyView diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 6e07f35c77..13ef2fa501 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -33,6 +33,7 @@ from InvenTree.mixins import ( SerializerContextMixin, UpdateAPI, ) +from InvenTree.schema import exclude_from_schema from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner, RuleSet, UserProfile from users.serializers import ( @@ -501,8 +502,38 @@ class UserProfileDetail(RetrieveUpdateAPI): user_urls = [ - path('roles/', RoleDetails.as_view(), name='api-user-roles'), - path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'), + # Legacy endpoints (to avoid breaking existing API clients) + # TODO @matmair - remove these legacy endpoints in the next breaking release + path( + 'roles/', + exclude_from_schema(RoleDetails, '/api/user/me/roles/').as_view(), + name='api-user-roles_legacy', + ), + path( + 'token/', + ensure_csrf_cookie( + exclude_from_schema(GetAuthToken, '/api/user/me/token/').as_view() + ), + name='api-token_legacy', + ), + path( + 'profile/', + exclude_from_schema(UserProfileDetail, '/api/user/me/profile/').as_view(), + name='api-user-profile_legacy', + ), + # Individual user endpoints + path( + 'me/', + include([ + path('profile/', UserProfileDetail.as_view(), name='api-user-profile'), + path('roles/', RoleDetails.as_view(), name='api-user-roles'), + path( + 'token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token' + ), + path('', MeUserDetail.as_view(), name='api-user-me'), + ]), + ), + # User related endpoints path( 'tokens/', include([ @@ -510,8 +541,6 @@ user_urls = [ path('', TokenListView.as_view(), name='api-token-list'), ]), ), - path('me/', MeUserDetail.as_view(), name='api-user-me'), - path('profile/', UserProfileDetail.as_view(), name='api-user-profile'), path( 'owner/', include([ diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index addb35378b..bc06ace42c 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -12,12 +12,13 @@ export enum ApiEndpoints { // User API endpoints user_list = 'user/', user_set_password = 'user/:id/set-password/', - user_me = 'user/me/', - user_profile = 'user/profile/', - user_roles = 'user/roles/', - user_token = 'user/token/', user_tokens = 'user/tokens/', user_simple_login = 'email/generate/', + // Individual user endpoints + user_me_profile = 'user/me/profile/', + user_me_roles = 'user/me/roles/', + user_me_token = 'user/me/token/', + user_me = 'user/me/', // User auth endpoints auth_base = '/auth/', diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx index bc8045b4ce..16bd567bd5 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/AccountDetailPanel.tsx @@ -53,7 +53,7 @@ export function AccountDetailPanel() { const editProfile = useEditApiFormModal({ title: t`Edit Profile Information`, - url: ApiEndpoints.user_profile, + url: ApiEndpoints.user_me_profile, onFormSuccess: fetchUserState, fields: profileFields, successMessage: t`Profile details updated` diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index d0d7aceaac..f5c09b0751 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -154,7 +154,7 @@ pushes changes in user profile to backend export function patchUser(key: 'language' | 'theme' | 'widgets', val: any) { const uid = useUserState.getState().userId(); if (uid) { - api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val }); + api.patch(apiUrl(ApiEndpoints.user_me_profile), { [key]: val }); } else { console.log('user not logged in, not patching'); } diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 16cfd75bde..f6e309861a 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -104,7 +104,7 @@ export const useUserState = create((set, get) => ({ // Fetch role data await api - .get(apiUrl(ApiEndpoints.user_roles)) + .get(apiUrl(ApiEndpoints.user_me_roles)) .then((response) => { if (response.status == 200) { const user: UserProps = get().user as UserProps; diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index 14d4bcda8a..ead1d0b50d 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -26,7 +26,7 @@ export function ApiTokenTable({ const [opened, { open, close }] = useDisclosure(false); const generateToken = useCreateApiFormModal({ - url: ApiEndpoints.user_token, + url: ApiEndpoints.user_me_token, method: 'GET', title: t`Generate Token`, fields: { name: {} }, diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index d181a882ac..66bd836008 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -70,7 +70,7 @@ export const test = baseTest.extend({ !msg.text().includes('/this/does/not/exist.js') && !url.includes('/this/does/not/exist.js') && !url.includes('/api/user/me/') && - !url.includes('/api/user/token/') && + !url.includes('/api/user/me/token/') && !url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/session') && !url.includes('/api/auth/v1/account/authenticators/totp') && diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts index 07af2a5a0b..eca6c9a004 100644 --- a/src/frontend/tests/pages/pui_dashboard.spec.ts +++ b/src/frontend/tests/pages/pui_dashboard.spec.ts @@ -142,7 +142,7 @@ test('Dashboard - Preserve widget sizes', async ({ browser }) => { password: user.testcred }); - (await api).patch('user/profile/', { + (await api).patch('user/me/profile/', { data: { widgets: { widgets: ['ovr-so'],