Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
130eabe
feat(web-client): add native fetch http core
mzner Aug 28, 2026
1e57b04
test(web-test-helpers): replace axios mocks with fetch-shaped helpers
mzner Aug 28, 2026
a45d842
refactor(web-pkg): build HttpClient on the fetch core
mzner Aug 28, 2026
b978985
refactor(web-pkg): unify ClientService interceptors on the fetch core
mzner Aug 28, 2026
23b69e7
refactor(web-pkg): drop axios types from useRequest
mzner Aug 28, 2026
f0017ed
fix(web): align error and header access with the fetch core
mzner Aug 28, 2026
472e690
refactor(web-client): move ocs onto the fetch core
mzner Aug 28, 2026
28cb9df
refactor(web-client): move webdav helpers onto the fetch core
mzner Aug 28, 2026
ea0c501
chore(web-client): regenerate graph client with typescript-fetch
mzner Aug 28, 2026
6bb4a63
refactor(web-client): adapt graph wrappers to the fetch-based generat…
mzner Aug 28, 2026
58b9890
refactor: use the generated property names for OData-annotated graph …
mzner Aug 28, 2026
1ba9398
chore: drop the axios dependency
mzner Aug 28, 2026
02ae9ed
fix(web-pkg): keep the axios-era surface on HttpClient
mzner Sep 6, 2026
7570fc5
fix(web-client): keep read-only graph fields in request bodies
mzner Sep 9, 2026
c193bdb
fix(web-client): let graph errors keep their status and body
mzner Sep 9, 2026
67d79be
fix(web-client): let fetch set the multipart boundary for FormData bo…
mzner Sep 9, 2026
cd9f493
fix(web): merge request headers case-insensitively, plus PR review fixes
mzner Sep 10, 2026
0ca256b
chore(web): drop leaked test title prefixes, document the maintenance…
mzner Sep 10, 2026
ebf2236
fix(web): keep graph fields the generated decoder used to lose
mzner Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
58 changes: 58 additions & 0 deletions changelog/unreleased/change-replace-axios-with-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
Change: Replace axios with the native fetch API in Web

The Web frontend no longer depends on axios. All HTTP traffic goes through a
single fetch-based client, and the libre-graph client is generated from the
`typescript-fetch` template instead of `typescript-axios`.

Sending requests is a drop-in change. `HttpClient` keeps its per-request config
including `data`, keeps `cancel()`, still resolves a non-JSON body as text, and
still exposes response headers as `headers['etag']` as well as
`headers.get('etag')`. Errors still carry the body and status as `error.data` /
`error.statusCode` and as `error.response.data` / `error.response.status`.
`mockAxiosResolve` and `mockAxiosReject` still work, deprecated in favour of
`mockHttpResponse` and `mockHttpError`.

Per-request `headers` are merged over the client-wide ones case-insensitively, so
an override replaces the header it names whatever case either side used.

Maintenance mode is now also detected on `clientService.httpAuthenticated`. Before,
only the unauthenticated, graph and ocs clients watched responses for it, as the
authenticated client had no response interceptor. Requests through it now raise and
clear the maintenance flag like any other, and they update
`lastSuccessfulRequestTime`, which seeds the MFA expiry timer.

Code that touches axios directly has to be adapted:

