---
phase: 08-dashboard-widgets-vollimplementierung
plan: 03
type: execute
wave: 3
depends_on: [08-02]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/src/favorites/favorites.module.ts
- apps/api/src/favorites/favorites.controller.ts
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/favorites/dto/create-favorite.dto.ts
- apps/api/src/favorites/dto/update-favorite.dto.ts
- apps/api/src/app.module.ts
- apps/web/src/lib/favorites-api.ts
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
autonomous: true
requirements: [DASH-09]
must_haves:
truths:
- "User can add, edit and delete favorite links directly in the Favorites widget in edit mode"
- "Favorite links persist across sessions in a FavoriteLink DB table scoped by userId and widgetId"
- "Adding a favorite triggers server-side icon discovery with SSRF protection; the icon is stored and rendered with a letter fallback"
- "User can switch the Favorites widget between list and grid view"
artifacts:
- "apps/api/src/favorites/favorites.service.ts"
- "apps/api/src/favorites/icon-discovery.service.ts"
- "apps/web/src/components/dashboard/widgets/favorites-widget.tsx"
- "FavoriteLink model in apps/api/prisma/schema.prisma"
key_links:
- "FavoritesController GET/POST/PATCH/DELETE /favorites scoped by userId + widgetId"
- "favorites-widget.tsx calls favorites-api.ts against NEXT_PUBLIC_API_URL with credentials include"
- "page.tsx calls wireFavoritesWidget(FavoritesWidget)"
---
Deliver the Favorites widget vertical slice with backend persistence (DASH-09, D-02..D-05): a new FavoriteLink table, a NestJS FavoritesModule (CRUD + SSRF-protected icon discovery), a frontend API client, and the FavoritesWidget with inline add/edit/delete and list/grid views.
Purpose: Give users a persistent, per-widget favorites list with automatic favicon discovery, isolated per user and per widget instance.
Output: A user can add favorite links in a Favorites widget, see discovered icons (with letter fallback), edit/delete them, toggle list/grid view, and have everything persist across sessions.
**As a** portal user, **I want to** manage a list of favorite links inside a dashboard widget with automatic site icons, **so that** I have persistent quick access to the sites I use most.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md
## Artifacts this phase produces (Plan 03)
- Prisma model `FavoriteLink` (id, userId, tenantId, widgetId, title, url, iconUrl?, position, createdAt, updatedAt) with indexes on userId, tenantId, widgetId
- NestJS `FavoritesModule`, `FavoritesController`, `FavoritesService`, `IconDiscoveryService`
- DTOs `CreateFavoriteDto`, `UpdateFavoriteDto`
- `FavoritesModule` added to app.module.ts imports
- Frontend client `apps/web/src/lib/favorites-api.ts` (fetchFavorites, createFavorite, updateFavorite, deleteFavorite) and `FavoriteLink` type
- Component `FavoritesWidget` (apps/web/src/components/dashboard/widgets/favorites-widget.tsx) + test
- Wiring line in page.tsx: `wireFavoritesWidget(FavoritesWidget)` (consumes the hook created in Plan 01)
- REST routes: GET /favorites?widgetId=, POST /favorites, PATCH /favorites/:id, DELETE /favorites/:id
Task 1: Failing FavoritesWidget test (RED)
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (fetch spy + next-intl mock)
- /home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx (UI behavior to adapt: load, add, edit, delete, list/grid toggle, letter fallback)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Favorites architecture, Pitfall 3 widgetId scope)
Mock next-intl (passthrough) and mock the favorites-api module ('@/lib/favorites-api') with vi.fn spies (fetchFavorites, createFavorite, updateFavorite, deleteFavorite). Import FavoritesWidget after mocks.
- On mount with instanceId "fav-1", fetchFavorites is called with "fav-1" (widgetId scope, Pitfall 3) and the returned links render (title text visible).
- Empty state: when fetchFavorites resolves [], the empty message (t('favorites.empty')) renders.
- Add (isEditMode true): filling title + url and submitting calls createFavorite with { widgetId: 'fav-1', title, url }; the new link appears.
- Edit: clicking edit on a link, changing the title, saving calls updateFavorite with the id and new title.
- Delete: clicking delete calls deleteFavorite with the id and removes the row.
- View toggle (isEditMode true): switching to grid renders the grid container variant; list is the default (D-03).
- Letter fallback: a link with iconUrl null renders the first uppercase letter of its title.
Create favorites-widget.test.tsx. Mock '@/lib/favorites-api' so all four functions are spies with controllable resolved values. Render FavoritesWidget with props instanceId, config, isEditMode. Query by accessible names from the mocked t() keys and by visible title text. Assert spy call arguments for widgetId scoping and CRUD payloads. Tests MUST fail now (component + api client do not exist).
pnpm --filter @tessera/web test --run favorites-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK
- favorites-widget.test.tsx exists and imports from './favorites-widget'
- Asserts fetchFavorites called with the instanceId (widgetId scope)
- Covers add/edit/delete, empty state, list/grid toggle, letter fallback
- Test run fails (RED) due to missing implementation
Favorites widget test exists and fails for the right reason, defining the CRUD + view contract.
Task 2: FavoriteLink schema + FavoritesModule (CRUD + SSRF icon discovery)
apps/api/prisma/schema.prisma
apps/api/src/favorites/favorites.module.ts
apps/api/src/favorites/favorites.controller.ts
apps/api/src/favorites/favorites.service.ts
apps/api/src/favorites/icon-discovery.service.ts
apps/api/src/favorites/dto/create-favorite.dto.ts
apps/api/src/favorites/dto/update-favorite.dto.ts
apps/api/src/app.module.ts
- apps/api/prisma/schema.prisma (WidgetInstance/SearchProvider model + index conventions)
- apps/api/src/dashboard/dashboard.controller.ts (extractContext pattern: userId/tenantId from req.user / req.tenantId; ForbiddenException)
- apps/api/src/dashboard/dashboard.service.ts (ownership check pattern: findUnique then userId compare, NotFoundException)
- apps/api/src/dashboard/dashboard.module.ts (module shape)
- apps/api/src/dashboard/dto/create-widget.dto.ts (class-validator DTO pattern)
- apps/api/src/app.module.ts (imports array; PrismaModule is global)
- /home/vicolab/Schreibtisch/personal-dashboard/src/lib/favorite-icons.ts (full SSRF-protected icon discovery to port: isPrivateIpv4, isPrivateIpv6, isPrivateIpAddress, isBlockedHostname, isPublicHttpUrl, parseAttributes, toAbsoluteUrl, extractIconFromHtml, fetchHtml, discoverFavoriteIconUrl; constants HTML_FETCH_TIMEOUT_MS 4000, MAX_REDIRECTS 2, MAX_HTML_CHARS 200000, FALLBACK_ICON_PATH /favicon.ico)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 3 schema, Pattern 4 module, Pattern 5, Security Domain, Pitfalls 3/4/5)
Add the FavoriteLink model to schema.prisma exactly per D-02 / research Pattern 3: fields id (uuid pk), userId, tenantId, widgetId, title, url, iconUrl (nullable), position (Int default 0), createdAt (default now), updatedAt (@updatedAt); add @@index on userId, tenantId, widgetId. After editing the schema run `pnpm --filter @tessera/api exec prisma db push` then `pnpm --filter @tessera/api exec prisma generate` (Pitfall 4 — generate is required after db push).
Create icon-discovery.service.ts as an @Injectable() class IconDiscoveryService with an async method discoverFavoriteIconUrl(pageUrl: string): Promise. Port the reference logic verbatim (DNS lookup → private-IP/blocked-hostname SSRF check → fetch with redirect 'manual' and manual redirect following up to MAX_REDIRECTS → HTML parse for apple-touch-icon / icon / shortcut icon / image_src / og:image → fallback origin /favicon.ico). Keep the SSRF checks and timeout intact; change only the User-Agent header value to "tessera/1.0". Never follow redirects automatically; re-run the public-URL check on each redirect target (Pitfall 5).
Create create-favorite.dto.ts: widgetId @IsUUID, title @IsString @IsNotEmpty, url @IsUrl, iconUrl @IsOptional @IsString, position @IsOptional @IsInt. Create update-favorite.dto.ts: all optional — title? @IsString, url? @IsUrl, iconUrl? @IsOptional (allow null), position? @IsInt.
Create favorites.service.ts (@Injectable, constructor injects PrismaService and IconDiscoveryService). Methods, every query scoped WHERE userId AND (for list) widgetId (V4 access control, Pitfall 3):
- list(userId, widgetId): findMany where userId + widgetId, orderBy position asc.
- create(userId, tenantId, dto): if dto.iconUrl absent, call iconDiscovery.discoverFavoriteIconUrl(dto.url) and store the result; persist with userId, tenantId, widgetId, title, url, iconUrl, position (default 0 or provided).
- update(id, userId, dto): findUnique; if missing or userId mismatch throw NotFoundException; update allowed fields (merge title/url/iconUrl/position).
- remove(id, userId): findUnique; ownership check; delete.
Create favorites.controller.ts @Controller('favorites') with the same private extractContext(req) helper as DashboardController. Routes: @Get() list(@Query('widgetId') widgetId) → service.list(userId, widgetId); @Post() create(@Body dto) → service.create(userId, tenantId, dto); @Patch(':id') update; @Delete(':id') remove. All rely on the global JwtAuthGuard + TenantGuard already applied app-wide.
Create favorites.module.ts (@Module controllers [FavoritesController], providers [FavoritesService, IconDiscoveryService]). Register FavoritesModule in app.module.ts imports (PrismaModule is global — no re-import).
pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec tsc --noEmit
- schema.prisma contains "model FavoriteLink" with fields widgetId, iconUrl, position and @@index([widgetId])
- prisma generate succeeds and PrismaClient exposes favoriteLink (tsc references compile)
- icon-discovery.service.ts contains User-Agent "tessera/1.0" and uses redirect: 'manual'
- icon-discovery.service.ts retains the private-IP / blocked-hostname SSRF checks (grep -c "isPrivateIpAddress" apps/api/src/favorites/icon-discovery.service.ts returns >= 1)
- favorites.service.ts scopes every query by userId (no query without a userId filter)
- app.module.ts imports FavoritesModule
- tsc --noEmit passes for @tessera/api
The backend persists FavoriteLink rows per user+widget, discovers icons server-side with SSRF protection, and exposes scoped CRUD routes under /favorites.
Task 3: FavoritesWidget frontend + API client + wiring (GREEN)
apps/web/src/lib/favorites-api.ts
apps/web/src/components/dashboard/widgets/favorites-widget.tsx
apps/web/src/app/(portal)/page.tsx
- apps/web/src/lib/dashboard-api.ts (fetch pattern: NEXT_PUBLIC_API_URL, credentials 'include', JSON headers)
- apps/web/src/components/dashboard/widgets/note-widget.tsx (updateWidgetConfig usage for persisting viewMode)
- /home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx (UI logic to adapt: sortedFavorites, add/edit/delete forms, list vs grid, icon + letter fallback, target _blank rel noreferrer)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetProps; wireFavoritesWidget defined in Plan 01)
- apps/web/src/app/(portal)/page.tsx (wire calls)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Favorites-API-Client example, i18n keys, Security XSS/open-redirect mitigations)
Create favorites-api.ts analogous to dashboard-api.ts: export a FavoriteLink type { id, widgetId, title, url, iconUrl: string | null, position } and functions fetchFavorites(widgetId): Promise (GET /favorites?widgetId=…), createFavorite(payload: { widgetId, title, url, iconUrl? }): Promise (POST), updateFavorite(id, payload: Partial<{title,url,iconUrl,position}>): Promise (PATCH), deleteFavorite(id): Promise (DELETE). All use `${API_URL}/favorites…` with credentials 'include' and JSON headers; API_URL from process.env.NEXT_PUBLIC_API_URL with the same localhost fallback as dashboard-api.ts.
Create favorites-widget.tsx as `export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps)` ('use client'; useTranslations('widgets')). Adapt the reference UI to Tailwind (no CSS modules, no favoriteX class names). Behavior:
- On mount and when instanceId changes, call fetchFavorites(instanceId) and store the list; show loading (t('favorites.loading')) then empty (t('favorites.empty')) when none.
- Sort by position asc then title (localeCompare).
- viewMode: read from config.viewMode ('list' default per D-03, or 'grid'); render list layout (icon + title stacked rows) or grid layout (tiles) with clean formatting (no clipped text). Show the list/grid toggle only in edit mode; persist the choice via updateWidgetConfig(instanceId, { viewMode }).
- Add/edit/delete only in edit mode (D-04): a '+' add form (title, url, optional icon URL) calling createFavorite; inline edit form per row calling updateFavorite; delete button calling deleteFavorite. Update local state on success; show t('favorites.error') on failure.
- Each favorite renders as an anchor with target="_blank" rel="noreferrer" (open-redirect mitigation) wrapping an icon:
with onError hiding the image, plus a letter-fallback span (first uppercase letter of title). Never use dangerouslySetInnerHTML (XSS mitigation).
- Add className "widgetNoDrag" (or stopPropagation) on interactive controls so clicks/drag inside the widget do not trigger grid drag.
Wire the widget: in page.tsx import FavoritesWidget and call wireFavoritesWidget(FavoritesWidget) next to the existing wire calls.
pnpm --filter @tessera/web test --run favorites-widget && pnpm --filter @tessera/web exec tsc --noEmit
- favorites-widget.test.tsx passes (GREEN)
- favorites-api.ts uses credentials 'include' and hits `${API_URL}/favorites`
- favorites-widget.tsx opens links with rel="noreferrer" and target="_blank"
- favorites-widget.tsx contains no dangerouslySetInnerHTML (grep -c "dangerouslySetInnerHTML" apps/web/src/components/dashboard/widgets/favorites-widget.tsx returns 0)
- grep -c "module.css" apps/web/src/components/dashboard/widgets/favorites-widget.tsx returns 0
- page.tsx contains wireFavoritesWidget(FavoritesWidget)
- tsc --noEmit passes for @tessera/web
A user can add/edit/delete favorites in the widget, switch list/grid view, and see discovered icons with letter fallback; data persists via the FavoritesModule.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (/favorites) | Untrusted title/url/widgetId cross into the API |
| API → external web (icon discovery) | Server fetches an attacker-controllable URL — SSRF surface |
| stored iconUrl → browser | Server-supplied URL rendered in an
tag |
| favorite url → new tab | User-clicked link opens externally |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-05 | Elevation of Privilege | IconDiscoveryService (SSRF) | high | mitigate | Port full SSRF guard: block localhost/.local/0.0.0.0, RFC1918/CGNAT/link-local/IPv6 private ranges, DNS-resolve and re-check every redirect target; redirect 'manual'; 4000ms timeout; 200k HTML cap |
| T-08-06 | Spoofing | FavoritesService queries | high | mitigate | Every query scoped WHERE userId (+ widgetId for list); NotFoundException on ownership mismatch (Pitfall 3) |
| T-08-07 | Tampering (XSS) | favorites-widget.tsx icon render | medium | mitigate | Render iconUrl only via
; no dangerouslySetInnerHTML |
| T-08-08 | Spoofing (open redirect) | favorite link anchor | low | mitigate | Links open with target="_blank" rel="noreferrer"; no server-side redirect |
| T-08-09 | Denial of Service | icon discovery HTML fetch | medium | mitigate | MAX_HTML_CHARS 200000 truncation + 4000ms AbortController timeout |
| T-08-SC | Tampering | npm/pip/cargo installs | high | accept | No new packages this phase (RESEARCH Package Legitimacy Audit: none); Node built-ins only |
- pnpm --filter @tessera/web test --run passes (favorites suite + all others)
- pnpm --filter @tessera/api exec tsc --noEmit clean; prisma generate succeeds
- Manual smoke (optional): add a favorite for a public URL → icon discovered; add localhost URL → falls back to /favicon.ico (no internal fetch); reload → favorites persist
- Favorites persist in the FavoriteLink table scoped by userId + widgetId (DASH-09)
- Inline add/edit/delete works in edit mode; list and grid views both render cleanly (D-03/D-04)
- Icon discovery runs server-side with SSRF protection; iconUrl rendered with letter fallback (D-05)