link-widget.tsx reuses favorites-api.ts against /favorites with widgetId = instanceId
page.tsx calls wireLinkWidget(LinkWidget)
Deliver the single-Link widget vertical slice (DASH-09 / D-06): a separate widget type that shows exactly one link, sharing the FavoriteLink backend from Plan 03 (widgetId scoping keeps each Link widget's entry isolated).
Purpose: Provide a compact one-link quick-access tile reusing the favorites infrastructure — no new backend.
Output: A user can add a Link widget, set its single link (icon auto-discovered), edit it, and toggle between row and tile presentation; the link persists across sessions.
<phase_goal>
As a portal user, I want to pin a single important link as its own compact dashboard tile, so that I can open my most-used destination in one click.
</phase_goal>
Component LinkWidget (apps/web/src/components/dashboard/widgets/link-widget.tsx) + test
Wiring line in page.tsx: wireLinkWidget(LinkWidget) (consumes the hook created in Plan 01)
No backend changes — reuses FavoritesModule + favorites-api.ts from Plan 03 (widgetId = instanceId)
</artifacts_produced>
Task 1: Failing LinkWidget test (RED)
apps/web/src/components/dashboard/widgets/link-widget.test.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock of '@/lib/favorites-api', next-intl mock — reuse the same approach)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md (D-06 single-link widget)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (link constraints 2x2, i18n link keys)
Mock next-intl (passthrough) and '@/lib/favorites-api' spies. Import LinkWidget after mocks.
- On mount with instanceId "link-1", fetchFavorites is called with "link-1"; the first (only) link renders as an anchor with target="_blank" rel="noreferrer".
- Empty + edit mode: with no link and isEditMode true, an add form is shown; submitting title + url calls createFavorite with { widgetId: 'link-1', title, url }.
- Edit mode with an existing link: editing calls updateFavorite with the id; there is no way to add a second link (single-link constraint — add form hidden once one exists).
- View toggle (edit mode): switching between row and tile renders the corresponding variant; row is default.
- Non-edit mode: renders the link only, no form/controls.
- Icon fallback: link with iconUrl null shows the first uppercase letter of the title.
Create link-widget.test.tsx mirroring favorites-widget.test.tsx setup. Assert single-link enforcement (add form absent when a link exists). Query controls by mocked t() keys (link.*, reuse favorites.* form labels where applicable). Tests MUST fail now (component missing).
pnpm --filter @tessera/web test --run link-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK
- link-widget.test.tsx exists and imports from './link-widget'
- Asserts fetchFavorites called with instanceId and single-link enforcement
- Test run fails (RED) due to missing implementation
Link widget test exists and fails for the right reason, defining the single-link contract.
Task 2: LinkWidget implementation + wiring (GREEN)
apps/web/src/components/dashboard/widgets/link-widget.tsx
apps/web/src/app/(portal)/page.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx (icon render, letter fallback, list/tile styling, edit forms, widgetNoDrag pattern — reuse structure)
- apps/web/src/lib/favorites-api.ts (fetchFavorites/createFavorite/updateFavorite/deleteFavorite, FavoriteLink type)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetProps; wireLinkWidget defined in Plan 01)
- apps/web/src/app/(portal)/page.tsx (wire calls)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (D-06, XSS/open-redirect mitigations)
Create link-widget.tsx as `export function LinkWidget({ instanceId, config, isEditMode }: WidgetProps)` ('use client'; useTranslations('widgets')). Reuse the favorites patterns but constrained to a single entry:
- On mount call fetchFavorites(instanceId); take the first element as the current link (there should be at most one for this widgetId).
- viewMode from config.viewMode: 'list' (row: icon + title in one line) or 'grid' (tile). Default 'list' (D-06). Show the toggle only in edit mode; persist via updateWidgetConfig(instanceId, { viewMode }).
- When no link exists and isEditMode: show an add form (title, url, optional icon URL) calling createFavorite({ widgetId: instanceId, ... }); after success, hide the add form (single-link enforcement — never render the add form while a link exists).
- When a link exists and isEditMode: show inline edit (updateFavorite) and delete (deleteFavorite) controls.
- Render the link as an anchor target="_blank" rel="noreferrer" with (onError hides image) + letter fallback span. No dangerouslySetInnerHTML. Tailwind only. Interactive controls carry widgetNoDrag / stopPropagation.
Wire the widget: in page.tsx import LinkWidget and call wireLinkWidget(LinkWidget) next to the existing wire calls.
pnpm --filter @tessera/web test --run link-widget && pnpm --filter @tessera/web exec tsc --noEmit
- link-widget.test.tsx passes (GREEN)
- link-widget.tsx opens the link with target="_blank" rel="noreferrer"
- link-widget.tsx contains no dangerouslySetInnerHTML and no .module.css import (both greps return 0)
- page.tsx contains wireLinkWidget(LinkWidget)
- tsc --noEmit passes for @tessera/web
A user can add/edit a single link in a Link widget with an auto-discovered icon; it persists via the shared favorites backend and supports row/tile views.
Task 3: Full phase suite + type-check gate
apps/web/src/components/dashboard/widgets/link-widget.tsx
- apps/web/package.json (scripts)
- apps/api/package.json (scripts)
Run the complete web test suite plus web and api type-checks to confirm all four Phase-8 widgets (Calculator, Stopwatch, Favorites, Link) and the existing widgets are green together. Fix any residual type errors. This is the phase gate before verification.
pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit
- Full @tessera/web vitest suite exits 0 (all widget suites)
- tsc --noEmit passes for @tessera/web and @tessera/api
All Phase-8 widgets pass together with clean type-checks — phase ready for /gsd-verify-work.
<threat_model>
Trust Boundaries
Boundary
Description
client → API (/favorites)
Link widget reuses the favorites endpoints; same untrusted input surface
stored iconUrl → browser
Server-supplied URL rendered in an tag
link url → new tab
User-clicked link opens externally
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-08-10
Spoofing
Link widget favorites reuse
high
mitigate
Reuses FavoritesService userId + widgetId scoping from Plan 03 (T-08-06); no new endpoint
T-08-11
Tampering (XSS)
link-widget.tsx icon render
medium
mitigate
Render iconUrl only via ; no dangerouslySetInnerHTML
T-08-12
Spoofing (open redirect)
link anchor
low
mitigate
Anchor opens with target="_blank" rel="noreferrer"
</threat_model>
- pnpm --filter @tessera/web test --run passes (link suite + all Phase-8 widgets)
- tsc --noEmit clean for web and api
- Manual smoke (optional): add Link widget → set one link → icon discovered; add form disappears; reload persists; row/tile toggle works
<success_criteria>
Link widget displays exactly one link with discovered icon and letter fallback (DASH-09 / D-06)
Single link is settable/editable in edit mode and persists via the shared FavoriteLink backend keyed by widgetId
Row and tile views both render cleanly
</success_criteria>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md` when done