- `new HttpClient()` takes `{ baseUrl, staticHeaders, headers, onResponse }`
instead of `{ config, requestInterceptor, responseInterceptor }`. Clients from
`ClientService` are unaffected.
- The per-request config drops `timeout`, `withCredentials`, `onUploadProgress`,
`cancelToken`, `paramsSerializer`, `transformRequest` / `transformResponse`,
`validateStatus` and `baseURL`; `responseType` drops `document` and `stream`.
- The per-request `headers` are typed as `HeadersInit`, so a `Headers` is accepted
as well as a plain object. Assigning into them after the fact
(`config.headers.Authorization = …`) no longer type-checks; build the object
first, or use `new Headers()` and `set()`.
- `error.response` carries the body on `error.response.data`, as it did with
axios. Its underlying stream is consumed, so `error.response.json()` is not
available.
- `graph()`, `ocs()`, `UrlSign` and `WebDavOptions` take a `FetchClient`, and
the latter two rename `axiosClient` to `httpClient`. `webdav()` is unchanged.
- Graph fields with an OData annotation use their generated camelCase names,
e.g. `atLibreGraphPermissionsActions`. The wire format is unchanged.
- Graph responses are rebuilt from the fields the spec declares, which is what makes
the renaming above possible. A field the spec does not declare is dropped, so the
oCIS-only `attributes` on users is added to the spec before generating. A declared
field the server omits is present and holds `undefined`, so
`'accountEnabled' in user` no longer tells whether the server sent it. Compare
against `undefined` instead.
- `CollaboratorAutoCompleteItem.attributes` is optional, matching the `attributes` on
the generated `User`. The field is only there when
`OCIS_USER_SEARCH_DISPLAYED_ATTRIBUTES` is configured, so read it as
`item.attributes?.join(…)`.
- The generated client loses its `*ApiFactory`, `*ApiFp` and
`*AxiosParamCreator` exports. The `*Api` classes now take one options object
per operation and resolve with the payload.
- `@ownclouders/web-test-helpers` no longer has a `mocks/axios` module path.

