Skip to content

Commit d609feb

Browse files
authored
Merge branch 'master' into fix-ldap-groupMemberAssociation-bug
2 parents 35f3a7b + 4e0993c commit d609feb

19 files changed

Lines changed: 865 additions & 25 deletions

.github/workflows/block-merge-freeze.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,4 @@ jobs:
5454

5555
- name: Run check
5656
if: ${{ env.server_ref != '' }}
57-
run: cat version.php | grep 'OC_VersionString' | grep -i -v 'RC'
57+
run: cat version.php | grep 'OC_VersionString' | grep -i -v -E '(\.0\.0 RC[3-9]|\.[1-9][0-9]* RC)'

apps/dav/appinfo/v1/publicwebdav.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
use OC\Files\Filesystem;
99
use OC\Files\Storage\Wrapper\DirPermissionsMask;
10+
use OC\Files\Storage\Wrapper\PermissionsMask;
1011
use OC\Files\View;
1112
use OCA\DAV\Connector\LegacyPublicAuth;
1213
use OCA\DAV\Connector\Sabre\BearerAuth;
@@ -19,6 +20,7 @@
1920
use OCP\BeforeSabrePubliclyLoadedEvent;
2021
use OCP\Constants;
2122
use OCP\EventDispatcher\IEventDispatcher;
23+
use OCP\Files\IHomeStorage;
2224
use OCP\Files\IRootFolder;
2325
use OCP\Files\Mount\IMountManager;
2426
use OCP\Files\Storage\IStorage;
@@ -112,11 +114,17 @@ function (\Sabre\DAV\Server $server) use (
112114
// FIXME: should not add storage wrappers outside of preSetup, need to find a better way
113115
$previousLog = Filesystem::logWarningWhenAddingStorageWrapper(false);
114116
Filesystem::addStorageWrapper('sharePermissions', function (string $mountPoint, IStorage $storage) use ($share) {
115-
return new DirPermissionsMask([
116-
'storage' => $storage,
117-
'mask' => $share->getPermissions() | Constants::PERMISSION_SHARE,
118-
'path' => 'files'
119-
]);
117+
$mask = $share->getPermissions() | Constants::PERMISSION_SHARE;
118+
119+
if ($storage instanceof IHomeStorage) {
120+
return new DirPermissionsMask([
121+
'storage' => $storage,
122+
'mask' => $mask,
123+
'path' => 'files',
124+
]);
125+
} else {
126+
return new PermissionsMask(['storage' => $storage, 'mask' => $mask]);
127+
}
120128
});
121129
Filesystem::addStorageWrapper('shareOwner', function (string $mountPoint, IStorage $storage) use ($share) {
122130
return new PublicOwnerWrapper(['storage' => $storage, 'owner' => $share->getShareOwner()]);

apps/settings/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
['name' => 'AuthSettings#create', 'url' => '/settings/personal/authtokens', 'verb' => 'POST' , 'root' => ''],
1515
['name' => 'AuthSettings#update', 'url' => '/settings/personal/authtokens/{id}', 'verb' => 'PUT' , 'root' => ''],
16+
['name' => 'AuthSettings#destroyOthers', 'url' => '/settings/personal/authtokens', 'verb' => 'DELETE' , 'root' => ''],
1617
['name' => 'AuthSettings#destroy', 'url' => '/settings/personal/authtokens/{id}', 'verb' => 'DELETE' , 'root' => ''],
1718
['name' => 'AuthSettings#wipe', 'url' => '/settings/personal/authtokens/wipe/{id}', 'verb' => 'POST' , 'root' => ''],
1819

apps/settings/lib/Activity/Provider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Provider implements IProvider {
2929
public const APP_TOKEN_CREATED = 'app_token_created';
3030
public const APP_TOKEN_DELETED = 'app_token_deleted';
3131
public const APP_TOKEN_DELETED_WIPE_CANCELLED = 'app_token_deleted_wipe_cancelled';
32+
public const APP_TOKEN_DELETED_ALL = 'app_token_deleted_all';
3233
public const APP_TOKEN_RENAMED = 'app_token_renamed';
3334
public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted';
3435
public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked';
@@ -90,6 +91,13 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
9091
$subject = $this->l->t('You deleted app password "{token}"');
9192
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED_WIPE_CANCELLED) {
9293
$subject = $this->l->t('You deleted app password "{token}" and cancelled its pending remote wipe');
94+
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED_ALL) {
95+
$count = (int)($event->getSubjectParameters()['count'] ?? 0);
96+
$subject = $this->l->n(
97+
'You revoked %n other session',
98+
'You revoked %n other sessions',
99+
$count,
100+
);
93101
} elseif ($event->getSubject() === self::APP_TOKEN_RENAMED) {
94102
$subject = $this->l->t('You renamed app password "{token}" to "{newToken}"');
95103
} elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_GRANTED) {
@@ -121,6 +129,7 @@ protected function getParameters(IEvent $event): array {
121129
case self::PASSWORD_RESET_SELF:
122130
case self::EMAIL_CHANGED_SELF:
123131
case self::EMAIL_CHANGED:
132+
case self::APP_TOKEN_DELETED_ALL:
124133
return [];
125134
case self::PASSWORD_CHANGED_BY:
126135
case self::EMAIL_CHANGED_BY:

apps/settings/lib/Controller/AuthSettingsController.php

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,48 @@ public function destroy(int $id): JSONResponse {
188188
return new JSONResponse([]);
189189
}
190190

191+
/**
192+
* Revoke the tokens of the current user other than the session's own.
193+
*
194+
* Wipe-pending tokens are kept too: revoking one cancels its pending wipe, so
195+
* that stays a per-token decision.
196+
*/
197+
#[NoSubAdminRequired]
198+
#[NoAdminRequired]
199+
#[PasswordConfirmationRequired(strict: true)]
200+
public function destroyOthers(): JSONResponse {
201+
if ($this->checkAppToken()) {
202+
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
203+
}
204+
205+
if ($this->userSession->getImpersonatingUserID() !== null) {
206+
return $this->getServiceNotAvailableResponse();
207+
}
208+
209+
try {
210+
$currentTokenId = $this->tokenProvider->getToken($this->session->getId())->getId();
211+
} catch (SessionNotAvailableException|InvalidTokenException) {
212+
return $this->getServiceNotAvailableResponse();
213+
}
214+
215+
$revoked = [];
216+
foreach ($this->tokenProvider->getTokenByUser($this->userId) as $token) {
217+
if ($token->getId() === $currentTokenId || $token->getType() === IToken::WIPE_TOKEN) {
218+
continue;
219+
}
220+
221+
$this->tokenProvider->invalidateTokenById($this->userId, $token->getId());
222+
$revoked[] = $token->getId();
223+
}
224+
225+
if ($revoked !== []) {
226+
// One aggregate entry rather than one per token, so a bulk revoke does not bury the feed.
227+
$this->publishActivity(Provider::APP_TOKEN_DELETED_ALL, null, ['count' => count($revoked)]);
228+
}
229+
230+
return new JSONResponse(['revoked' => $revoked]);
231+
}
232+
191233
#[NoSubAdminRequired]
192234
#[NoAdminRequired]
193235
#[PasswordConfirmationRequired(strict: true)]
@@ -222,14 +264,20 @@ public function update(int $id, array $scope, string $name): JSONResponse {
222264
return new JSONResponse([]);
223265
}
224266

225-
private function publishActivity(string $subject, int $id, array $parameters = []): void {
267+
/**
268+
* @param int|null $id Token the event is about, or null for events that span several tokens
269+
*/
270+
private function publishActivity(string $subject, ?int $id, array $parameters = []): void {
226271
$event = $this->activityManager->generateEvent();
227272
$event->setApp('settings')
228273
->setType('security')
229274
->setAffectedUser($this->userId)
230275
->setAuthor($this->userId)
231-
->setSubject($subject, $parameters)
232-
->setObject('app_token', $id, 'App Password');
276+
->setSubject($subject, $parameters);
277+
278+
if ($id !== null) {
279+
$event->setObject('app_token', $id, 'App Password');
280+
}
233281

234282
try {
235283
$this->activityManager->publish($event);
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IToken } from '../store/authtoken.ts'
7+
8+
import { createTestingPinia } from '@pinia/testing'
9+
import { mount } from '@vue/test-utils'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
12+
// AuthToken.vue, pulled in transitively, reads window.OC.theme.productName at module
13+
// evaluation time. vi.hoisted runs before imports, so it is set before the SFC is parsed.
14+
vi.hoisted(() => {
15+
(window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' }
16+
})
17+
18+
vi.mock('@nextcloud/initial-state', () => ({
19+
loadState: vi.fn((_app: string, key: string) => (key === 'app_tokens' ? [] : true)),
20+
}))
21+
22+
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
23+
import AuthTokenRevokeAllDialog from './AuthTokenRevokeAllDialog.vue'
24+
import AuthTokenSection from './AuthTokenSection.vue'
25+
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
26+
27+
function makeToken(overrides: Partial<IToken> = {}): IToken {
28+
return {
29+
id: 1,
30+
name: 'Test device',
31+
type: TokenType.PERMANENT_TOKEN,
32+
lastActivity: 1700000000,
33+
canDelete: true,
34+
canRename: true,
35+
scope: { filesystem: true },
36+
...overrides,
37+
}
38+
}
39+
40+
// Renders the `buttons` prop as real buttons, so the callbacks are exercised by clicking
41+
// rather than by reaching into the component instance.
42+
const NcDialogStub = {
43+
props: ['buttons'],
44+
template: '<div><slot /><button v-for="(button, index) in buttons" :key="index" @click="button.callback()">{{ button.label }}</button></div>',
45+
}
46+
47+
const NcButtonStub = {
48+
template: '<button @click="$emit(\'click\')"><slot /></button>',
49+
}
50+
51+
function mountSection(tokens: IToken[]) {
52+
return mount(AuthTokenSection, {
53+
mocks: {
54+
t: (_: string, text: string) => text,
55+
},
56+
stubs: {
57+
AuthTokenList: true,
58+
AuthTokenSetup: true,
59+
NcSettingsSection: { template: '<div><slot /></div>' },
60+
NcButton: NcButtonStub,
61+
NcDialog: NcDialogStub,
62+
},
63+
pinia: createTestingPinia({
64+
createSpy: vi.fn,
65+
initialState: { 'auth-token': { tokens } },
66+
}),
67+
})
68+
}
69+
70+
function mountDialog(props: { count: number, wipePendingCount: number, open?: boolean }) {
71+
return mount(AuthTokenRevokeAllDialog, {
72+
propsData: { open: true, ...props },
73+
mocks: {
74+
t: (_: string, text: string) => text,
75+
},
76+
stubs: {
77+
NcDialog: NcDialogStub,
78+
},
79+
})
80+
}
81+
82+
describe('AuthTokenSection revoke-all button', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
})
86+
87+
it('hides the button when only the current session exists', () => {
88+
const wrapper = mountSection([makeToken({ id: 1, current: true })])
89+
90+
expect(wrapper.find('button').exists()).toBe(false)
91+
expect(wrapper.findComponent(AuthTokenRevokeAllDialog).exists()).toBe(false)
92+
})
93+
94+
it('hides the button when the only other token is wipe-pending', () => {
95+
const wrapper = mountSection([
96+
makeToken({ id: 1, current: true }),
97+
makeToken({ id: 2, type: TokenType.WIPING_TOKEN }),
98+
])
99+
100+
expect(wrapper.find('button').exists()).toBe(false)
101+
})
102+
103+
it('shows the button and opens the dialog without revoking anything yet', async () => {
104+
const wrapper = mountSection([
105+
makeToken({ id: 1, current: true }),
106+
makeToken({ id: 2 }),
107+
])
108+
const store = useAuthTokenStore()
109+
110+
const button = wrapper.find('button')
111+
expect(button.exists()).toBe(true)
112+
113+
await button.trigger('click')
114+
115+
const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
116+
expect(dialog.exists()).toBe(true)
117+
expect(dialog.props('open')).toBe(true)
118+
expect(store.deleteAllOtherTokens).not.toHaveBeenCalled()
119+
})
120+
121+
// One-time tokens are hidden from the table but still revoked, so they count.
122+
it('counts every other token except the wipe-pending ones', async () => {
123+
const wrapper = mountSection([
124+
makeToken({ id: 1, current: true }),
125+
makeToken({ id: 2 }),
126+
makeToken({ id: 3 }),
127+
makeToken({ id: 4, type: TokenType.WIPING_TOKEN }),
128+
makeToken({ id: 5, type: TokenType.ONETIME_TOKEN }),
129+
])
130+
131+
await wrapper.find('button').trigger('click')
132+
133+
const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
134+
expect(dialog.props('count')).toBe(3)
135+
expect(dialog.props('wipePendingCount')).toBe(1)
136+
})
137+
138+
it('revokes only after the dialog emits confirm', async () => {
139+
const wrapper = mountSection([
140+
makeToken({ id: 1, current: true }),
141+
makeToken({ id: 2 }),
142+
])
143+
const store = useAuthTokenStore()
144+
145+
await wrapper.find('button').trigger('click')
146+
147+
const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
148+
dialog.vm.$emit('confirm')
149+
dialog.vm.$emit('update:open', false)
150+
await wrapper.vm.$nextTick()
151+
152+
expect(store.deleteAllOtherTokens).toHaveBeenCalledTimes(1)
153+
})
154+
155+
it('does not revoke when the dialog is dismissed', async () => {
156+
const wrapper = mountSection([
157+
makeToken({ id: 1, current: true }),
158+
makeToken({ id: 2 }),
159+
])
160+
const store = useAuthTokenStore()
161+
162+
await wrapper.find('button').trigger('click')
163+
164+
const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
165+
dialog.vm.$emit('update:open', false)
166+
await wrapper.vm.$nextTick()
167+
168+
expect(wrapper.findComponent(AuthTokenRevokeAllDialog).exists()).toBe(false)
169+
expect(store.deleteAllOtherTokens).not.toHaveBeenCalled()
170+
})
171+
})
172+
173+
describe('AuthTokenRevokeAllDialog', () => {
174+
it('omits the wipe note when nothing is pending a wipe', () => {
175+
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
176+
expect(wrapper.findComponent(NcNoteCard).exists()).toBe(false)
177+
})
178+
179+
it('warns that wipe-pending devices keep access', () => {
180+
const wrapper = mountDialog({ count: 3, wipePendingCount: 2 })
181+
182+
const noteCard = wrapper.findComponent(NcNoteCard)
183+
expect(noteCard.exists()).toBe(true)
184+
expect(noteCard.props('type')).toBe('warning')
185+
expect(noteCard.text()).toMatch(/wipe/i)
186+
})
187+
188+
it('tells the user the current session is kept', () => {
189+
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
190+
expect(wrapper.text()).toMatch(/stay signed in here/i)
191+
})
192+
193+
it('emits confirm and closes when the destructive button is used', async () => {
194+
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
195+
196+
const buttons = wrapper.findAll('button')
197+
await buttons.at(buttons.length - 1).trigger('click')
198+
199+
expect(wrapper.emitted('confirm')).toHaveLength(1)
200+
expect(wrapper.emitted('update:open')).toEqual([[false]])
201+
})
202+
203+
it('closes without confirming when cancelled', async () => {
204+
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
205+
206+
await wrapper.findAll('button').at(0).trigger('click')
207+
208+
expect(wrapper.emitted('confirm')).toBeFalsy()
209+
expect(wrapper.emitted('update:open')).toEqual([[false]])
210+
})
211+
})

0 commit comments

Comments
 (0)