Only allow workflows on owned boards, and show the board in the table
Build package / php-lint (8.4) (push) Successful in 1m7s
Build package / php-lint (8.5) (push) Successful in 56s
Build package / xml-lint (push) Successful in 17s
Build package / unit-tests (push) Successful in 42s
Build package / package (push) Successful in 1m4s

The board list now only contains boards the current user owns, which
also makes the controller's board validation enforce the owner rule.

The workflow table gains a Board column (Board, Titel, Von Stapel,
Nach Stapel, Filter, E-Mail, Aktiv) and is sorted alphabetically by
those columns in that order.
This commit is contained in:
Patrick Niebeling committed 2026-09-30 09:16:09 +02:00
1 parent 7c7c52cb1c
commit ada16cb101
4 files changed
+85 -22

No files matched your search

+6
View File
@@ -40,6 +40,12 @@ The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::fi
Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns. Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns.
## Workflows only on boards the user owns
A workflow can only be created or saved on a board whose Deck owner is the current user. `DeckIntegrationService::listBoardsForCurrentUser($uid)` returns **only owned boards** (`unwrapUid($board->getOwner()) === $uid`); boards merely shared with the user are dropped. Since `WorkflowController::validate()` checks the board id against that list, the same filter enforces the rule server-side — there is no separate ownership check.
Workflows created before this rule may still sit on a shared board. The runner does not check ownership, so they keep running; in the settings table their board is missing from the list, so the row is marked "Board nicht mehr vorhanden oder nicht dein eigenes" and saving it requires picking an owned board.
## Dead filters are surfaced, never disabled ## Dead filters are surfaced, never disabled
`findBrokenTarget()` covers the board and the two stacks — **not** `filter_user_ids` / `filter_label_ids`. Those are plain id lists compared with `array_intersect()` in `cardMatchesFilters()`, so an entry that no longer exists simply stops matching. With three labels of which one was deleted that is exactly right, and disabling would be wrong. `findBrokenTarget()` covers the board and the two stacks — **not** `filter_user_ids` / `filter_label_ids`. Those are plain id lists compared with `array_intersect()` in `cardMatchesFilters()`, so an entry that no longer exists simply stops matching. With three labels of which one was deleted that is exactly right, and disabling would be wrong.
+7 -5
View File
@@ -85,14 +85,14 @@ class WorkflowController extends OCSController {
bool $notifyEmail = false, bool $notifyEmail = false,
bool $enabled = true, bool $enabled = true,
): DataResponse { ): DataResponse {
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
try { try {
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId()); $workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
} catch (DoesNotExistException $e) { } catch (DoesNotExistException $e) {
throw new OCSNotFoundException('Workflow not found'); throw new OCSNotFoundException('Workflow not found');
} }
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled); $this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
$workflow = $this->workflowMapper->update($workflow); $workflow = $this->workflowMapper->update($workflow);
@@ -114,7 +114,7 @@ class WorkflowController extends OCSController {
#[NoAdminRequired] #[NoAdminRequired]
public function boards(): DataResponse { public function boards(): DataResponse {
$this->assertDeck(); $this->assertDeck();
return new DataResponse($this->deckService->listBoardsForCurrentUser()); return new DataResponse($this->deckService->listBoardsForCurrentUser($this->currentUserId()));
} }
#[NoAdminRequired] #[NoAdminRequired]
@@ -161,12 +161,14 @@ class WorkflowController extends OCSController {
// not "Deck is unreachable". // not "Deck is unreachable".
$this->assertDeck(); $this->assertDeck();
// Only boards the user owns are listed, so this also enforces the
// owner rule.
$boardIds = array_map( $boardIds = array_map(
static fn (array $board) => (int)$board['id'], static fn (array $board) => (int)$board['id'],
$this->deckService->listBoardsForCurrentUser(), $this->deckService->listBoardsForCurrentUser($this->currentUserId()),
); );
if (!in_array($boardId, $boardIds, true)) { if (!in_array($boardId, $boardIds, true)) {
throw new OCSBadRequestException('The selected board does not exist or is not accessible'); throw new OCSBadRequestException('The selected board does not exist or is not owned by you');
} }
$stackIds = array_map( $stackIds = array_map(
+8 -4
View File
@@ -174,13 +174,16 @@ class DeckIntegrationService {
/** /**
* @return array<int, array{id: int, title: string}> * @return array<int, array{id: int, title: string}>
* *
* Only boards owned by `$userId` — workflows may only be created on those,
* so boards merely shared with the user are left out entirely.
*
* Only ever call this from a *request*: getUserBoards() reads the uid * Only ever call this from a *request*: getUserBoards() reads the uid
* BoardService froze at construction time, and beginUserContext() cannot * BoardService froze at construction time, and beginUserContext() cannot
* help here — it repoints that field, but Deck may already have cached * help here — it repoints that field, but Deck may already have cached
* board lists behind it, and the job has no use for a board *list* * board lists behind it, and the job has no use for a board *list*
* anyway. It asks findStackIds() about one known board id instead. * anyway. It asks findStackIds() about one known board id instead.
*/ */
public function listBoardsForCurrentUser(): array { public function listBoardsForCurrentUser(string $userId): array {
$boards = $this->call(function () { $boards = $this->call(function () {
$boardService = $this->resolve(BoardService::class); $boardService = $this->resolve(BoardService::class);
try { try {
@@ -198,12 +201,13 @@ class DeckIntegrationService {
}, []); }, []);
// Deck merges own/group/circle boards, so the same board can come // Deck merges own/group/circle boards, so the same board can come
// back more than once; and boards in the trash or archived ones are // back more than once; boards in the trash or archived ones are no
// no useful automation target. Keyed by id => deduplicated. // useful automation target, and shared ones are not allowed. Keyed
// by id => deduplicated.
$result = []; $result = [];
foreach ($boards as $board) { foreach ($boards as $board) {
$id = (int)$board->getId(); $id = (int)$board->getId();
if (isset($result[$id]) || $this->isBoardHidden($board)) { if (isset($result[$id]) || $this->isBoardHidden($board) || $this->unwrapUid($board->getOwner()) !== $userId) {
continue; continue;
} }
$title = $board->getTitle(); $title = $board->getTitle();
+64 -13
View File
@@ -14,6 +14,7 @@
<table v-if="workflows.length" class="wfda-table"> <table v-if="workflows.length" class="wfda-table">
<thead> <thead>
<tr> <tr>
<th>Board</th>
<th>Titel</th> <th>Titel</th>
<th>Von Stapel</th> <th>Von Stapel</th>
<th>Nach Stapel</th> <th>Nach Stapel</th>
@@ -24,7 +25,8 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="workflow in workflows" :key="workflow.id"> <tr v-for="workflow in sortedWorkflows" :key="workflow.id">
<td>{{ boardLabel(workflow.boardId) }}</td>
<td> <td>
{{ workflow.title }} {{ workflow.title }}
<span v-if="workflowIssues[workflow.id]" class="wfda-issue"> <span v-if="workflowIssues[workflow.id]" class="wfda-issue">
@@ -33,16 +35,9 @@
</td> </td>
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td> <td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td> <td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
<td> <td>{{ filterLabel(workflow) }}</td>
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length">–</span> <td>{{ yesNo(workflow.notifyEmail) }}</td>
<span v-else> <td>{{ yesNo(workflow.enabled) }}</td>
<span v-if="workflow.filterUserIds.length">{{ workflow.filterUserIds.length }} Benutzer</span>
<span v-if="workflow.filterUserIds.length && workflow.filterLabelIds.length">, </span>
<span v-if="workflow.filterLabelIds.length">{{ workflow.filterLabelIds.length }} Label</span>
</span>
</td>
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
<td class="wfda-actions"> <td class="wfda-actions">
<NcButton variant="tertiary" @click="editWorkflow(workflow)"> <NcButton variant="tertiary" @click="editWorkflow(workflow)">
Bearbeiten Bearbeiten
@@ -75,6 +70,9 @@
:reduce="option => option.value" :reduce="option => option.value"
placeholder="Board wählen" placeholder="Board wählen"
@update:model-value="onBoardChange" /> @update:model-value="onBoardChange" />
<NcNoteCard v-if="!boardOptions.length" type="info">
Workflows können nur für Boards angelegt werden, deren Eigentümer du bist.
</NcNoteCard>
<NcSelect <NcSelect
v-model="form.sourceStackId" v-model="form.sourceStackId"
@@ -175,7 +173,40 @@ const participantOptions = ref([])
// Which board the three lists above currently belong to. // Which board the three lists above currently belong to.
const loadedBoardId = ref(null) const loadedBoardId = ref(null)
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title }))) const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
// The backend only returns boards the user owns.
const boardOptions = computed(() => boards.value
.map((board) => ({ value: board.id, label: board.title }))
.sort((a, b) => collator.compare(a.label, b.label)))
/**
* Alphabetical by the columns as displayed, left to right: board, title,
* source stack, target stack, filter, e-mail, active.
*/
const sortedWorkflows = computed(() => {
const sortKeys = (workflow) => [
boardLabel(workflow.boardId),
workflow.title,
stackLabel(workflow.boardId, workflow.sourceStackId),
stackLabel(workflow.boardId, workflow.targetStackId),
filterLabel(workflow),
yesNo(workflow.notifyEmail),
yesNo(workflow.enabled),
]
return workflows.value
.map((workflow) => ({ workflow, keys: sortKeys(workflow) }))
.sort((a, b) => {
for (let i = 0; i < a.keys.length; i++) {
const result = collator.compare(a.keys[i], b.keys[i])
if (result !== 0) {
return result
}
}
return a.workflow.id - b.workflow.id
})
.map((entry) => entry.workflow)
})
// Source and target must differ, so each dropdown hides what the other one // Source and target must differ, so each dropdown hides what the other one
// already holds — the error message on save stays as the backstop for // already holds — the error message on save stays as the backstop for
@@ -199,7 +230,7 @@ const workflowIssues = computed(() => {
for (const workflow of workflows.value) { for (const workflow of workflows.value) {
if (!boards.value.some((board) => board.id === workflow.boardId)) { if (!boards.value.some((board) => board.id === workflow.boardId)) {
issues[workflow.id] = 'Board nicht mehr vorhanden' issues[workflow.id] = 'Board nicht mehr vorhanden oder nicht dein eigenes'
continue continue
} }
@@ -262,6 +293,26 @@ function emptyForm() {
} }
} }
function boardLabel(boardId) {
const board = boards.value.find((entry) => entry.id === boardId)
return board ? board.title : `#${boardId}`
}
function filterLabel(workflow) {
const parts = []
if (workflow.filterUserIds.length) {
parts.push(`${workflow.filterUserIds.length} Benutzer`)
}
if (workflow.filterLabelIds.length) {
parts.push(`${workflow.filterLabelIds.length} Label`)
}
return parts.length ? parts.join(', ') : '–'
}
function yesNo(value) {
return value ? 'Ja' : 'Nein'
}
function stackLabel(boardId, stackId) { function stackLabel(boardId, stackId) {
const stacks = stacksByBoard[boardId] const stacks = stacksByBoard[boardId]
if (!stacks) { if (!stacks) {