https://github.com/owncloud/ocis/pull/12910
1 change: 0 additions & 1 deletion web/packages/web-app-admin-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"@ownclouders/design-system": "workspace:^",
"@ownclouders/web-client": "workspace:^",
"@ownclouders/web-pkg": "workspace:^",
"axios": "^1.18.1",
"email-validator": "^2.0.4",
"fuse.js": "7.3.0",
"lodash-es": "4.18.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const groupsDisplayValue = computed(() => {
.join(', ')
})
const showUserQuota = computed(() => {
return 'total' in (user.drive?.quota || {})
return user.drive?.quota?.total !== undefined
})
const quotaDisplayValue = computed(() => {
return user.drive.quota.total === 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,8 @@ const loginOptions = computed(() => {
]
})
const selectedLoginValue = computed(() => {
return unref(loginOptions).find((option) =>
!('accountEnabled' in unref(editUser))
? option.value === true
: unref(editUser).accountEnabled === option.value
)
const accountEnabled = unref(editUser).accountEnabled ?? true
return unref(loginOptions).find((option) => option.value === accountEnabled)
})
const translatedRoleOptions = computed(() => {
return roles.map((role) => {
Expand Down Expand Up @@ -443,11 +440,14 @@ watch(
() => {
/**
* Property accountEnabled won't be always set, but this still means, that login is allowed.
* So we actually don't need to change the property if missing and not set to forbidden in the UI.
* So we actually don't need to change the property if unset and not set to forbidden in the UI.
* This also avoids the compare save dialog from displaying that there are unsaved changes.
* The value is reset instead of deleted, so that it keeps matching the unset original: the
* graph client materializes every declared field, and the dialog compares with `isEqual`,
* which tells an absent key apart from one holding `undefined`.
*/
if (unref(editUser).accountEnabled === true && !('accountEnabled' in user)) {
delete unref(editUser).accountEnabled
if (unref(editUser).accountEnabled === true && user.accountEnabled === undefined) {
unref(editUser).accountEnabled = undefined
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ const orderBy = (list: User[], prop: string, desc: boolean) => {
b = getRoleDisplayNameByUser(user2)
break
case 'accountEnabled':
a = ('accountEnabled' in user1 ? user1.accountEnabled : true).toString()
b = ('accountEnabled' in user2 ? user2.accountEnabled : true).toString()
a = (user1.accountEnabled ?? true).toString()
b = (user2.accountEnabled ?? true).toString()
break
default:
a = user1[prop as keyof User].toString() || ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import CreateGroupModal from '../../../../src/components/Groups/CreateGroupModal
import {
defaultComponentMocks,
defaultPlugins,
mockAxiosReject,
mockAxiosResolve,
mockHttpError,
mockHttpResponse,
shallowMount
} from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
Expand Down Expand Up @@ -50,7 +50,7 @@ describe('CreateGroupModal', () => {
it('should be true when displayName is valid', async () => {
const { wrapper, mocks } = getWrapper()
const graphMock = mocks.$clientService.graphAuthenticated
const getGroupSub = graphMock.groups.getGroup.mockRejectedValue(() => mockAxiosReject())
const getGroupSub = graphMock.groups.getGroup.mockRejectedValue(() => mockHttpError())
wrapper.vm.group.displayName = 'users'
expect(await wrapper.vm.validateDisplayName()).toBeTruthy()
expect(getGroupSub).toHaveBeenCalled()
Expand Down Expand Up @@ -99,7 +99,7 @@ describe('CreateGroupModal', () => {
await wrapper.vm.validateDisplayName()

mocks.$clientService.graphAuthenticated.groups.createGroup.mockRejectedValue(
mockAxiosResolve({ id: 'e3515ffb-d264-4dfc-8506-6c239f6673b5' })
mockHttpResponse({ id: 'e3515ffb-d264-4dfc-8506-6c239f6673b5' })
)
await wrapper.vm.onConfirm()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import EditPanel from '../../../../../src/components/Groups/SideBar/EditPanel.vu
import {
defaultComponentMocks,
defaultPlugins,
mockAxiosReject,
mockHttpError,
mount
} from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
Expand Down Expand Up @@ -37,7 +37,7 @@ describe('EditPanel', () => {
const { wrapper, mocks } = getWrapper()
;(wrapper.vm as any).editGroup.displayName = 'users'
const graphMock = mocks.$clientService.graphAuthenticated
const getGroupStub = graphMock.groups.getGroup.mockRejectedValue(() => mockAxiosReject())
const getGroupStub = graphMock.groups.getGroup.mockRejectedValue(() => mockHttpError())
expect(await (wrapper.vm as any).validateDisplayName()).toBeTruthy()
expect(getGroupStub).toHaveBeenCalled()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import CreateUserModal from '../../../../src/components/Users/CreateUserModal.vu
import {
defaultComponentMocks,
defaultPlugins,
mockAxiosReject,
mockHttpError,
shallowMount
} from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
Expand Down Expand Up @@ -60,15 +60,15 @@ describe('CreateUserModal', () => {
it('should be true when userName is valid', async () => {
const { wrapper, mocks } = getWrapper()
const graphMock = mocks.$clientService.graphAuthenticated
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockAxiosReject())
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockHttpError())
;(wrapper.vm as any).user.onPremisesSamAccountName = 'jana'
expect(await (wrapper.vm as any).validateUserName()).toBeTruthy()
expect(getUserStub).toHaveBeenCalled()
})
it('should be true when userName is an email address', async () => {
const { wrapper, mocks } = getWrapper()
const graphMock = mocks.$clientService.graphAuthenticated
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockAxiosReject())
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockHttpError())
;(wrapper.vm as any).user.onPremisesSamAccountName = 'sk@domain.tld'
expect(await (wrapper.vm as any).validateUserName()).toBeTruthy()
expect(getUserStub).toHaveBeenCalled()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { User } from '@ownclouders/web-client/graph/generated'
import { DriveFromJSON, User } from '@ownclouders/web-client/graph/generated'
import DetailsPanel from '../../../../../src/components/Users/SideBar/DetailsPanel.vue'
import UserInfoBox from '../../../../../src/components/Users/SideBar/UserInfoBox.vue'
import { PartialComponentProps, defaultPlugins, shallowMount } from '@ownclouders/web-test-helpers'
Expand Down Expand Up @@ -58,6 +58,28 @@ describe('DetailsPanel', () => {
expect(wrapper.find('[data-testid="no-user-selected"]').exists()).toBeFalsy()
})
})
describe('computed method "showUserQuota"', () => {
/**
* The graph client materializes every declared field, so a drive that comes back without a
* quota limit still has a `quota` object carrying `total: undefined`. Going by the presence
* of the key showed the quota with an unknown size instead of hiding it.
*/
it('should be false if the drive has no quota total', () => {
const drive = DriveFromJSON({ id: 'drive', quota: {} })
const { wrapper } = getWrapper({
props: { user: { ...defaultUser, drive } as User, users: [defaultUser] }
})
expect((wrapper.vm as any).showUserQuota).toBeFalsy()
})
it('should be true if the drive has a quota total', () => {
const drive = DriveFromJSON({ id: 'drive', quota: { total: 100 } })
const { wrapper } = getWrapper({
props: { user: { ...defaultUser, drive } as User, users: [defaultUser] }
})
expect((wrapper.vm as any).showUserQuota).toBeTruthy()
})
})

describe('computed method "multipleUsers"', () => {
it('should be false if no users are given', () => {
const { wrapper } = getWrapper({ props: { user: null, users: [] } })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import EditPanel from '../../../../../src/components/Users/SideBar/EditPanel.vue
import {
defaultComponentMocks,
defaultPlugins,
mockAxiosReject,
mockHttpError,
shallowMount
} from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
import { Drive, Group, User } from '@ownclouders/web-client/graph/generated'
import { isEqual } from 'lodash-es'
import { Drive, Group, User, UserFromJSON } from '@ownclouders/web-client/graph/generated'
import { CapabilityStore } from '@ownclouders/web-pkg'
import GroupSelect from '../../../../../src/components/Users/GroupSelect.vue'

Expand Down Expand Up @@ -101,7 +102,7 @@ describe('EditPanel', () => {
it('should be true when userName is valid', async () => {
const { wrapper, mocks } = getWrapper()
const graphMock = mocks.$clientService.graphAuthenticated
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockAxiosReject())
const getUserStub = graphMock.users.getUser.mockRejectedValue(() => mockHttpError())
;(wrapper.vm as any).editUser.onPremisesSamAccountName = 'jana'
expect(await (wrapper.vm as any).validateUserName()).toBeTruthy()
expect(getUserStub).toHaveBeenCalled()
Expand Down Expand Up @@ -156,6 +157,49 @@ describe('EditPanel', () => {
})
})

/**
* A user the server sends without an accountEnabled is allowed to log in. The graph client
* materializes every declared field, so such a user still carries the property, holding
* `undefined` - the field being there says nothing about what the server sent.
*/
describe('computed method "selectedLoginValue"', () => {
const decodedUser = (accountEnabled?: boolean) =>
UserFromJSON({
id: '2',
displayName: 'jan',
onPremisesSamAccountName: 'jan',
memberOf: [],
...(accountEnabled !== undefined && { accountEnabled })
})

it('should select "Allowed" if the user has no accountEnabled', () => {
const { wrapper } = getWrapper({ user: decodedUser() })
expect((wrapper.vm as any).selectedLoginValue.value).toBe(true)
})
it.each([true, false])('should select the option matching an accountEnabled of %s', (value) => {
const { wrapper } = getWrapper({ user: decodedUser(value) })
expect((wrapper.vm as any).selectedLoginValue.value).toBe(value)
})

it('should not report unsaved changes when login stays allowed for an unset accountEnabled', async () => {
const user = decodedUser()
const { wrapper } = getWrapper({ user })
;(wrapper.vm as any).editUser.accountEnabled = true
await wrapper.vm.$nextTick()

// the comparison the save dialog makes, which tells an absent key apart from `undefined`
expect(isEqual(user, (wrapper.vm as any).editUser)).toBe(true)
})
it('should report unsaved changes when login gets forbidden for an unset accountEnabled', async () => {
const user = decodedUser()
const { wrapper } = getWrapper({ user })
;(wrapper.vm as any).editUser.accountEnabled = false
await wrapper.vm.$nextTick()

expect(isEqual(user, (wrapper.vm as any).editUser)).toBe(false)
})
})

describe('group select', () => {
it('takes all available groups', () => {
const { wrapper } = getWrapper()
Expand All @@ -177,8 +221,14 @@ describe('EditPanel', () => {
function getWrapper({
readOnlyUserAttributes = [],
selectedGroups = [],
groups = availableGroupOptions
}: { readOnlyUserAttributes?: string[]; selectedGroups?: Group[]; groups?: Group[] } = {}) {
groups = availableGroupOptions,
user
}: {
readOnlyUserAttributes?: string[]
selectedGroups?: Group[]
groups?: Group[]
user?: User
} = {}) {
const mocks = defaultComponentMocks()
const capabilities = {
graph: { users: { read_only_attributes: readOnlyUserAttributes }, tags: { max_tag_length: 30 } }
Expand All @@ -188,14 +238,16 @@ function getWrapper({
mocks,
wrapper: shallowMount(EditPanel, {
props: {
user: {
id: '2',
displayName: 'jan',
mail: 'jan@owncloud.com',
passwordProfile: { password: '' },
drive: { quota: {} } as Drive,
memberOf: selectedGroups
} as User,
user:
user ??
({
id: '2',
displayName: 'jan',
mail: 'jan@owncloud.com',
passwordProfile: { password: '' },
drive: { quota: {} } as Drive,
memberOf: selectedGroups
} as User),
roles: [{ id: '1', displayName: 'admin' }],
groups,
applicationId: '1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { displayPositionedDropdown, eventBus, queryItemAsString } from '@ownclouders/web-pkg'
import { SideBarEventTopics } from '@ownclouders/web-pkg'
import { useUserSettingsStore } from '../../../../src/composables/stores/userSettings'
import { User } from '@ownclouders/web-client/graph/generated'
import { User, UserFromJSON } from '@ownclouders/web-client/graph/generated'

const getUserMocks = () => [{ id: '1', displayName: 'jan' }] as User[]
vi.mock('@ownclouders/web-pkg', async (importOriginal) => ({
Expand Down Expand Up @@ -93,6 +93,28 @@ describe('UsersList', () => {
{ appRoleAssignments: [{ appRoleId: '1' }] }
])
})

/**
* A user whose accountEnabled the server does not send still carries the property after
* being decoded by the graph client, holding `undefined`. Treating a present key as a sent
* value made the sort read `undefined` and throw.
*/
it('should sort a user without an accountEnabled as being allowed to log in', () => {
const { wrapper } = getWrapper()
const users = [
UserFromJSON({ displayName: 'forbidden', accountEnabled: false }),
UserFromJSON({ displayName: 'unset' })
] as User[]

expect((wrapper.vm as any).orderBy(users, 'accountEnabled', false)).toEqual([
users[0],
users[1]
])
expect((wrapper.vm as any).orderBy(users, 'accountEnabled', true)).toEqual([
users[1],
users[0]
])
})
})
it('should show the context menu on right click', async () => {
const users = getUserMocks()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { unref } from 'vue'
import {
defaultComponentMocks,
RouteLocation,
mockAxiosResolve,
mockHttpResponse,
getComposableWrapper
} from '@ownclouders/web-test-helpers'

Expand All @@ -22,7 +22,7 @@ describe('resetLogo', () => {
it('should show message on request success', () => {
getWrapper({
setup: async ({ actions }, { clientService, router }) => {
clientService.httpAuthenticated.delete.mockResolvedValue(mockAxiosResolve())
clientService.httpAuthenticated.delete.mockResolvedValue(mockHttpResponse())
await unref(actions)[0].handler()
vi.runAllTimers()
expect(router.go).toHaveBeenCalledTimes(1)
Expand Down
Loading
Loading