19 KiB
19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-dashboard-widgets-vollimplementierung | 03 | execute | 3 |
|
|
true |
|
|
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.
<phase_goal> 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. </phase_goal>
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.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_produced>
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 FavoritesModuleadded to app.module.ts imports- Frontend client
apps/web/src/lib/favorites-api.ts(fetchFavorites, createFavorite, updateFavorite, deleteFavorite) andFavoriteLinktype - 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 </artifacts_produced>
Create icon-discovery.service.ts as an @Injectable() class IconDiscoveryService with an async method discoverFavoriteIconUrl(pageUrl: string): Promise<string>. 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: <img src={iconUrl}> 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.
<threat_model>
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 |
| 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 |
| 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 |
| </threat_model> |
<success_criteria>
- 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) </success_criteria>