feat(quick-261008-w5w): Modul-Changelog Durchstich mit Nextcloud-Status
- Typen, Register, Route GET /modules/changelog/:slug, Waechter-Test - Seed liest die Version per latestVersion aus dem Changelog - Marktplatz-Detailseite zeigt den Abschnitt Aenderungen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# E2E quick-261008-w5w: Modul-Changelog gegen den lokalen Stack.
|
||||||
|
# Aufruf: e2e-changelog.sh [slug ...]
|
||||||
|
# Mit Argumenten MUESSEN genau diese Slugs einen nicht leeren Changelog haben;
|
||||||
|
# ohne Argumente gilt das fuer JEDEN Slug im Katalog.
|
||||||
|
# Nur Testwerte (admin/admin123), liest keine .env-Dateien.
|
||||||
|
set -u
|
||||||
|
API=${API:-http://localhost:3001}
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
JAR="$TMP/jar"
|
||||||
|
fail() { echo "E2E FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# (1) ohne Anmeldung -> 401 (Route existiert, ist geschuetzt)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$API/modules/changelog/nextcloud-status")
|
||||||
|
[ "$code" = "401" ] || fail "ohne Anmeldung erwartet 401, war $code"
|
||||||
|
|
||||||
|
# (2) anmelden
|
||||||
|
code=$(curl -s -o "$TMP/login.out" -w '%{http_code}' -c "$JAR" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' "$API/auth/login")
|
||||||
|
{ [ "$code" = "200" ] || [ "$code" = "201" ]; } || fail "login -> $code"
|
||||||
|
|
||||||
|
# (3) unbekannte Kennungen und Prototyp-Namen -> 200 und genau []
|
||||||
|
for slug in does-not-exist __proto__ constructor toString; do
|
||||||
|
code=$(curl -s -o "$TMP/body.out" -w '%{http_code}' -b "$JAR" "$API/modules/changelog/$slug")
|
||||||
|
[ "$code" = "200" ] || fail "$slug erwartet 200, war $code"
|
||||||
|
[ "$(tr -d ' \n' < "$TMP/body.out")" = "[]" ] || fail "$slug erwartet [], war $(head -c 200 "$TMP/body.out")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# (4)+(5) Katalog laden, je Slug Changelog holen und Versionen vergleichen
|
||||||
|
code=$(curl -s -o "$TMP/catalog.out" -w '%{http_code}' -b "$JAR" "$API/modules/catalog")
|
||||||
|
[ "$code" = "200" ] || fail "Katalog -> $code"
|
||||||
|
slugs=$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(c.map(m=>m.slug+"="+m.version).join(" "))' "$TMP/catalog.out")
|
||||||
|
[ -n "$slugs" ] || fail "Katalog leer"
|
||||||
|
|
||||||
|
required="$*"
|
||||||
|
checked=0
|
||||||
|
for entry in $slugs; do
|
||||||
|
slug=${entry%%=*}
|
||||||
|
version=${entry#*=}
|
||||||
|
code=$(curl -s -o "$TMP/cl.out" -w '%{http_code}' -b "$JAR" "$API/modules/changelog/$slug")
|
||||||
|
[ "$code" = "200" ] || fail "$slug: Changelog -> $code"
|
||||||
|
latest=$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(Array.isArray(c)&&c.length?c[0].version:"")' "$TMP/cl.out")
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
if [ -z "$required" ] || [[ " $required " == *" $slug "* ]]; then
|
||||||
|
fail "$slug: Changelog ist leer, wird aber verlangt"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[ "$latest" = "$version" ] || fail "$slug: Katalog-Version $version ungleich Changelog $latest"
|
||||||
|
checked=$((checked + 1))
|
||||||
|
echo " $slug $version"
|
||||||
|
done
|
||||||
|
for slug in $required; do
|
||||||
|
[[ " $slugs " == *" $slug="* ]] || fail "$slug nicht im Katalog"
|
||||||
|
done
|
||||||
|
[ "$checked" -gt 0 ] || fail "kein Modul mit Changelog gefunden"
|
||||||
|
echo "e2e-changelog ok"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NEXTCLOUD_STATUS_CHANGELOG } from '../nextcloud-status/nextcloud-status.changelog';
|
||||||
|
import type { ModuleChangelog } from './module-changelog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Modul-Changelogs nach Modul-Slug (quick-261008-w5w), alphabetisch.
|
||||||
|
* Ein neues Modul trägt seinen Changelog hier ein; der Wächter-Test
|
||||||
|
* `module-changelog.spec.ts` prüft Vollständigkeit und Versionsgleichheit.
|
||||||
|
*/
|
||||||
|
export const MODULE_CHANGELOGS: ReadonlyMap<string, ModuleChangelog> = new Map<
|
||||||
|
string,
|
||||||
|
ModuleChangelog
|
||||||
|
>([['nextcloud-status', NEXTCLOUD_STATUS_CHANGELOG]]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map statt Objekt-Index, damit `__proto__`, `constructor` und Co. nicht in die
|
||||||
|
* Prototypenkette greifen (T-261008-w5w-02). Unbekannte Kennung → leere Liste.
|
||||||
|
*/
|
||||||
|
export function getModuleChangelog(slug: string): ModuleChangelog {
|
||||||
|
return MODULE_CHANGELOGS.get(slug) ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { compareSemver, latestVersion } from './module-changelog';
|
||||||
|
import { MODULE_CHANGELOGS, getModuleChangelog } from './module-changelog.registry';
|
||||||
|
import { ModuleRegistryController } from './module-registry.controller';
|
||||||
|
import type { ModuleRegistryService } from './module-registry.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wächter-Test (quick-261008-w5w): Die Modulversion steht nur im Changelog; ein
|
||||||
|
* Seed ohne Changelog, eine Seed-Version abseits des neuesten Eintrags oder ein
|
||||||
|
* fehlerhafter Eintrag lassen diesen Test fehlschlagen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SRC = resolve(__dirname, '..');
|
||||||
|
|
||||||
|
// Ersatzschreibungen in deutschen Texten (ganze Wörter, ohne Groß-/Kleinschreibung).
|
||||||
|
const REPLACEMENT_WORDS = [
|
||||||
|
'fuer', 'ueber', 'koennen', 'moechten', 'muessen', 'Aenderung', 'Aenderungen', 'geaendert',
|
||||||
|
'moeglich', 'zurueck', 'loeschen', 'geloescht', 'pruefen', 'geprueft', 'Pruefung', 'waehlen',
|
||||||
|
'ausgewaehlt', 'Schluessel', 'groesser', 'Groesse', 'schliessen', 'oeffnen', 'geoeffnet',
|
||||||
|
];
|
||||||
|
const REPLACEMENT_RE = new RegExp(`\\b(${REPLACEMENT_WORDS.join('|')})\\b`, 'i');
|
||||||
|
// Mandanten-/Lizenzbegriffe: ganze Wörter bzw. Wortanfänge („Mandantennummer“ löst nicht aus).
|
||||||
|
const FORBIDDEN_RE = /\b(Mandant|Mandanten|tenant|tenants)\b|\b(Lizenz|licens|licenc)/i;
|
||||||
|
|
||||||
|
function isRealDate(value: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
const d = new Date(`${value}T00:00:00Z`);
|
||||||
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Manifest {
|
||||||
|
slug: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSeedManifests(): Promise<{ files: string[]; manifests: Manifest[] }> {
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const dir of readdirSync(SRC)) {
|
||||||
|
const full = join(SRC, dir);
|
||||||
|
if (!statSync(full).isDirectory()) continue;
|
||||||
|
for (const file of readdirSync(full)) {
|
||||||
|
if (file.endsWith('.seed.ts')) files.push(join(full, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manifests: Manifest[] = [];
|
||||||
|
const registry = {
|
||||||
|
seedModule: async (manifest: Manifest) => {
|
||||||
|
manifests.push(manifest);
|
||||||
|
},
|
||||||
|
} as unknown as ModuleRegistryService;
|
||||||
|
for (const file of files) {
|
||||||
|
const mod = (await import(file)) as Record<string, unknown>;
|
||||||
|
for (const [name, fn] of Object.entries(mod)) {
|
||||||
|
if (/^seed\w*Module$/.test(name) && typeof fn === 'function') {
|
||||||
|
await (fn as (r: ModuleRegistryService) => Promise<void>)(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, manifests };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compareSemver / latestVersion', () => {
|
||||||
|
it('vergleicht numerisch', () => {
|
||||||
|
expect(compareSemver('1.10.0', '1.9.2')).toBe(1);
|
||||||
|
expect(compareSemver('2.0.0', '1.99.99')).toBe(1);
|
||||||
|
expect(compareSemver('1.9.2', '1.10.0')).toBe(-1);
|
||||||
|
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('latestVersion liefert den ersten Eintrag, leer wirft verständlich', () => {
|
||||||
|
expect(latestVersion([{ version: '1.2.0', date: '2026-10-08', changes: [] }])).toBe('1.2.0');
|
||||||
|
expect(() => latestVersion([])).toThrow(/Changelog/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getModuleChangelog', () => {
|
||||||
|
it('liefert das Array des Registers', () => {
|
||||||
|
expect(getModuleChangelog('nextcloud-status')).toBe(MODULE_CHANGELOGS.get('nextcloud-status'));
|
||||||
|
expect(getModuleChangelog('nextcloud-status').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['does-not-exist', '__proto__', 'constructor', 'toString', 'hasOwnProperty'])(
|
||||||
|
'liefert für %s eine leere Liste',
|
||||||
|
(slug) => {
|
||||||
|
expect(getModuleChangelog(slug)).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ModuleRegistryController.getChangelog', () => {
|
||||||
|
const controller = new ModuleRegistryController({} as any, {} as any, {} as any);
|
||||||
|
|
||||||
|
it('gibt das Ergebnis des Registers unverändert zurück', () => {
|
||||||
|
expect(controller.getChangelog('nextcloud-status')).toBe(
|
||||||
|
MODULE_CHANGELOGS.get('nextcloud-status'),
|
||||||
|
);
|
||||||
|
expect(controller.getChangelog('gibt-es-nicht')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Format aller Modul-Changelogs', () => {
|
||||||
|
for (const [slug, changelog] of MODULE_CHANGELOGS) {
|
||||||
|
describe(slug, () => {
|
||||||
|
it('ist nicht leer', () => {
|
||||||
|
expect(changelog.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat gültige Versionen, strikt absteigend', () => {
|
||||||
|
for (const release of changelog) {
|
||||||
|
expect(release.version, `${slug}: Version`).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
}
|
||||||
|
for (let i = 1; i < changelog.length; i += 1) {
|
||||||
|
expect(
|
||||||
|
compareSemver(changelog[i - 1].version, changelog[i].version),
|
||||||
|
`${slug}: ${changelog[i - 1].version} muss größer sein als ${changelog[i].version}`,
|
||||||
|
).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat echte Kalenderdaten, neueste oben', () => {
|
||||||
|
for (const release of changelog) {
|
||||||
|
expect(isRealDate(release.date), `${slug} ${release.version}: Datum ${release.date}`).toBe(true);
|
||||||
|
}
|
||||||
|
for (let i = 1; i < changelog.length; i += 1) {
|
||||||
|
expect(
|
||||||
|
changelog[i - 1].date >= changelog[i].date,
|
||||||
|
`${slug}: Daten dürfen nicht aufsteigen (${changelog[i - 1].date} / ${changelog[i].date})`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat je Version mindestens einen Eintrag mit gültiger Art und de/en-Text', () => {
|
||||||
|
for (const release of changelog) {
|
||||||
|
expect(release.changes.length, `${slug} ${release.version}: Einträge`).toBeGreaterThan(0);
|
||||||
|
for (const item of release.changes) {
|
||||||
|
expect(['new', 'changed', 'fixed']).toContain(item.kind);
|
||||||
|
expect(item.de.trim(), `${slug} ${release.version}: de leer`).not.toBe('');
|
||||||
|
expect(item.en.trim(), `${slug} ${release.version}: en leer`).not.toBe('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat keine Ersatzschreibungen in den deutschen Texten', () => {
|
||||||
|
for (const release of changelog) {
|
||||||
|
for (const item of release.changes) {
|
||||||
|
expect(item.de, `${slug} ${release.version}`).not.toMatch(REPLACEMENT_RE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enthält keine Mandanten- oder Lizenzbegriffe', () => {
|
||||||
|
for (const release of changelog) {
|
||||||
|
for (const item of release.changes) {
|
||||||
|
expect(item.de, `${slug} ${release.version} de`).not.toMatch(FORBIDDEN_RE);
|
||||||
|
expect(item.en, `${slug} ${release.version} en`).not.toMatch(FORBIDDEN_RE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Seeds und Changelogs gehören zusammen', () => {
|
||||||
|
it('findet die Seed-Dateien und liest Manifeste', async () => {
|
||||||
|
const { files, manifests } = await collectSeedManifests();
|
||||||
|
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(manifests.length).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Seed-Version entspricht dem neuesten Changelog-Eintrag', async () => {
|
||||||
|
const { manifests } = await collectSeedManifests();
|
||||||
|
for (const manifest of manifests) {
|
||||||
|
const changelog = MODULE_CHANGELOGS.get(manifest.slug);
|
||||||
|
if (!changelog) continue; // Vollständigkeit prüft der nächste Test
|
||||||
|
expect(manifest.version, `${manifest.slug}: Seed-Version`).toBe(changelog[0].version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Modul-Changelog (quick-261008-w5w).
|
||||||
|
*
|
||||||
|
* Eine Quelle der Wahrheit für die Versionsnummer eines Moduls: Jedes Modul hat
|
||||||
|
* neben seiner Seed-Datei eine `<name>.changelog.ts` mit den Änderungen,
|
||||||
|
* neueste Version zuerst. Die Seed-Datei liest ihre Version ausschließlich per
|
||||||
|
* `latestVersion(...)` von dort; der Marktplatz zeigt die Einträge über
|
||||||
|
* `GET /modules/changelog/:slug`. Der Wächter-Test `module-changelog.spec.ts`
|
||||||
|
* schlägt fehl, wenn Seed und Changelog auseinanderlaufen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ModuleChangeKind = 'new' | 'changed' | 'fixed';
|
||||||
|
|
||||||
|
export interface ModuleChangelogItem {
|
||||||
|
kind: ModuleChangeKind;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleChangelogRelease {
|
||||||
|
/** Modulversion im Muster Haupt.Neben.Fehler, z. B. `1.2.0`. */
|
||||||
|
version: string;
|
||||||
|
/** Datum der Tessera-Version (oder des Eintrags), Format JJJJ-MM-TT. */
|
||||||
|
date: string;
|
||||||
|
changes: readonly ModuleChangelogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neueste Version zuerst. */
|
||||||
|
export type ModuleChangelog = readonly ModuleChangelogRelease[];
|
||||||
|
|
||||||
|
/** Vergleicht zwei Versionen numerisch je Teil (1.10.0 ist größer als 1.9.2). */
|
||||||
|
export function compareSemver(a: string, b: string): number {
|
||||||
|
const pa = a.split('.').map(Number);
|
||||||
|
const pb = b.split('.').map(Number);
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||||
|
if (diff !== 0) return diff > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version des neuesten Eintrags; ein leerer Changelog ist ein Programmierfehler. */
|
||||||
|
export function latestVersion(changelog: ModuleChangelog): string {
|
||||||
|
if (changelog.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Der Modul-Changelog ist leer: Mindestens ein Eintrag (neueste Version zuerst) in der Datei <modul>.changelog.ts ist nötig.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return changelog[0].version;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||||
import { ModuleAccessService } from './module-access.service';
|
import { ModuleAccessService } from './module-access.service';
|
||||||
|
import { getModuleChangelog } from './module-changelog.registry';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
import { ModuleRegistryService } from './module-registry.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,10 @@ import { ModuleRegistryService } from './module-registry.service';
|
|||||||
* - GET /modules/active — list modules accessible to the requesting user
|
* - GET /modules/active — list modules accessible to the requesting user
|
||||||
* (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe
|
* (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe
|
||||||
* Auflösung wie ModuleGuard)
|
* Auflösung wie ModuleGuard)
|
||||||
|
* - GET /modules/changelog/:slug — Modul-Changelog aus dem Code (neueste Version
|
||||||
|
* zuerst; unbekannte Kennung → leere Liste). Das statische Segment `changelog`
|
||||||
|
* steht vorn, weil die Modul-Controller unter `modules/<slug>` eigene
|
||||||
|
* `:id`-Routen haben und `/modules/:slug/changelog` sonst verschattet würde.
|
||||||
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
|
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
|
||||||
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
|
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
|
||||||
*
|
*
|
||||||
@@ -106,6 +111,17 @@ export class ModuleRegistryController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /modules/changelog/:slug
|
||||||
|
* Liefert den Changelog eines eingebauten Moduls (quick-261008-w5w). Statische
|
||||||
|
* Produkttexte, daher wie der Katalog ohne @Roles für jeden angemeldeten
|
||||||
|
* Benutzer (T-03-03). Unbekannte und eigene Module → leere Liste.
|
||||||
|
*/
|
||||||
|
@Get('changelog/:slug')
|
||||||
|
getChangelog(@Param('slug') slug: string) {
|
||||||
|
return getModuleChangelog(slug);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /modules/:moduleId/activate
|
* POST /modules/:moduleId/activate
|
||||||
* Activates a module for the current tenant.
|
* Activates a module for the current tenant.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ModuleChangelog } from '../module-registry/module-changelog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungen am Modul Nextcloud-Status, neueste Version zuerst
|
||||||
|
* (quick-261008-w5w). Die Version in `nextcloud-status.seed.ts` kommt
|
||||||
|
* ausschließlich von hier.
|
||||||
|
*/
|
||||||
|
export const NEXTCLOUD_STATUS_CHANGELOG: ModuleChangelog = [
|
||||||
|
{
|
||||||
|
version: '1.1.0',
|
||||||
|
date: '2026-10-08',
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Über den Kacheln gibt es ein Suchfeld: Es zeigt nur die Clouds, deren Kundenname den eingegebenen Text enthält.',
|
||||||
|
en: 'A search field above the tiles shows only the clouds whose customer name contains the text you type.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Als Logo können Sie jetzt auch eine Bildadresse mit http:// eingeben; Tessera holt das Bild beim Speichern einmalig ab.',
|
||||||
|
en: 'You can now also enter an image address starting with http:// as the logo; Tessera fetches the image once when you save.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'changed',
|
||||||
|
de: 'Die Kacheln sind kompakter, Kundenname und Adresse stehen in voller Länge da, und die Knöpfe sitzen unten rechts.',
|
||||||
|
en: 'The tiles are more compact, customer name and address are shown in full, and the buttons sit at the bottom right.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'fixed',
|
||||||
|
de: 'Die Meldungen im Formular zum Hinzufügen und Bearbeiten einer Cloud erscheinen jetzt auf Deutsch.',
|
||||||
|
en: 'The messages in the form for adding and editing a cloud now appear in your chosen language.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
date: '2026-10-06',
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Für jede Nextcloud Ihrer Kunden eine Kachel mit Ampel (Version, Support-Ende, Erreichbarkeit); Tessera prüft stündlich, die Kacheln lassen sich sortieren.',
|
||||||
|
en: 'A tile with a traffic light (version, end of support, availability) for every customer Nextcloud; Tessera checks hourly and the tiles can be sorted.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Die Glocke „Benachrichtigen“ meldet Störungen und Entwarnung per E-Mail und auf dem Bildschirm.',
|
||||||
|
en: 'The “Notify” bell reports problems and recoveries by email and on screen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Bei „Nicht erreichbar“ nennt die Kachel den Grund in Klartext.',
|
||||||
|
en: 'For “Not reachable”, the tile states the reason in plain words.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'new',
|
||||||
|
de: 'Eine Dashboard-Kachel zeigt die Zähler für Grün, Gelb und Rot.',
|
||||||
|
en: 'A dashboard tile shows the counts for green, yellow and red.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { latestVersion } from '../module-registry/module-changelog';
|
||||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
import { NEXTCLOUD_STATUS_CHANGELOG } from './nextcloud-status.changelog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds the nextcloud-status module into the module registry
|
* Seeds the nextcloud-status module into the module registry
|
||||||
@@ -12,7 +14,8 @@ export async function seedNextcloudStatusModule(
|
|||||||
await moduleRegistryService.seedModule({
|
await moduleRegistryService.seedModule({
|
||||||
slug: 'nextcloud-status',
|
slug: 'nextcloud-status',
|
||||||
name: 'Nextcloud-Status',
|
name: 'Nextcloud-Status',
|
||||||
version: '1.0.0',
|
// Die Version kommt ausschließlich aus dem Changelog (quick-261008-w5w).
|
||||||
|
version: latestVersion(NEXTCLOUD_STATUS_CHANGELOG),
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
description: {
|
description: {
|
||||||
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',
|
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
@@ -18,6 +18,12 @@ vi.mock('next-intl', () => ({
|
|||||||
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
|
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
|
||||||
deactivateDialogConfirm: 'Deaktivieren',
|
deactivateDialogConfirm: 'Deaktivieren',
|
||||||
deactivateDialogCancel: 'Abbrechen',
|
deactivateDialogCancel: 'Abbrechen',
|
||||||
|
changelogTitle: 'Änderungen',
|
||||||
|
changelogCurrent: 'Aktuell',
|
||||||
|
changelogRelease: 'Version {version} vom {date}',
|
||||||
|
changelogKindNew: 'Neu',
|
||||||
|
changelogKindChanged: 'Geändert',
|
||||||
|
changelogKindFixed: 'Behoben',
|
||||||
};
|
};
|
||||||
let result = translations[key] ?? key;
|
let result = translations[key] ?? key;
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -88,6 +94,43 @@ const mockCatalogActive = [
|
|||||||
mockCatalog[1],
|
mockCatalog[1],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockReleases = [
|
||||||
|
{
|
||||||
|
version: '1.1.0',
|
||||||
|
date: '2026-10-08',
|
||||||
|
changes: [
|
||||||
|
{ kind: 'fixed', de: 'Behobener Fehler im Formular', en: 'Fixed form bug' },
|
||||||
|
{ kind: 'new', de: 'Neues Suchfeld', en: 'New search field' },
|
||||||
|
{ kind: 'changed', de: 'Kompaktere Kacheln', en: 'More compact tiles' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
date: '2026-10-06',
|
||||||
|
changes: [{ kind: 'new', de: 'Erste Fassung der Kacheln', en: 'First version of the tiles' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** URL-abhaengige fetch-Attrappe: Katalog und Changelog getrennt. */
|
||||||
|
function stubFetch(catalog: unknown, changelog: () => Promise<unknown> | unknown = () => []) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/modules/catalog')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalog) });
|
||||||
|
}
|
||||||
|
if (url.includes('/modules/changelog/')) {
|
||||||
|
return Promise.resolve(changelog()).then((res) =>
|
||||||
|
res && typeof res === 'object' && 'ok' in (res as object)
|
||||||
|
? res
|
||||||
|
: { ok: true, json: () => Promise.resolve(res) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -169,4 +212,81 @@ describe('Module Detail Page', () => {
|
|||||||
expect(screen.getByText('notFound')).toBeInTheDocument();
|
expect(screen.getByText('notFound')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeigt den Abschnitt Aenderungen: neueste Version offen mit Aktuell, aeltere zugeklappt', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
stubFetch(mockCatalogActive, () => mockReleases);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
const { container } = render(<Page />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Änderungen' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Aktuell')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Neues Suchfeld')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Kompaktere Kacheln')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Behobener Fehler im Formular')).toBeInTheDocument();
|
||||||
|
// Gruppen in fester Reihenfolge Neu, Geaendert, Behoben
|
||||||
|
const labels = Array.from(container.querySelectorAll('section > div p.uppercase')).map(
|
||||||
|
(n) => n.textContent,
|
||||||
|
);
|
||||||
|
expect(labels).toEqual(['Neu', 'Geändert', 'Behoben']);
|
||||||
|
|
||||||
|
const details = container.querySelectorAll('details');
|
||||||
|
expect(details).toHaveLength(1);
|
||||||
|
expect(details[0].open).toBe(false);
|
||||||
|
const summary = within(details[0] as HTMLElement).getByText(/Version 1\.0\.0 vom/);
|
||||||
|
expect(summary.tagName).toBe('SUMMARY');
|
||||||
|
expect(summary.textContent).toContain('2026');
|
||||||
|
expect(within(details[0] as HTMLElement).getByText('Erste Fassung der Kacheln')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt bei leerer Liste keinen Abschnitt Aenderungen', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
stubFetch(mockCatalogActive, () => []);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt das Modul normal, wenn der Changelog-Abruf mit 500 antwortet', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
stubFetch(mockCatalogActive, () => ({ ok: false, status: 500, json: () => Promise.resolve({}) }));
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt das Modul normal bei Netzwerkfehler des Changelog-Abrufs', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
stubFetch(mockCatalogActive, () => Promise.reject(new Error('offline')));
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft eine Antwort, die kein Array von Versionen ist', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
stubFetch(mockCatalogActive, () => [{ foo: 'bar' }]);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
stubFetch(mockCatalogActive, () => ({ version: '1.0.0', date: 'x', changes: [] }));
|
||||||
|
render(<Page />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/lib/stores/auth-store';
|
|||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { useToastStore } from '../components/Toast';
|
import { useToastStore } from '../components/Toast';
|
||||||
import { ActivationDialog } from '../components/ActivationDialog';
|
import { ActivationDialog } from '../components/ActivationDialog';
|
||||||
|
import { ModuleChangelog, type ChangelogRelease } from '../components/ModuleChangelog';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default function ModuleDetailPage() {
|
|||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [pendingDeactivation, setPendingDeactivation] = useState(false);
|
const [pendingDeactivation, setPendingDeactivation] = useState(false);
|
||||||
|
const [releases, setReleases] = useState<ChangelogRelease[]>([]);
|
||||||
|
|
||||||
// isAdmin gates module MANAGEMENT (activate/deactivate) only — the
|
// isAdmin gates module MANAGEMENT (activate/deactivate) only — the
|
||||||
// catalog itself stays open to every authenticated user (D-08).
|
// catalog itself stays open to every authenticated user (D-08).
|
||||||
@@ -79,6 +81,46 @@ export default function ModuleDetailPage() {
|
|||||||
fetchModule();
|
fetchModule();
|
||||||
}, [fetchModule]);
|
}, [fetchModule]);
|
||||||
|
|
||||||
|
// quick-261008-w5w: Modul-Changelog; jeder Fehler oder jede unerwartete Antwort
|
||||||
|
// ergibt eine leere Liste (kein Abschnitt, kein Toast).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (selectedTenantId) {
|
||||||
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_URL}/modules/changelog/${encodeURIComponent(slug)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!cancelled) setReleases([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: unknown = await res.json();
|
||||||
|
const valid =
|
||||||
|
Array.isArray(data) &&
|
||||||
|
data.every(
|
||||||
|
(r) =>
|
||||||
|
r !== null &&
|
||||||
|
typeof r === 'object' &&
|
||||||
|
typeof (r as ChangelogRelease).version === 'string' &&
|
||||||
|
typeof (r as ChangelogRelease).date === 'string' &&
|
||||||
|
Array.isArray((r as ChangelogRelease).changes),
|
||||||
|
);
|
||||||
|
if (!cancelled) setReleases(valid ? (data as ChangelogRelease[]) : []);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setReleases([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [slug, selectedTenantId]);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (module?.isActiveForTenant) {
|
if (module?.isActiveForTenant) {
|
||||||
setPendingDeactivation(true);
|
setPendingDeactivation(true);
|
||||||
@@ -194,6 +236,8 @@ export default function ModuleDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ModuleChangelog releases={releases} locale={locale} />
|
||||||
|
|
||||||
<ActivationDialog
|
<ActivationDialog
|
||||||
open={pendingDeactivation}
|
open={pendingDeactivation}
|
||||||
moduleName={module.name}
|
moduleName={module.name}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschnitt "Änderungen" der Marktplatz-Detailseite (quick-261008-w5w).
|
||||||
|
* Eigene schmale Schnittstellen, kein Import aus der API. Texte werden nur als
|
||||||
|
* React-Textknoten ausgegeben (kein HTML, kein Markdown; T-261008-w5w-03).
|
||||||
|
*/
|
||||||
|
export type ChangeKind = 'new' | 'changed' | 'fixed';
|
||||||
|
|
||||||
|
export interface ChangelogItem {
|
||||||
|
kind: ChangeKind;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangelogRelease {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
changes: ChangelogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ORDER: ChangeKind[] = ['new', 'changed', 'fixed'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
releases: ChangelogRelease[];
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string, locale: string): string {
|
||||||
|
const parsed = new Date(`${date}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return date;
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseChanges({ release, locale }: { release: ChangelogRelease; locale: string }) {
|
||||||
|
const t = useTranslations('marketplace');
|
||||||
|
const kindLabel: Record<ChangeKind, string> = {
|
||||||
|
new: t('changelogKindNew'),
|
||||||
|
changed: t('changelogKindChanged'),
|
||||||
|
fixed: t('changelogKindFixed'),
|
||||||
|
};
|
||||||
|
const text = (item: ChangelogItem) =>
|
||||||
|
(locale === 'en' ? item.en : item.de) || item.de;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{KIND_ORDER.map((kind) => {
|
||||||
|
const items = release.changes.filter((c) => c.kind === kind);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={kind}>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{kindLabel[kind]}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm text-foreground">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={`${kind}-${index}`}>{text(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleChangelog({ releases, locale }: Props) {
|
||||||
|
const t = useTranslations('marketplace');
|
||||||
|
if (releases.length === 0) return null;
|
||||||
|
const [latest, ...older] = releases;
|
||||||
|
|
||||||
|
const releaseLabel = (release: ChangelogRelease) =>
|
||||||
|
t('changelogRelease', { version: release.version, date: formatDate(release.date, locale) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t('changelogTitle')}</h2>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-card p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">{releaseLabel(latest)}</h3>
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{t('changelogCurrent')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ReleaseChanges release={latest} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{older.map((release) => (
|
||||||
|
<details key={release.version} className="rounded-md border border-border bg-card p-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium text-foreground">
|
||||||
|
{releaseLabel(release)}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3">
|
||||||
|
<ReleaseChanges release={release} locale={locale} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1234,6 +1234,12 @@
|
|||||||
"tenantContextLabel": "Organisation",
|
"tenantContextLabel": "Organisation",
|
||||||
"backToMarketplace": "Zurück zum Marktplatz",
|
"backToMarketplace": "Zurück zum Marktplatz",
|
||||||
"detailVersion": "Version {version}",
|
"detailVersion": "Version {version}",
|
||||||
|
"changelogTitle": "Änderungen",
|
||||||
|
"changelogCurrent": "Aktuell",
|
||||||
|
"changelogRelease": "Version {version} vom {date}",
|
||||||
|
"changelogKindNew": "Neu",
|
||||||
|
"changelogKindChanged": "Geändert",
|
||||||
|
"changelogKindFixed": "Behoben",
|
||||||
"detailStatusActive": "Aktiviert",
|
"detailStatusActive": "Aktiviert",
|
||||||
"detailStatusInactive": "Nicht aktiviert",
|
"detailStatusInactive": "Nicht aktiviert",
|
||||||
"accessDenied": "Zugriff verweigert",
|
"accessDenied": "Zugriff verweigert",
|
||||||
|
|||||||
@@ -1234,6 +1234,12 @@
|
|||||||
"tenantContextLabel": "Organization",
|
"tenantContextLabel": "Organization",
|
||||||
"backToMarketplace": "Back to Marketplace",
|
"backToMarketplace": "Back to Marketplace",
|
||||||
"detailVersion": "Version {version}",
|
"detailVersion": "Version {version}",
|
||||||
|
"changelogTitle": "Changes",
|
||||||
|
"changelogCurrent": "Current",
|
||||||
|
"changelogRelease": "Version {version}, {date}",
|
||||||
|
"changelogKindNew": "New",
|
||||||
|
"changelogKindChanged": "Changed",
|
||||||
|
"changelogKindFixed": "Fixed",
|
||||||
"detailStatusActive": "Activated",
|
"detailStatusActive": "Activated",
|
||||||
"detailStatusInactive": "Not activated",
|
"detailStatusInactive": "Not activated",
|
||||||
"accessDenied": "Access denied",
|
"accessDenied": "Access denied",
|
||||||
|
|||||||
Reference in New Issue
Block a user