diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 5284daa..4ab93ae 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { login } from '@/lib/auth-actions'; import { BRAND_YELLOW } from '@/components/brand/brand'; import { TesseraLogo } from '@/components/brand/tessera-logo'; +import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links'; /** * Split-screen login page (D-01). @@ -181,6 +182,9 @@ export default function LoginPage() { )} + + {/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */} + diff --git a/apps/web/src/components/desktop/desktop-download-links.test.tsx b/apps/web/src/components/desktop/desktop-download-links.test.tsx new file mode 100644 index 0000000..04365dd --- /dev/null +++ b/apps/web/src/components/desktop/desktop-download-links.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DesktopDownloadLinks } from './desktop-download-links'; + +/** + * desktop-download-links.test — Link-Block auf der Anmeldeseite (18-03). + * next-intl-Mock nach dem Muster in widget-settings-panel.test.tsx + * (de.json-gestuetzt), `@/lib/desktop` gemockt mit steuerbarem + * `loadDesktopLatest`; `desktopDownloadUrl` wird echt durchgereicht. + */ +vi.mock('next-intl', async () => { + const messages = (await import('@/messages/de.json')).default as Record; + const lookup = (path: string): string | undefined => + path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as + | string + | undefined; + return { + useTranslations: + (ns?: string) => + (key: string, values?: Record) => { + const raw = lookup(ns ? `${ns}.${key}` : key) ?? key; + return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw; + }, + useLocale: () => 'de', + }; +}); + +const { loadDesktopLatest } = vi.hoisted(() => ({ loadDesktopLatest: vi.fn() })); + +vi.mock('@/lib/desktop', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadDesktopLatest, + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const windowsFile = { + name: 'Tessera-Setup-1.1.0.exe', + size: 6123456, + sha256: 'x', + url: '/desktop/download/windows', +}; +const linuxFile = { + name: 'Tessera-1.1.0.AppImage', + size: 106461688, + sha256: 'y', + url: '/desktop/download/linux', +}; + +describe('DesktopDownloadLinks (18-03)', () => { + it('Test 1 (keine Daten): rendert nichts, solange null zurueckkommt', async () => { + loadDesktopLatest.mockResolvedValue(null); + const { container } = render(); + await vi.waitFor(() => expect(loadDesktopLatest).toHaveBeenCalled()); + expect(container.firstChild).toBeNull(); + }); + + it('Test 2 (beide Plattformen): Windows-Hauptlink, Linux-Kurzlink, Version', async () => { + loadDesktopLatest.mockResolvedValue({ + version: '1.1.0', + channel: 'beta', + commit: 'abc1234', + buildTime: 'x', + files: { windows: windowsFile, linux: linuxFile }, + }); + render(); + + const windowsLink = await screen.findByText('Desktop-App herunterladen (Windows)'); + expect(windowsLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/windows'); + + const linuxLink = screen.getByText('Linux-Version'); + expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux'); + + expect(screen.getByText('Version 1.1.0')).toBeInTheDocument(); + }); + + it('Test 3 (nur Linux): genau ein Link mit dem Linux-Text', async () => { + loadDesktopLatest.mockResolvedValue({ + version: '1.1.0', + channel: 'beta', + commit: 'abc1234', + buildTime: 'x', + files: { linux: linuxFile }, + }); + render(); + + const linuxLink = await screen.findByText('Desktop-App herunterladen (Linux)'); + expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux'); + expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/desktop/desktop-download-links.tsx b/apps/web/src/components/desktop/desktop-download-links.tsx new file mode 100644 index 0000000..7464201 --- /dev/null +++ b/apps/web/src/components/desktop/desktop-download-links.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop'; + +/** + * Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur, + * wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger, + * nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux + * folgt als kleiner zweiter Link, wenn beide Pakete vorliegen. + */ +export function DesktopDownloadLinks() { + const t = useTranslations('auth'); + const [info, setInfo] = useState(null); + + useEffect(() => { + let active = true; + loadDesktopLatest().then((data) => { + if (active) setInfo(data); + }); + return () => { + active = false; + }; + }, []); + + const files = info?.files; + const windows = files?.windows; + const linux = files?.linux; + if (!info || (!windows && !linux)) { + return null; + } + + const primary = windows ?? linux; + const primaryLabel = windows ? t('desktopDownload.windows') : t('desktopDownload.linux'); + const showLinuxShort = Boolean(windows && linux); + + return ( +
+
+ {primary && ( + + {primaryLabel} + + )} + {showLinuxShort && linux && ( + <> + {' · '} + + {t('desktopDownload.linuxShort')} + + + )} +
+
{t('desktopDownload.version', { version: info.version })}
+
+ ); +} diff --git a/apps/web/src/lib/desktop.test.ts b/apps/web/src/lib/desktop.test.ts new file mode 100644 index 0000000..f68af9f --- /dev/null +++ b/apps/web/src/lib/desktop.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * desktop.test — Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18-03). + * + * `loadDesktopLatest()` memoisiert die Antwort von `GET /desktop/latest` — + * deshalb setzt jeder Test die Module zurueck und importiert dynamisch, + * nachdem `fetch` gestubbt ist (Muster: app-version.test.ts). + */ + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +async function importFresh() { + vi.resetModules(); + return import('./desktop'); +} + +describe('desktop (18-03)', () => { + it('Test 1 (Laden, memoisiert): zwei Aufrufe liefern dasselbe Objekt, fetch laeuft genau einmal ohne credentials', async () => { + const payload = { + version: '1.1.0', + channel: 'beta', + commit: 'abc1234', + buildTime: 'x', + files: { + windows: { name: 'Tessera-Setup-1.1.0.exe', size: 6123456, sha256: 'x', url: '/desktop/download/windows' }, + }, + }; + const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload) })); + vi.stubGlobal('fetch', fetchMock); + const mod = await importFresh(); + + const first = await mod.loadDesktopLatest(); + const second = await mod.loadDesktopLatest(); + + expect(first).toEqual(payload); + expect(second).toEqual(payload); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined]; + expect(url.endsWith('/desktop/latest')).toBe(true); + expect(options?.credentials).toBeUndefined(); + }); + + it('Test 2 (still bei ok=false): eine Nicht-2xx-Antwort liefert null, nichts wird geworfen', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }))); + const mod = await importFresh(); + await expect(mod.loadDesktopLatest()).resolves.toBeNull(); + }); + + it('Test 3 (still bei Netzfehler): ein abgelehntes fetch-Promise liefert null, nichts wird geworfen', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz')))); + const mod = await importFresh(); + await expect(mod.loadDesktopLatest()).resolves.toBeNull(); + }); + + it('Test 4 (desktopDownloadUrl): API_URL plus relatives url-Feld', async () => { + const mod = await importFresh(); + expect( + mod.desktopDownloadUrl({ name: 'Tessera-Setup-1.1.0.exe', size: 1, sha256: 'x', url: '/desktop/download/windows' }), + ).toBe('http://localhost:3001/desktop/download/windows'); + }); + + it('Test 5 (formatFileSize): lokalisierte MB-Werte', async () => { + const mod = await importFresh(); + expect(mod.formatFileSize(6123456, 'de')).toBe('5,8 MB'); + expect(mod.formatFileSize(6123456, 'en')).toBe('5.8 MB'); + expect(mod.formatFileSize(106461688, 'de')).toBe('101,5 MB'); + }); +}); diff --git a/apps/web/src/lib/desktop.ts b/apps/web/src/lib/desktop.ts new file mode 100644 index 0000000..d388381 --- /dev/null +++ b/apps/web/src/lib/desktop.ts @@ -0,0 +1,67 @@ +/** + * Desktop-Paket-Info fuer die Web-Oberflaeche (Phase 18, D-10/D-12). + * + * Quelle fuer den unauffaelligen Download-Link auf der Anmeldeseite + * (`DesktopDownloadLinks`) UND fuer die Einstellungsseite + * "Desktop-App" (`DesktopAppSettings`): beide lesen `GET /desktop/latest` + * (oeffentlich, `@Public()` in `apps/api/src/desktop/desktop.controller.ts`) + * und blenden sich aus, wenn der Server keine Pakete traegt (404). + * + * Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit + * im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code + * steht — kein Destructuring, kein `process.env[name]` (siehe `app-version.ts`). + * Im Betrieb laeuft die Anfrage ueber den Rewrite `/api-proxy` auf die API. + */ + +export type DesktopPlatform = 'windows' | 'linux'; + +/** + * Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus + * `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab + * (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in + * `apps/api/src/desktop/desktop.service.ts`. + */ +export interface DesktopFileInfo { + name: string; + size: number; + sha256: string; + url: string; +} + +export interface DesktopLatestInfo { + version: string; + channel: string; + commit: string; + buildTime: string; + files: Partial>; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +let desktopLatestPromise: Promise | null = null; + +/** + * Laedt `GET /desktop/latest` genau einmal je Modulinstanz (memoisiert); + * jeder Fehler (Netz, Nicht-2xx — insbesondere 404 ohne Manifest) ist still + * und liefert `null`. Kein `credentials: 'include'`: die Anmeldeseite zeigt + * den Link, bevor ein Cookie existiert, und der Endpunkt ist oeffentlich. + */ +export function loadDesktopLatest(): Promise { + if (!desktopLatestPromise) { + desktopLatestPromise = fetch(`${API_URL}/desktop/latest`) + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .catch(() => null); + } + return desktopLatestPromise; +} + +/** Baut die absolute Download-Adresse aus `API_URL` plus dem relativen `url`-Feld (T-18-07). */ +export function desktopDownloadUrl(file: DesktopFileInfo): string { + return `${API_URL}${file.url}`; +} + +/** Formatiert Dateigroessen in MB, lokalisiert (z. B. `5,8 MB` / `5.8 MB`). */ +export function formatFileSize(bytes: number, locale: string): string { + const mb = bytes / 1048576; + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(mb)} MB`; +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index d0732f4..6b1276d 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -34,6 +34,12 @@ "branding": { "tagline": "Modulare Workflow-Plattform für Ihr Unternehmen" }, + "desktopDownload": { + "windows": "Desktop-App herunterladen (Windows)", + "linux": "Desktop-App herunterladen (Linux)", + "linuxShort": "Linux-Version", + "version": "Version {version}" + }, "resetPassword": { "title": "Passwort zurücksetzen", "email": "E-Mail-Adresse", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 097d73c..f628732 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -34,6 +34,12 @@ "branding": { "tagline": "Modular workflow platform for your organization" }, + "desktopDownload": { + "windows": "Download desktop app (Windows)", + "linux": "Download desktop app (Linux)", + "linuxShort": "Linux version", + "version": "Version {version}" + }, "resetPassword": { "title": "Reset Password", "email": "Email address",