feat(18-03): Task 1 — Fetch-Helfer und Download-Link auf der Anmeldeseite
- apps/web/src/lib/desktop.ts: loadDesktopLatest (memoisiert, still bei Fehler), desktopDownloadUrl, formatFileSize (lokalisierte MB-Werte) - DesktopDownloadLinks: unauffaelliger Link-Block, rendert nichts ohne Daten; Windows fuehrt, Linux als Kurzlink wenn beide Pakete vorliegen - Anmeldeseite bindet den Block nach dem Formular ein (D-12) - de/en: auth.desktopDownload.* ergaenzt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import Link from 'next/link';
|
|||||||
import { login } from '@/lib/auth-actions';
|
import { login } from '@/lib/auth-actions';
|
||||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
import { BRAND_YELLOW } from '@/components/brand/brand';
|
||||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||||
|
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split-screen login page (D-01).
|
* Split-screen login page (D-01).
|
||||||
@@ -181,6 +182,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
|
||||||
|
<DesktopDownloadLinks />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
const lookup = (path: string): string | undefined =>
|
||||||
|
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
useTranslations:
|
||||||
|
(ns?: string) =>
|
||||||
|
(key: string, values?: Record<string, unknown>) => {
|
||||||
|
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<typeof import('@/lib/desktop')>();
|
||||||
|
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(<DesktopDownloadLinks />);
|
||||||
|
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(<DesktopDownloadLinks />);
|
||||||
|
|
||||||
|
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(<DesktopDownloadLinks />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DesktopLatestInfo | null>(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 (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{primary && (
|
||||||
|
<a
|
||||||
|
href={desktopDownloadUrl(primary)}
|
||||||
|
download
|
||||||
|
className="hover:text-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{primaryLabel}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{showLinuxShort && linux && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a
|
||||||
|
href={desktopDownloadUrl(linux)}
|
||||||
|
download
|
||||||
|
className="hover:text-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{t('desktopDownload.linuxShort')}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">{t('desktopDownload.version', { version: info.version })}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<DesktopPlatform, DesktopFileInfo>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
let desktopLatestPromise: Promise<DesktopLatestInfo | null> | 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<DesktopLatestInfo | null> {
|
||||||
|
if (!desktopLatestPromise) {
|
||||||
|
desktopLatestPromise = fetch(`${API_URL}/desktop/latest`)
|
||||||
|
.then((res) => (res.ok ? (res.json() as Promise<DesktopLatestInfo>) : 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`;
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@
|
|||||||
"branding": {
|
"branding": {
|
||||||
"tagline": "Modulare Workflow-Plattform für Ihr Unternehmen"
|
"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": {
|
"resetPassword": {
|
||||||
"title": "Passwort zurücksetzen",
|
"title": "Passwort zurücksetzen",
|
||||||
"email": "E-Mail-Adresse",
|
"email": "E-Mail-Adresse",
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
"branding": {
|
"branding": {
|
||||||
"tagline": "Modular workflow platform for your organization"
|
"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": {
|
"resetPassword": {
|
||||||
"title": "Reset Password",
|
"title": "Reset Password",
|
||||||
"email": "Email address",
|
"email": "Email address",
|
||||||
|
|||||||
Reference in New Issue
Block a user