diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
index 0d9c190c..e50ecce2 100644
--- a/.github/workflows/sbom.yml
+++ b/.github/workflows/sbom.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [main, development]
+permissions:
+ contents: write
+ pull-requests: write
+
jobs:
sbom:
runs-on: ubuntu-latest
@@ -79,7 +83,18 @@ jobs:
- name: npm audit
run: npm audit --audit-level=critical
- - name: Commit SBOM
+ # Protected branches (main, development, beta) reject direct pushes per
+ # the org ruleset, so route SBOM updates through a PR instead. On any
+ # other branch (feature/**, bugfix/**, hotfix/**) commit + push direct
+ # since they aren't protected. Skip entirely on pull_request events —
+ # the SBOM is already validated by the steps above; merging the PR will
+ # re-trigger this workflow on the target branch.
+ - name: Commit SBOM (unprotected branches)
+ if: |
+ github.event_name == 'push' &&
+ github.ref_name != 'main' &&
+ github.ref_name != 'development' &&
+ github.ref_name != 'beta'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -91,6 +106,26 @@ jobs:
git push
fi
+ - name: Open PR with SBOM update (protected branches)
+ if: |
+ github.event_name == 'push' &&
+ (github.ref_name == 'main' || github.ref_name == 'development' || github.ref_name == 'beta')
+ uses: peter-evans/create-pull-request@v6
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: "chore: update SBOM"
+ title: "chore: update SBOM (auto-generated)"
+ body: |
+ Auto-generated SBOM regeneration after a push to `${{ github.ref_name }}`.
+
+ Source run: [${{ github.workflow }} #${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
+
+ Merge to keep `sbom.cdx.json` in sync with the locked dependency tree on `${{ github.ref_name }}`.
+ branch: chore/sbom-update-${{ github.ref_name }}
+ base: ${{ github.ref_name }}
+ add-paths: sbom.cdx.json
+ delete-branch: true
+
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
diff --git a/composer.json b/composer.json
index 0659b8ef..dd072397 100644
--- a/composer.json
+++ b/composer.json
@@ -41,7 +41,7 @@
"phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
"phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
"psalm": "./vendor/bin/psalm --threads=1 --no-cache || echo 'Psalm not installed, skipping...'",
- "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G || echo 'PHPStan not installed, skipping...'",
+ "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
"test": "phpunit --configuration phpunit.xml",
"test:unit": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
"test:all": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
diff --git a/docs/screenshots/admin-settings.png b/docs/screenshots/admin-settings.png
new file mode 100644
index 00000000..d96f2972
Binary files /dev/null and b/docs/screenshots/admin-settings.png differ
diff --git a/docs/screenshots/customize-panel-dashboards.png b/docs/screenshots/customize-panel-dashboards.png
new file mode 100644
index 00000000..c50e87d1
Binary files /dev/null and b/docs/screenshots/customize-panel-dashboards.png differ
diff --git a/docs/screenshots/customize-panel-widgets.png b/docs/screenshots/customize-panel-widgets.png
new file mode 100644
index 00000000..a1ddde18
Binary files /dev/null and b/docs/screenshots/customize-panel-widgets.png differ
diff --git a/docs/screenshots/template-create-modal.png b/docs/screenshots/template-create-modal.png
new file mode 100644
index 00000000..19241f0b
Binary files /dev/null and b/docs/screenshots/template-create-modal.png differ
diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php
index c8e9fd6c..3bc7114e 100644
--- a/lib/Controller/AdminController.php
+++ b/lib/Controller/AdminController.php
@@ -355,9 +355,10 @@ public function updateGroupOrder(mixed $groups=null): JSONResponse
try {
$this->settingsService->setGroupOrder(groupIds: $groups);
- } catch (InvalidArgumentException $e) {
+ } catch (InvalidArgumentException) {
+ // ADR-005: do not leak raw exception messages.
return new JSONResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Invalid groups payload'],
statusCode: Http::STATUS_BAD_REQUEST
);
}
diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php
index 3d728f28..6022c8e4 100644
--- a/lib/Controller/DashboardApiController.php
+++ b/lib/Controller/DashboardApiController.php
@@ -432,9 +432,10 @@ public function getGroup(
groupId: $groupId,
uuid: $uuid
);
- } catch (DoesNotExistException $e) {
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new JSONResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
}
@@ -496,9 +497,10 @@ public function updateGroup(
return ResponseHelper::success(
data: ['dashboard' => $dashboard->jsonSerialize()]
);
- } catch (DoesNotExistException $e) {
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new JSONResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
} catch (\Exception $e) {
@@ -543,9 +545,10 @@ public function deleteGroup(
);
return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (DoesNotExistException $e) {
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new JSONResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
} catch (\Exception $e) {
@@ -608,9 +611,10 @@ public function setGroupDefault(
'uuid' => $uuid,
]
);
- } catch (DoesNotExistException $e) {
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new JSONResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
} catch (\Exception $e) {
diff --git a/lib/Controller/DashboardShareApiController.php b/lib/Controller/DashboardShareApiController.php
index 0855a3ed..83f6bfcf 100644
--- a/lib/Controller/DashboardShareApiController.php
+++ b/lib/Controller/DashboardShareApiController.php
@@ -92,9 +92,10 @@ public function index(int $id): DataResponse
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception $e) {
+ } catch (Exception) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Forbidden'],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -136,9 +137,10 @@ public function create(
data: $share->jsonSerialize(),
statusCode: Http::STATUS_CREATED
);
- } catch (InvalidArgumentException $e) {
+ } catch (InvalidArgumentException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Invalid request'],
statusCode: Http::STATUS_BAD_REQUEST
);
} catch (DoesNotExistException) {
@@ -146,9 +148,10 @@ public function create(
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception $e) {
+ } catch (Exception) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Forbidden'],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -182,9 +185,10 @@ public function destroy(int $shareId): DataResponse
data: ['error' => 'Share not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception $e) {
+ } catch (Exception) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Forbidden'],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -223,9 +227,10 @@ public function replace(int $id, ?array $shares=null): DataResponse
array: $newShares
);
return new DataResponse(data: $serialized);
- } catch (InvalidArgumentException $e) {
+ } catch (InvalidArgumentException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Invalid request'],
statusCode: Http::STATUS_BAD_REQUEST
);
} catch (DoesNotExistException) {
@@ -233,9 +238,10 @@ public function replace(int $id, ?array $shares=null): DataResponse
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception $e) {
+ } catch (Exception) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Forbidden'],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -269,9 +275,10 @@ public function revokeForRecipient(
callerId: $this->userId
);
return new DataResponse(data: ['deleted' => $count]);
- } catch (InvalidArgumentException $e) {
+ } catch (InvalidArgumentException) {
+ // ADR-005: do not leak raw exception messages to clients.
return new DataResponse(
- data: ['error' => $e->getMessage()],
+ data: ['error' => 'Invalid request'],
statusCode: Http::STATUS_BAD_REQUEST
);
}
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 209e6169..9ec92f87 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -45,17 +45,18 @@ class PageController extends Controller
/**
* Constructor
*
- * @param IRequest $request The request.
- * @param IInitialState $initialState Nextcloud initial-state service.
- * @param IDashboardManager $dashboardManager Dashboard widget manager.
- * @param IUserSession $userSession Current user session.
- * @param IGroupManager $groupManager Group membership lookup.
- * @param AdminSettingsService $adminSettings MyDash admin settings.
- * @param DashboardService $dashboardService Dashboard service (active
- * resolver — REQ-DASH-018).
- * @param AdminTemplateService $templateService Template service (primary
- * group resolver —
- * REQ-TMPL-012).
+ * @param IRequest $request The request.
+ * @param IInitialState $initialState Nextcloud initial-state service.
+ * @param IDashboardManager $dashboardManager Dashboard widget manager.
+ * @param IUserSession $userSession Current user session.
+ * @param IGroupManager $groupManager Group membership lookup.
+ * @param AdminSettingsService $adminSettings MyDash admin settings.
+ * @param DashboardService $dashboardService Dashboard service (active
+ * resolver —
+ * REQ-DASH-018).
+ * @param AdminTemplateService $templateService Template service (primary
+ * group resolver —
+ * REQ-TMPL-012).
*/
public function __construct(
IRequest $request,
@@ -88,18 +89,19 @@ public function index(): TemplateResponse
// Load all widget scripts so legacy widgets can register their callbacks.
$widgets = $this->loadWidgetScripts();
- $user = $this->userSession->getUser();
- $isAdmin = false;
- $userId = null;
+ $user = $this->userSession->getUser();
+ $isAdmin = false;
+ $userId = null;
if ($user !== null) {
$userId = $user->getUID();
$isAdmin = $this->groupManager->isAdmin(userId: $userId);
}
// Resolve the primary group via the canonical REQ-TMPL-012 authority.
- $primaryGroup = ($userId !== null)
- ? $this->templateService->resolvePrimaryGroup(userId: $userId)
- : Dashboard::DEFAULT_GROUP_ID;
+ $primaryGroup = Dashboard::DEFAULT_GROUP_ID;
+ if ($userId !== null) {
+ $primaryGroup = $this->templateService->resolvePrimaryGroup(userId: $userId);
+ }
$primaryGroupName = $primaryGroup;
if ($primaryGroup !== 'default'
diff --git a/lib/Controller/ResponseHelper.php b/lib/Controller/ResponseHelper.php
index d9e6f616..9bc2eb27 100644
--- a/lib/Controller/ResponseHelper.php
+++ b/lib/Controller/ResponseHelper.php
@@ -64,12 +64,12 @@ public static function forbidden(
* exception is recorded in the server log; the client only ever sees
* the generic `$message` (defaulting to "Operation failed").
*
- * @param \Exception $exception The exception.
- * @param int $statusCode The HTTP status code.
- * @param LoggerInterface|null $logger When provided, the exception is
- * logged at ERROR level before
- * the response is returned.
- * @param string $message Generic client-facing message.
+ * @param \Exception $exception The exception.
+ * @param int $statusCode The HTTP status code.
+ * @param LoggerInterface|null $logger When provided, the exception is
+ * logged at ERROR level before
+ * the response is returned.
+ * @param string $message Generic client-facing message.
*
* @return JSONResponse The error response.
*/
diff --git a/lib/Db/AdminSettingMapper.php b/lib/Db/AdminSettingMapper.php
index 247ee418..5b4c3e75 100644
--- a/lib/Db/AdminSettingMapper.php
+++ b/lib/Db/AdminSettingMapper.php
@@ -104,13 +104,13 @@ public function setSetting(string $key, mixed $value): AdminSetting
try {
$setting = $this->findByKey(key: $key);
$setting->setValueEncoded($value);
- $setting->setUpdatedAt(new DateTime());
+ $setting->setUpdatedAt((new DateTime())->format(format: 'Y-m-d H:i:s'));
return $this->update(entity: $setting);
} catch (DoesNotExistException) {
$setting = new AdminSetting();
$setting->setSettingKey($key);
$setting->setValueEncoded($value);
- $setting->setUpdatedAt(new DateTime());
+ $setting->setUpdatedAt((new DateTime())->format(format: 'Y-m-d H:i:s'));
return $this->insert(entity: $setting);
}
}//end setSetting()
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index 95b0181b..e5e63e5b 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -139,15 +139,15 @@ public function prepare(
*
* Subject parameters: [sharerUserId, dashboardName, permissionLevel].
*
- * @param INotification $notification The notification.
- * @param \OCP\L10N\IL10N $l The L10N instance.
- * @param string $url The deep-link URL.
+ * @param INotification $notification The notification.
+ * @param \OCP\IL10N $l The L10N instance.
+ * @param string $url The deep-link URL.
*
* @return INotification The prepared notification.
*/
private function prepareDashboardShared(
INotification $notification,
- \OCP\L10N\IL10N $l,
+ \OCP\IL10N $l,
string $url
): INotification {
$params = $notification->getSubjectParameters();
@@ -175,7 +175,7 @@ private function prepareDashboardShared(
message: $levelLabel,
parameters: []
);
- $notification->setParsedMessage(subject: $levelLabel);
+ $notification->setParsedMessage(message: $levelLabel);
$notification->setLink(link: $url);
@@ -187,15 +187,15 @@ private function prepareDashboardShared(
*
* Subject parameters: [dashboardName].
*
- * @param INotification $notification The notification.
- * @param \OCP\L10N\IL10N $l The L10N instance.
- * @param string $url The deep-link URL.
+ * @param INotification $notification The notification.
+ * @param \OCP\IL10N $l The L10N instance.
+ * @param string $url The deep-link URL.
*
* @return INotification The prepared notification.
*/
private function prepareOwnershipTransferred(
INotification $notification,
- \OCP\L10N\IL10N $l,
+ \OCP\IL10N $l,
string $url
): INotification {
$params = $notification->getSubjectParameters();
@@ -217,7 +217,7 @@ private function prepareOwnershipTransferred(
message: $message,
parameters: []
);
- $notification->setParsedMessage(subject: $message);
+ $notification->setParsedMessage(message: $message);
$notification->setLink(link: $url);
@@ -261,13 +261,13 @@ private function buildDashboardUrl(string $objectId): string
/**
* Return the human-readable label for a permission level.
*
- * @param \OCP\L10N\IL10N $l The L10N instance.
- * @param string $level The permission level identifier.
+ * @param \OCP\IL10N $l The L10N instance.
+ * @param string $level The permission level identifier.
*
* @return string The translated label.
*/
private function permissionLabel(
- \OCP\L10N\IL10N $l,
+ \OCP\IL10N $l,
string $level
): string {
return match ($level) {
diff --git a/lib/Service/AdminTemplateService.php b/lib/Service/AdminTemplateService.php
index f4b38a9c..d8868a5e 100644
--- a/lib/Service/AdminTemplateService.php
+++ b/lib/Service/AdminTemplateService.php
@@ -25,7 +25,6 @@
use OCA\MyDash\Db\WidgetPlacementMapper;
use OCP\IGroupManager;
use OCP\IUserManager;
-use Ramsey\Uuid\Uuid;
/**
* Service for admin template CRUD operations.
@@ -110,7 +109,7 @@ public function createTemplate(
}
$template = new Dashboard();
- $template->setUuid(Uuid::uuid4()->toString());
+ $template->setUuid($this->generateUuid());
$template->setName($name);
$template->setDescription($description);
$template->setType(Dashboard::TYPE_ADMIN_TEMPLATE);
@@ -327,4 +326,22 @@ public static function pickFirstMatch(
return null;
}//end pickFirstMatch()
+
+
+ /**
+ * Generate a UUID v4.
+ *
+ * @return string The generated UUID.
+ */
+ private function generateUuid(): string
+ {
+ $data = random_bytes(length: 16);
+ $data[6] = chr(codepoint: ord(character: $data[6]) & 0x0f | 0x40);
+ $data[8] = chr(codepoint: ord(character: $data[8]) & 0x3f | 0x80);
+
+ return vsprintf(
+ format: '%s%s-%s-%s-%s-%s%s%s',
+ values: str_split(string: bin2hex(string: $data), length: 4)
+ );
+ }//end generateUuid()
}//end class
diff --git a/lib/Service/DashboardFactory.php b/lib/Service/DashboardFactory.php
index de067856..53a2c0fd 100644
--- a/lib/Service/DashboardFactory.php
+++ b/lib/Service/DashboardFactory.php
@@ -36,18 +36,27 @@ class DashboardFactory
* mismatch — no row is persisted in that case (the caller never
* receives an entity to insert).
*
- * @param string|null $userId The user ID — must be non-null for
- * `TYPE_USER`, must be null for
- * `TYPE_GROUP_SHARED` /
- * `TYPE_ADMIN_TEMPLATE`.
- * @param string $name The dashboard name.
- * @param string|null $description The dashboard description.
- * @param string $type The dashboard type
- * (default {@see Dashboard::TYPE_USER}).
- * @param string|null $groupId The group ID — required when
- * `type === TYPE_GROUP_SHARED`,
- * forbidden otherwise.
- * @param int $gridColumns The grid column count.
+ * @param string|null $userId The user ID — must be non-null
+ * for `TYPE_USER`, must be null for
+ * `TYPE_GROUP_SHARED` /
+ * `TYPE_ADMIN_TEMPLATE`.
+ * @param string $name The dashboard name.
+ * @param string|null $description The dashboard description.
+ * @param string $type The dashboard type
+ * (default {@see
+ * Dashboard::TYPE_USER}).
+ * @param string|null $groupId The group ID — required
+ * when `type ===
+ * TYPE_GROUP_SHARED`,
+ * forbidden otherwise.
+ * @param int $gridColumns The grid column count.
+ * @param string $permissionLevel The owner's permission level on
+ * this dashboard. Defaults to
+ * {@see Dashboard::PERMISSION_FULL};
+ * callers may pass a more
+ * restrictive level when forking
+ * a shared dashboard or
+ * creating a read-only template.
*
* @return Dashboard The created dashboard entity (not yet persisted).
*
@@ -60,7 +69,8 @@ public function create(
?string $description=null,
string $type=Dashboard::TYPE_USER,
?string $groupId=null,
- int $gridColumns=12
+ int $gridColumns=12,
+ string $permissionLevel=Dashboard::PERMISSION_FULL
): Dashboard {
$this->assertTypeGroupInvariant(type: $type, groupId: $groupId);
@@ -73,7 +83,7 @@ public function create(
$dashboard->setUserId($userId);
$dashboard->setGroupId($groupId);
$dashboard->setGridColumns($gridColumns);
- $dashboard->setPermissionLevel(Dashboard::PERMISSION_FULL);
+ $dashboard->setPermissionLevel($permissionLevel);
// Group-shared dashboards are not "active" per-user — activation
// is a personal-scope concept tied to the active-dashboard cookie.
$isActive = 0;
diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php
index 8089e06f..2ad29c18 100644
--- a/lib/Service/FileService.php
+++ b/lib/Service/FileService.php
@@ -28,6 +28,8 @@
use OCA\MyDash\Exception\ForbiddenExtensionException;
use OCA\MyDash\Exception\InvalidDirectoryException;
use OCA\MyDash\Exception\InvalidFilenameException;
+use OCP\Files\File;
+use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IURLGenerator;
@@ -110,19 +112,32 @@ public function createFile(
$userFolder->newFolder(path: $normalizedDir);
}
- $targetFolder = $userFolder->get(path: $normalizedDir);
+ $resolved = $userFolder->get(path: $normalizedDir);
+ if (($resolved instanceof Folder) === false) {
+ throw new \RuntimeException(
+ 'Expected '.$normalizedDir.' to be a folder, got file'
+ );
+ }
+
+ $targetFolder = $resolved;
}
// Overwrite if the file already exists; create otherwise.
if ($targetFolder->nodeExists(path: $filename) === true) {
- // @phpstan-ignore-next-line
- $file = $targetFolder->get(path: $filename);
- $file->putContent(data: $content);
+ $existing = $targetFolder->get(path: $filename);
+ if (($existing instanceof File) === false) {
+ throw new \RuntimeException(
+ 'Expected '.$filename.' to be a file, got folder'
+ );
+ }
+
+ $file = $existing;
} else {
$file = $targetFolder->newFile(path: $filename);
- $file->putContent(data: $content);
}
+ $file->putContent(data: $content);
+
$fileId = $file->getId();
$url = $this->urlGenerator->linkToRouteAbsolute(
diff --git a/mydash-walkthrough/findings.md b/mydash-walkthrough/findings.md
new file mode 100644
index 00000000..932d6820
--- /dev/null
+++ b/mydash-walkthrough/findings.md
@@ -0,0 +1,11 @@
+# MyDash Walkthrough Findings — 2026-04-29
+
+## Console findings — initial dashboard load (/apps/mydash/ as mydash-admin)
+
+### MyDash bugs (in mydash-main.js)
+- **[WARN] NcButton missing `text` or `ariaLabel`** — 3 occurrences (accessibility)
+- **[WARN] NcInputField missing `label` prop** — 1 occurrence
+- **[vue-select warn] Label key "option.Icon" does not exist** — icon picker uses wrong label key (likely tile create modal)
+
+### External (NOT mydash bugs, logging for awareness)
+- opencatalogi widgets emit 6 errors (`appName`/`appVersion` not set in @nextcloud/vue) — opencatalogi bug
diff --git a/openspec/changes/image-widget/proposal.md b/openspec/changes/image-widget/proposal.md
index 9c5b3f47..4c22fefd 100644
--- a/openspec/changes/image-widget/proposal.md
+++ b/openspec/changes/image-widget/proposal.md
@@ -2,14 +2,14 @@
## Why
-MyDash today has no first-class way to put a single image on a dashboard. Users currently jam logos, screenshots, branding, and decorative imagery into the markdown widget via `
` tags or rely on the iframe widget pointing at an external image URL — both workarounds. Neither path supports proper `object-fit` control, broken-image fallback, click-through to a target URL, or the upload-a-file UX users expect from a dashboard product. Competitor dashboards (Sendent, Grafana, Microsoft Power BI tiles) all ship a dedicated image widget. We need parity, with the small UX upgrade that the cell only looks clickable when there is actually a link to click.
+MyDash today has no first-class way to put a single image on a dashboard. Users currently jam logos, screenshots, branding, and decorative imagery into the markdown widget via `
` tags or rely on the iframe widget pointing at an external image URL — both workarounds. Neither path supports proper `object-fit` control, broken-image fallback, click-through to a target URL, or the upload-a-file UX users expect from a dashboard product. Competitor dashboards (Grafana, Microsoft Power BI tiles, and similar) all ship a dedicated image widget. We need parity, with the small UX upgrade that the cell only looks clickable when there is actually a link to click.
## What Changes
- Add a new widget type `image` rendered via `src/components/Widgets/Renderers/ImageWidget.vue`.
- Persisted shape: `{type: 'image', content: {url, alt, link, fit}}`. `fit` defaults to `'cover'` and is restricted to `'cover' | 'contain' | 'fill' | 'none'` by a Vue prop validator (with fallback to `'cover'` on unknown input).
- The form (`src/components/Widgets/Forms/ImageForm.vue`) offers two ways to set `url`: file upload (handed to the resource-uploads endpoint) OR direct URL string. It also exposes `alt`, `link`, `fit`, and a live preview thumbnail.
-- Click-through: when `link` is non-empty, clicking the cell opens the link via `window.open(link, '_blank', 'noopener,noreferrer')`. When `link` is empty there is no navigation AND `cursor` stays default (deliberate fix for the Sendent UX bug where every image cell looked clickable even without a link).
+- Click-through: when `link` is non-empty, clicking the cell opens the link via `window.open(link, '_blank', 'noopener,noreferrer')`. When `link` is empty there is no navigation AND `cursor` stays default (deliberate UX choice — no misleading clickable affordance when there is nothing to click).
- Empty-URL placeholder: 48 px camera icon + `t('No image')` centred, in `var(--color-text-maxcontrast)`.
- Broken-image fallback: `
` swaps in the same placeholder plus the annotation `t('Image failed to load')`. Must not crash the surrounding GridStack grid.
- Register the new type in `src/constants/widgetRegistry.js` with defaults `{url:'', alt:'', link:'', fit:'cover'}`.
diff --git a/openspec/changes/image-widget/specs/image-widget/spec.md b/openspec/changes/image-widget/specs/image-widget/spec.md
index aebc0dec..513a64c0 100644
--- a/openspec/changes/image-widget/specs/image-widget/spec.md
+++ b/openspec/changes/image-widget/specs/image-widget/spec.md
@@ -68,7 +68,7 @@ When `url` is empty or null, the renderer MUST display a placeholder consisting
### Requirement: REQ-IMG-003 Click-through link behaviour
-When the persisted `link` field is non-empty, a click on the cell MUST open `link` in a new tab via `window.open(link, '_blank', 'noopener,noreferrer')`. The cell wrapper MUST set `cursor: pointer` only when `link` is non-empty — when there is no link the cursor MUST remain default so users are not given a misleading clickable affordance (this is a deliberate fix of the Sendent UX bug where every image cell looked clickable even without a link).
+When the persisted `link` field is non-empty, a click on the cell MUST open `link` in a new tab via `window.open(link, '_blank', 'noopener,noreferrer')`. The cell wrapper MUST set `cursor: pointer` only when `link` is non-empty — when there is no link the cursor MUST remain default so users are not given a misleading clickable affordance.
#### Scenario: Click opens link in new tab
diff --git a/openspec/changes/link-button-widget/proposal.md b/openspec/changes/link-button-widget/proposal.md
index a423ba73..89bd58a1 100644
--- a/openspec/changes/link-button-widget/proposal.md
+++ b/openspec/changes/link-button-widget/proposal.md
@@ -2,7 +2,7 @@
## Why
-MyDash today has no first-class action button. Users cannot drop a styled clickable tile on a dashboard to open an external URL in a new tab, kick off a registered in-app workflow, or create a fresh document in their Files app. Sendent-era code shipped an ad-hoc "internal action" placeholder with auto-detect-from-extension semantics that proved fragile (a `.docx` URL meant "create file" but a `.html` URL meant "open external", indistinguishable for opaque links). This change formalises a typed `link` widget with three explicit action types, a runtime-mutable registry of named internal actions, and a strictly-validated server endpoint for file creation, so the action set can grow safely without regressing the existing widget contract.
+MyDash today has no first-class action button. Users cannot drop a styled clickable tile on a dashboard to open an external URL in a new tab, kick off a registered in-app workflow, or create a fresh document in their Files app. An earlier prototype shipped an ad-hoc "internal action" placeholder with auto-detect-from-extension semantics that proved fragile (a `.docx` URL meant "create file" but a `.html` URL meant "open external", indistinguishable for opaque links). This change formalises a typed `link` widget with three explicit action types, a runtime-mutable registry of named internal actions, and a strictly-validated server endpoint for file creation, so the action set can grow safely without regressing the existing widget contract.
## What Changes
diff --git a/sbom.cdx.json b/sbom.cdx.json
index af4d1e2d..73d37515 100644
--- a/sbom.cdx.json
+++ b/sbom.cdx.json
@@ -2,10 +2,10 @@
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
- "serialNumber": "urn:uuid:63778652-7d92-4d43-894d-552f1737e964",
+ "serialNumber": "urn:uuid:66ba57e1-9658-4efa-876d-e83cfbe6eb17",
"version": 1,
"metadata": {
- "timestamp": "2026-04-30T21:15:36Z",
+ "timestamp": "2026-05-01T06:06:11Z",
"tools": [
{
"name": "composer",
@@ -82,10 +82,10 @@
}
],
"component": {
- "bom-ref": "mydash/mydash-dev-feature/template-and-adr-cleanup",
+ "bom-ref": "mydash/mydash-dev-chore/mydash-followup-deltas",
"type": "application",
"name": "mydash",
- "version": "dev-feature/template-and-adr-cleanup",
+ "version": "dev-chore/mydash-followup-deltas",
"group": "mydash",
"description": "Enhanced dashboard with grid layout and admin controls for Nextcloud",
"author": "MyDash Contributors",
@@ -96,15 +96,15 @@
}
}
],
- "purl": "pkg:composer/mydash/mydash@dev-feature/template-and-adr-cleanup",
+ "purl": "pkg:composer/mydash/mydash@dev-chore/mydash-followup-deltas",
"properties": [
{
"name": "cdx:composer:package:distReference",
- "value": "462587d3c7c5821c665e2956c06183e57fafbabc"
+ "value": "ac6295af7749eb066ef9479dfe707f2d7bfce3dc"
},
{
"name": "cdx:composer:package:sourceReference",
- "value": "462587d3c7c5821c665e2956c06183e57fafbabc"
+ "value": "ac6295af7749eb066ef9479dfe707f2d7bfce3dc"
},
{
"name": "cdx:composer:package:type",
@@ -17764,7 +17764,7 @@
],
"dependencies": [
{
- "ref": "mydash/mydash-dev-feature/template-and-adr-cleanup"
+ "ref": "mydash/mydash-dev-chore/mydash-followup-deltas"
}
]
}
diff --git a/src/components/Widgets/WidgetContextMenu.vue b/src/components/Widgets/WidgetContextMenu.vue
index 32b1eebe..66004dd6 100644
--- a/src/components/Widgets/WidgetContextMenu.vue
+++ b/src/components/Widgets/WidgetContextMenu.vue
@@ -42,7 +42,7 @@