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 @@