diff --git a/.gitignore b/.gitignore index e53a96d..489ee85 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,13 @@ logs #VSC .history .wrangler + +# AI tooling +.claude +.agents +.cursor +.windsurf +.aider* +CLAUDE.md +AGENTS.md +skills-lock.json diff --git a/README.md b/README.md index d0a6626..3a45496 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **The documentation site for [upcore](https://github.com/upcore-app/upcore)** — self-hosted uptime monitoring with public status pages. -[docs.upcore.app](https://docs.upcore.app) · [Product repo](https://github.com/upcore-app/upcore) · [upcore Cloud](https://go.upcore.app) +[docs.upcore.app](https://docs.upcore.app) · [Product repo](https://github.com/upcore-app/upcore) · [upcore Cloud](https://go.upcore.app/register) · [Discord](https://discord.gg/eWeaQZYcyd) @@ -80,3 +80,9 @@ CI publishes the image to `ghcr.io/upcore-app/docs` on every push to `main`. Issues and pull requests are welcome. For anything about upcore itself — features, bugs, security — use the [product repo](https://github.com/upcore-app/upcore). + +## Support + +Questions and support run through our Discord: +[discord.gg/eWeaQZYcyd](https://discord.gg/eWeaQZYcyd). Want upcore without hosting it +yourself? [upcore Cloud](https://go.upcore.app/register) is free to start. diff --git a/app/app.config.ts b/app/app.config.ts index 2a8709e..c38a69a 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -43,6 +43,7 @@ export default defineAppConfig({ socials: { github: 'https://github.com/upcore-app/upcore', + discord: 'https://discord.gg/eWeaQZYcyd', }, toc: { @@ -56,6 +57,18 @@ export default defineAppConfig({ to: 'https://github.com/upcore-app/upcore', target: '_blank', }, + { + icon: 'i-simple-icons-discord', + label: 'Support on Discord', + to: 'https://discord.gg/eWeaQZYcyd', + target: '_blank', + }, + { + icon: 'i-lucide-cloud', + label: 'Start upcore Cloud', + to: 'https://go.upcore.app/register', + target: '_blank', + }, { icon: 'i-lucide-shield-check', label: 'Report a vulnerability', diff --git a/app/components/app/AppFooterLeft.vue b/app/components/app/AppFooterLeft.vue new file mode 100644 index 0000000..df039da --- /dev/null +++ b/app/components/app/AppFooterLeft.vue @@ -0,0 +1,23 @@ + + + diff --git a/content/de/1.getting-started/1.introduction.md b/content/de/1.getting-started/1.introduction.md index 4b67bfd..126b3b7 100644 --- a/content/de/1.getting-started/1.introduction.md +++ b/content/de/1.getting-started/1.introduction.md @@ -142,7 +142,7 @@ ersten geschrieben, sofern nichts anderes dabeisteht. #description Dasselbe upcore, betrieben von onesrv auf - [go.upcore.app](https://go.upcore.app). Kostenlos starten, upgraden wenn es eng wird; + [go.upcore.app](https://go.upcore.app/register). Kostenlos starten, upgraden wenn es eng wird; keine Datenbank, kein Update und kein Zertifikat, um das du dich kümmerst. ::: :: diff --git a/content/de/2.guide/5.outposts.md b/content/de/2.guide/5.outposts.md index a3ffa17..dc55503 100644 --- a/content/de/2.guide/5.outposts.md +++ b/content/de/2.guide/5.outposts.md @@ -164,3 +164,29 @@ Outpost-Limit deines Tarifs — die stellst du bereit und bezahlst sie. Nimmt ein Tarifwechsel einen geteilten Standort weg, werden die davon abhängigen Zuweisungen genau dort entfernt statt bei der nächsten Check-Runde. Ein Monitor verliert einen Standort, nie seinen Check. + +### Sehen, welche Standorte ein Tarif hinzufügen würde + +Eine Probe, die dein Tarif nicht enthält, fehlt im Monitor-Formular nicht einfach +— sie steht dort, ausgegraut, mit einem Schloss und den Tarifen, in denen sie +enthalten ist. Ein Standort, der gar nicht auftaucht, liest sich wie ein Standort, +den es nicht gibt, und das ist die falsche Auskunft über eine Flotte, die jemand +anders betreibt. + +Es ist ein Hinweis und keine Checkbox: auswählbar ist er nicht, und eine +deaktivierte Checkbox, die sich nie setzen lässt, liest sich wie ein Fehler statt +wie ein Preis. Die Liste vergibt auch nichts — ein Monitor-Schreibvorgang prüft +weiterhin gegen die Standorte, die der Organisation tatsächlich zur Verfügung +stehen. Mehr als anzeigen kann ein Client damit also nicht. + +Auf jeder self-hosted Instanz und innerhalb der Operator-Organisation ist die +Liste leer. + +::note +Geteilt wird über Tags, nicht über eine Liste von IDs: Der Operator stellt eine +Maschine bereit, taggt sie mit `eu`, und jeder Tarif mit diesem Tag bekommt sie — +ohne dass ein einziger Tarif bearbeitet wird. Wird die Maschine abgebaut, +verschwindet sie aus allen Tarifen gleichzeitig. Taggen lassen sich nur die +Outposts des Operators — ein Tag an der eigenen Probe eines Mandanten würde nichts +bedeuten und wird deshalb abgelehnt statt gespeichert und ignoriert. +:: diff --git a/content/de/2.guide/6.topology.md b/content/de/2.guide/6.topology.md new file mode 100644 index 0000000..a5bff36 --- /dev/null +++ b/content/de/2.guide/6.topology.md @@ -0,0 +1,154 @@ +--- +title: Topologie +description: Wer prüft was — als Bild. Und die drei Fälle, in denen das nicht dem + entspricht, was am Monitor eingestellt ist. +navigation: + icon: i-lucide-network +--- + +Jede Zahl auf dieser Seite gibt es woanders schon. Das Monitor-Formular kennt +seine Check-Strategie, die Outpost-Liste kennt die Zahl der Monitore pro Probe — +und keines von beiden sagt, was der Runner aus den beiden tatsächlich macht. + +Die **Topologie** sagt es. Ein Bildschirm, eine Frage: *welcher Standort erreicht +gerade welchen Monitor* — und interessant wird es dort, wo die Antwort von der +Konfiguration abweicht. + +![Der Topologie-Graph: links der zentrale Check, rechts ein Knoten pro Monitor](/screenshots/de/topology.jpg) + +## Die Fläche lesen + +Links steht jeder Ort, von dem ein Check kommen kann: **Zentral** — die +upcore-Instanz selbst — und darunter jeder Outpost, den diese Organisation nutzen +darf, einschließlich der Probes, die gerade nichts tragen. Eine untätige Probe ist +kein Problem, aber sie gehört zur Flotte und bleibt deshalb auf der Fläche. + +Rechts steht ein Knoten pro Monitor. Eine Kante dazwischen heißt: dieser Standort +prüft diesen Monitor. + +Zwei Monitor-Typen fehlen mit Absicht. Ein `group`-Monitor fasst andere Monitore +zusammen, statt etwas zu erreichen, und ein `push`-Monitor wird gerufen, statt zu +rufen — keiner von beiden wird von einem Standort aus versendet. Ihre Mitglieder +und die Push-Endpunkte bleiben als eigene Monitore sichtbar. + +### Den Farben folgen + +Eine Kante trägt die Farbe dessen, was der Standort zuletzt gemeldet hat: + +| | | +| --- | --- | +| grün | erreichbar | +| gelb | beeinträchtigt | +| rot | Störung | +| grau | still — dieser Standort hat im Zeitfenster nichts gemeldet | + +Wer eine Kante überfährt, bekommt die Zahlen dahinter: letzter Status, Latenz und +Meldung mit dem Zeitpunkt, dazu Uptime, durchschnittliche Latenz und die Zahl der +Checks der letzten **24 Stunden** — für genau diesen Standort und genau diesen +Monitor. + +::note +Ein Monitor, der von genau einem Ort geprüft wird, schreibt gar keine Zeilen pro +Standort: Der Runner spaltet eine Runde erst auf, wenn mehr als ein Standort +gemeldet hat. Sein aggregierter Heartbeat *ist* das Urteil dieses Standorts, und +der Graph liest es deshalb am Monitor ab, statt eine leere Kante zu zeigen, die in +Wahrheit das Einzige ist, was den Monitor oben hält. +:: + +## Die drei Überraschungen erkennen + +Eine Kante kann einen Hinweis tragen, und jeder benennt einen Fall, in dem Bild +und Konfiguration auseinandergelaufen sind: + +::u-page-grid + :::u-page-card + --- + icon: i-lucide-life-buoy + variant: subtle + --- + #title + fallback + + #description + Der Monitor hat entfernte Standorte verlangt, keiner davon kann ihn bedienen, + und der zentrale Check hat übernommen. Nirgends sonst in der Admin-Oberfläche + steht das — der Monitor liest sich weiterhin als „von Outposts geprüft“, und das + ist er nicht. + ::: + + :::u-page-card + --- + icon: i-lucide-power-off + variant: subtle + --- + #title + inactive + + #description + Eine zugewiesene Probe, die abgeschaltet ist. Sie steht auf der Fläche, weil du + sie verlangt hast; angesprochen wird sie nicht. + ::: + + :::u-page-card + --- + icon: i-lucide-shield-alert + variant: subtle + --- + #title + unverified + + #description + Eine zugewiesene Probe, die upcore nie zurück erreicht hat. Wird ebenfalls + übersprungen — siehe [Outposts](/de/guide/outposts) dazu, was Verifikation ist + und warum eine unverifizierte Probe inert bleibt. + ::: +:: + +## Kacheln und Filter nutzen + +Über der Fläche stehen vier Zähler: + +| Kachel | Zählt | +| --- | --- | +| **Nur zentral geprüft** | Monitore, die kein Standort bedient. Ein `both`-Monitor gehört nicht dazu. | +| **Von Outposts geprüft** | Monitore, für die mindestens eine Probe tatsächlich angesprochen wird. | +| **Standorte einsatzbereit** | Outposts, die aktiv *und* verifiziert sind, von allen. | +| **Auffälligkeiten** | Ausfallende Standorte plus Monitore, die zurückgefallen sind. | + +**Nur Auffälligkeiten** reduziert die Fläche auf die Monitore, die etwas zu sagen +haben — ein Standort, der gerade ausfällt, eine Zuweisung, die der Runner nicht +bedienen kann, oder ein abgeschalteter Monitor — und lässt die Probes weg, die +nichts davon tragen. Zweihundert Monitore liest man nicht, indem man sie alle +ansieht. + +Die Seite aktualisiert sich alle 30 Sekunden und lässt sich anhalten. Sie ist +langsamer als die Outpost-Liste, und zwar absichtlich: eine Kante, die unter dem +Mauszeiger die Farbe wechselt, ist eine Unterbrechung, und hier gibt es mehr +davon. + +## Wissen, wer sie öffnen darf + +Nur Admins. Editoren sehen sie nicht in der Navigation, und +`/api/outposts/topology` weist sie ab — die Antwort enthält Outpost-Zeilen, und +unter welchen Adressen eine Probe erreichbar ist, geht einen Editor nichts an. + +::note +Das ganze Bild ist ein Request: `GET /api/outposts/topology` auf der +sessionauthentifizierten Admin-API, nicht auf [`/api/v1`](/de/api/authentication). +:: + +## Wie es weitergeht + +::u-page-links +--- +links: + - label: Outposts + icon: i-lucide-globe + to: /de/guide/outposts + description: Was eine Probe ist, wie sie sich anmeldet und wie die Standorte abstimmen. + - label: Monitore + icon: i-lucide-activity + to: /de/guide/monitors + description: Wo Check-Strategie und Outpost-Policy eines Monitors gesetzt werden. +--- +:: diff --git a/content/de/2.guide/6.embeds-and-badges.md b/content/de/2.guide/7.embeds-and-badges.md similarity index 100% rename from content/de/2.guide/6.embeds-and-badges.md rename to content/de/2.guide/7.embeds-and-badges.md diff --git a/content/de/2.guide/7.users-and-access.md b/content/de/2.guide/8.users-and-access.md similarity index 71% rename from content/de/2.guide/7.users-and-access.md rename to content/de/2.guide/8.users-and-access.md index 7cc5291..dd93d3f 100644 --- a/content/de/2.guide/7.users-and-access.md +++ b/content/de/2.guide/8.users-and-access.md @@ -79,3 +79,27 @@ Die Session trägt keine Rolle. Die Rolle wird pro Request gegen die angesproche Organisation aufgelöst — eine gegen eine andere Organisation wiedereingespielte Session findet also keine Mitgliedschaft und wird abgelehnt, was sie anderswo auch sein mag. + +### Organisation wechseln + +Ein Konto kann mehreren angehören. In der upcore Cloud nennt der Fuß der +Seitenleiste die Organisation, in der du bist, und öffnet die anderen in einem +Popover — self-hosted wird er nicht gerendert, und für ein Konto mit einer +einzigen Mitgliedschaft auch nicht: ein Menü mit einem Eintrag führt dorthin +zurück, wo man ohnehin schon ist. + +Der Umschalter **betritt eine Organisation nicht selbst**. Sitzungen sind an den +Host gebunden, und eine Übergabe aus einem Mandanten heraus zu erzeugen wird +bewusst abgelehnt: Genau diese Ablehnung verhindert, dass ein Fuß in der Tür einer +Organisation zu einer Sitzung in jeder anderen wird, die das Konto erreichen kann. +Ein Eintrag verlinkt deshalb auf die Auswahl auf der Hauptdomain und benennt das +Ziel; betreten wird es dort beim Ankommen — und die Mitgliedschaft wird +serverseitig neu gelesen. Die benannte Organisation ist also eine Vorauswahl und +nie eine Berechtigung. + +## Das Konto selbst absichern + +Rollen und Keys entscheiden, was eine Sitzung darf. Was es braucht, um überhaupt +eine zu *bekommen* — Passkeys, Authenticator-App, Wiederherstellungscodes, die +Pflicht für die ganze Organisation und die Liste der angemeldeten Geräte — steht +unter [Kontosicherheit](/de/guide/account-security). diff --git a/content/de/2.guide/9.account-security.md b/content/de/2.guide/9.account-security.md new file mode 100644 index 0000000..adbd7fc --- /dev/null +++ b/content/de/2.guide/9.account-security.md @@ -0,0 +1,163 @@ +--- +title: Kontosicherheit +description: Passkeys, Authenticator-App, Wiederherstellungscodes, die Pflicht für + die ganze Organisation — und die Liste der Geräte, auf denen dein Konto + angemeldet ist. +navigation: + icon: i-lucide-shield-check +--- + +Eine Rolle entscheidet, was du ändern darfst. Auf dieser Seite geht es um die +andere Hälfte: nachzuweisen, dass du es bist — und jedes Gerät zu sehen, das +diesen Nachweis schon erbracht hat. + +Alles davon liegt unter **Mein Konto**, und alles davon gehört zum *Konto* und +nicht zur Organisation. Dasselbe Konto kann eine Sitzung auf drei Subdomains +halten; dahinter steht ein einziger Satz Faktoren. + +![Mein Konto: die Passkey-Karte und die Karte für die Authenticator-App](/screenshots/de/account-security.jpg) + +## Einen Passkey hinterlegen + +Ein Passkey meldet dich mit Fingerabdruck, Gesicht oder Geräte-PIN an. Der private +Schlüssel verlässt das Gerät nie und die Signatur gilt nur für diese Adresse — ein +Passkey **zählt allein als zwei Faktoren** und lässt sich nicht abphishen. + +Hinterlegt wird er unter **Mein Konto → Passkeys**, benannt nach dem Gerät; +danach bietet die Anmeldeseite **Mit Passkey anmelden** an. Bis zu **10** pro +Konto — genug für Laptop, Telefon und den Hardware-Schlüssel in der Schublade. + +## Eine Authenticator-App einrichten + +Ein zeitbasierter Code (TOTP) aus 1Password, Aegis, Bitwarden, Google +Authenticator oder allem anderen, was den Standard spricht. + +::steps{level="3"} + +### QR-Code scannen + +Oder das Geheimnis von Hand eintippen — beides wird angezeigt. + +### Einen Code bestätigen + +Die Einrichtung ist erst fertig, wenn die App einen Code erzeugt hat, der aufgeht. +Ein Geheimnis, das geschrieben, aber nie bestätigt wurde, wird beim nächsten Mal +wieder angeboten, statt von vorn zu beginnen. + +### Wiederherstellungscodes notieren + +Zehn Stück, je einmal verwendbar, und sie werden **genau einmal** angezeigt. +Gespeichert werden nur ihre Hashes — dieser eine Bildschirm ist der einzige Ort, +an dem sie lesbar existieren. Die Karte zeigt, wie viele übrig sind, und neue +lassen sich erzeugen, was den alten Satz ungültig macht. + +:: + +Das Bestätigen des Codes markiert außerdem die Sitzung, in der du gerade bist, als +Zwei-Faktor-Sitzung. Du hast den Besitz des Geräts nachgewiesen; dich zurück zur +Anmeldung zu schicken, um ihn ein zweites Mal nachzuweisen, wäre Theater. + +## Es für alle verlangen + +Ein Admin schaltet das unter **Einstellungen → Zwei-Faktor-Authentifizierung → +Zweiten Faktor für alle Mitglieder verlangen** ein. Jede Sitzung in dieser +Organisation muss dann mit einem Passkey, einem Code aus der Authenticator-App +oder über SSO angemeldet sein. Ein Passwort allein reicht nicht mehr. + +![Die Zwei-Faktor-Einstellung der Organisation](/screenshots/de/two-factor-required.jpg) + +::warning +Richte **zuerst** einen eigenen Faktor ein und melde dich damit an. Die +Einstellung gilt für dich genauso wie für alle anderen. +:: + +Eine Sitzung, der fehlt, was die Organisation verlangt, bekommt keine halb +funktionierende Admin-Oberfläche — die Route-Middleware schickt sie auf +`/two-factor`, die eine Seite, die etwas dagegen tun kann. Diese Seite entscheidet +danach, was das Konto besitzt: + +- **Es hat einen Faktor** → es weist ihn nach und landet auf `/admin`. +- **Es hat keinen** → es richtet einen ein, und das Einrichten ist zugleich der + Nachweis. + +Keiner der beiden Wege endet mit „jetzt bitte neu anmelden“. + +::note +In der upcore Cloud ist die Pflicht für die Operator-Organisation fest an und der +Schalter gesperrt. Ein über SSO angemeldetes Konto gilt für diese Regel bereits +als zwei Faktoren — seine Faktoren sind Sache des Identity Providers, und hier +gibt es nichts einzurichten. +:: + +## Den letzten Faktor behalten + +Einen Passkey zu entfernen oder die Authenticator-App abzuschalten verlangt dein +Passwort. Etwas hinzuzufügen nie: Das Einrichten weist den Besitz selbst nach, +während das Entfernen das Konto *schwächt* — und das Passwort ist das, was daraus +eine Entscheidung macht statt etwas, das eine geliehene Sitzung nebenbei erledigt. +Ein Konto von einem Identity Provider hat kein Passwort und wird auch nicht danach +gefragt. + +Zusätzlich verweigert upcore das Entfernen des letzten Faktors, solange **irgendeine** Organisation, der du angehörst, einen verlangt — und benennt sie in der +Ablehnung. Geprüft wird über alle Mitgliedschaften hinweg und nicht nur über den +Mandanten, auf dem du gerade bist: Ein Mitglied einer strengen Organisation darf +seinen Faktor nicht aus einer laxen heraus abschalten und damit den Zugang zur +strengen verlieren. + +## Ein Gerät abmelden + +**Mein Konto → Angemeldete Geräte** listet jede offene Anmeldung dieses Kontos — +auch auf anderen Subdomains. + +![Die Liste der angemeldeten Geräte, das eigene ist markiert](/screenshots/de/sessions.jpg) + +Jede Zeile trägt Browser und Betriebssystem, soweit sie sich erkennen lassen, die +Adresse, von der die Anmeldung kam, sowie Beginn und letzte Aktivität. Der +Browser, in dem du das hier liest, ist als **Dieses Gerät** markiert und wird als +*abmelden* angeboten statt als *entziehen* — dasselbe, gesagt in dem Wort, das man +erwartet. + +| | | +| --- | --- | +| **Eine Zeile** | Meldet dieses Gerät ab. | +| **Überall sonst abmelden** | Jede Sitzung außer diesem Browser. Der Griff für ein verlorenes Telefon oder einen Laptop, den jemand nicht mehr hat. | + +Beides verlangt kein Passwort. Zugang wegzunehmen ist das Gegenteil davon, das +Konto zu schwächen, und ein Bestätigungsdialog ist genau die richtige Menge +Reibung. Wirksam wird es beim **nächsten Request** des anderen Geräts, nicht erst +bei dessen nächster Anmeldung. + +## Die Form einer Anmeldung kennen + +Eine Passwort-Anmeldung, die einen Code braucht, bekommt nicht zuerst eine +Sitzung. Der Passwort-Schritt hinterlässt eine versiegelte Notiz, die fünf Minuten +gilt und nichts benennt als das Konto; der Code-Schritt verbraucht sie und erzeugt +die Sitzung. Eine Sitzung ist das, was jemand fürs Authentifizieren bekommt — und +keine Markierung dafür, nicht fertig zu sein. + +Beide Schritte sind begrenzt: zehn Versuche pro Konto in fünf Minuten und fünfzig +pro Quelladresse. So bleiben sechs Ziffern sechs Ziffern und werden nicht zu etwas, +das sich in Masse raten lässt. + +::note +Hinter dieser Seite stehen `/api/me/passkeys`, `/api/me/two-factor`, +`/api/me/step-up` und `/api/me/sessions` auf der sessionauthentifizierten +Admin-API. Keiner davon existiert unter [`/api/v1`](/de/api/authentication): Ein +API-Key kann keinen Faktor einrichten, keinen entfernen und kein Gerät abmelden. +:: + +## Wie es weitergeht + +::u-page-links +--- +links: + - label: Benutzer & Zugriff + icon: i-lucide-users + to: /de/guide/users-and-access + description: Rollen, API-Keys und Single Sign-on. + - label: Sicherheit + icon: i-lucide-shield-check + to: /de/operations/security + description: Was upcore selbst schützt und was es dir überlässt. +--- +:: diff --git a/content/de/3.operations/5.security.md b/content/de/3.operations/5.security.md index 2dbc37c..df197e7 100644 --- a/content/de/3.operations/5.security.md +++ b/content/de/3.operations/5.security.md @@ -61,6 +61,14 @@ Admins können eigenes CSS auf öffentliche Statusseiten setzen und ausgehende Webhook-Ziele konfigurieren. Beides sind legitime Funktionen, und beides ist mächtig. +### Einen zweiten Faktor verlangen + +**Einstellungen → Zwei-Faktor-Authentifizierung.** upcore rate-limitet Logins +nicht, ein Passwort allein ist also nur so gut wie das Passwort. Richte deinen +eigenen Passkey oder deine Authenticator-App ein und melde dich damit an, *bevor* +du die Pflicht einschaltest — sie gilt für dich genauso. Siehe +[Kontosicherheit](/de/guide/account-security). + :: ::warning @@ -73,7 +81,8 @@ Hostnamen aus Monitornamen heraus, die auf einer öffentlichen Seite erscheinen. | | | | --- | --- | -| **Sessions** | Cookies, versiegelt mit `NUXT_SESSION_PASSWORD`. Passwörter gehasht gespeichert, nie im Klartext. | +| **Sessions** | Cookies, versiegelt mit `NUXT_SESSION_PASSWORD`. Passwörter gehasht gespeichert, nie im Klartext. Jede Sitzung ist eine eigene Zeile — ein Gerät abzumelden wirkt deshalb bei dessen nächstem Request und nicht erst bei dessen nächster Anmeldung, siehe [Kontosicherheit](/de/guide/account-security). | +| **Zweiter Faktor** | Passkeys (WebAuthn) und TOTP mit einmal verwendbaren Wiederherstellungscodes, von denen nur Hashes gespeichert werden. Eine Organisation kann ihn von allen Mitgliedern verlangen, und der letzte Faktor lässt sich nicht entfernen, solange irgendeine Organisation des Kontos ihn noch verlangt. Eine Passwort-Anmeldung, die einen Code braucht, hält den halbfertigen Zustand in einer versiegelten Fünf-Minuten-Notiz, nie in einer Sitzung. | | **Rollen** | Die Admin-API ist durch Middleware geschützt. Editoren dürfen Störungen und Wartungen schreiben und Monitore und Statusseiten lesen — nicht schreiben. | | **API-Keys** | Gespeichert wird nur `sha256(secret)`. Geprüft wird der Reihe nach: Key existiert → Secret passt → aktiv und nicht abgelaufen → Quelle innerhalb der CIDR-Bindung → hält den nötigen Scope. Ein unbekannter Präfix und ein falsches Secret liefern dieselbe Meldung. | | **Key-Verwaltung** | Nicht über `/api/v1` erreichbar — ein geleakter Key kann also keinen zweiten erzeugen und seine Scopes nicht erweitern. | diff --git a/content/de/5.cloud/1.overview.md b/content/de/5.cloud/1.overview.md index 8d12097..d7239d2 100644 --- a/content/de/5.cloud/1.overview.md +++ b/content/de/5.cloud/1.overview.md @@ -7,7 +7,7 @@ navigation: --- Nicht jeder will einen Container pflegen. **upcore Cloud** ist upcore als Dienst, -betrieben von onesrv: du registrierst dich auf [go.upcore.app](https://go.upcore.app), +betrieben von onesrv: du registrierst dich auf [go.upcore.app](https://go.upcore.app/register), legst einen Monitor an — und die Kapitel dieser Dokumentation über Datenbanken, Updates, TLS und Mail-Relays sind nicht mehr dein Problem. @@ -153,6 +153,13 @@ Outpost-Limit des Tarifs, weil du sie betreibst. Siehe [Outposts](/de/guide/outposts) dazu, was eine Probe ist und wie die Anmeldung läuft. +## Hilfe bekommen + +Der Support läuft über unseren Discord: [discord.gg/eWeaQZYcyd](https://discord.gg/eWeaQZYcyd). +Frag dort zu deiner Cloud-Organisation, einem Monitor, der sich seltsam verhält, +oder einem Tarifwechsel — du erreichst uns direkt statt über ein Ticketsystem. +Fragen zum Self-Hosting sind dort genauso willkommen. + ## Wie es weitergeht ::u-page-links diff --git a/content/de/5.cloud/2.self-hosted-or-cloud.md b/content/de/5.cloud/2.self-hosted-or-cloud.md index 7e423ce..c3668eb 100644 --- a/content/de/5.cloud/2.self-hosted-or-cloud.md +++ b/content/de/5.cloud/2.self-hosted-or-cloud.md @@ -52,7 +52,7 @@ dahin. - Niemand im Team eine Datenbank besitzen will, samt Backups und dem Restore, den nie jemand ausprobiert hat. -[Auf go.upcore.app registrieren](https://go.upcore.app) — der kostenlose Tarif braucht +[Auf go.upcore.app registrieren](https://go.upcore.app/register) — der kostenlose Tarif braucht keine Karte. ## Später umentscheiden diff --git a/content/de/index.md b/content/de/index.md index 2cc5c96..2ca1e06 100644 --- a/content/de/index.md +++ b/content/de/index.md @@ -151,6 +151,34 @@ description: Ein Container gegen eine Datei. Nichts bereitzustellen, nichts zu `/api/v1`, beschrieben durch ein OpenAPI-Dokument, das aus denselben Schemas erzeugt wird, gegen die die Endpunkte validieren. :::: + + ::::u-page-card + --- + icon: i-lucide-network + spotlight: true + to: /de/guide/topology + --- + #title + Ein Bild davon, wer was prüft + + #description + Ein Graph aus allen Standorten und allen Monitoren — samt denen, die still auf + den zentralen Check zurückgefallen sind. + :::: + + ::::u-page-card + --- + icon: i-lucide-shield-check + spotlight: true + to: /de/guide/account-security + --- + #title + Passkeys und zweiter Faktor + + #description + WebAuthn, TOTP mit Wiederherstellungscodes, eine Pflicht, die eine + Organisation allen Mitgliedern auferlegen kann, und jedes angemeldete Gerät. + :::: ::: :: @@ -243,7 +271,7 @@ description: upcore Cloud ist dieselbe Anwendung, betrieben von onesrv. Kostenlo upcore Cloud #description - Auf [go.upcore.app](https://go.upcore.app) registrieren und einen Monitor anlegen. + Auf [go.upcore.app](https://go.upcore.app/register) registrieren und einen Monitor anlegen. Kein Container, keine Datenbank, kein Zertifikat und kein Update, um das du dich kümmerst — geteilte Probe-Standorte kommen mit dem Tarif. Überall sonst in dieser Doku bleibt Self-Hosting der Standard. diff --git a/content/en/1.getting-started/1.introduction.md b/content/en/1.getting-started/1.introduction.md index 30977a5..b648382 100644 --- a/content/en/1.getting-started/1.introduction.md +++ b/content/en/1.getting-started/1.introduction.md @@ -136,7 +136,7 @@ for the first one unless it says otherwise. #description The same upcore, run for you by onesrv at - [go.upcore.app](https://go.upcore.app). Free to start and upgradeable when you + [go.upcore.app](https://go.upcore.app/register). Free to start and upgradeable when you outgrow it; no database, no update and no certificate is yours to look after. ::: :: diff --git a/content/en/2.guide/5.outposts.md b/content/en/2.guide/5.outposts.md index e6a0746..17cd717 100644 --- a/content/en/2.guide/5.outposts.md +++ b/content/en/2.guide/5.outposts.md @@ -155,3 +155,26 @@ those are the ones you provision and pay for. If a plan change takes a shared location away, the assignments that depended on it are removed right there rather than on the next check round. A monitor loses a location, never its check. + +### See the locations a plan would add + +A probe your plan does not include is not simply missing from the monitor form — +it is listed there, greyed out, with a lock and the tiers it comes with. A +location that is absent reads as a location that does not exist, which is the +wrong thing to learn about a fleet somebody else is running. + +It is a label and not a checkbox: it cannot be picked, and a disabled checkbox +that never ticks reads as a bug rather than as a price. Nothing about the list +grants anything either — a monitor write still asserts against the locations the +organization may actually use, so the worst a client can do with it is draw it. + +The list is empty on every self-hosted instance, and inside the operator +organization. + +::note +Tags, not a list of ids, are what share a probe: the operator brings up a box, +tags it `eu`, and every tier carrying that tag gains it without a single tier +being edited. Retiring the box removes it from every plan at once. Only the +operator's own outposts can be tagged — a tag on a tenant's own probe would mean +nothing, so it is refused rather than stored and ignored. +:: diff --git a/content/en/2.guide/6.topology.md b/content/en/2.guide/6.topology.md new file mode 100644 index 0000000..006de45 --- /dev/null +++ b/content/en/2.guide/6.topology.md @@ -0,0 +1,150 @@ +--- +title: Topology +description: Who checks what, as a picture — and the three cases where that is not + what the monitor's settings said. +navigation: + icon: i-lucide-network +--- + +Every number on this page exists somewhere else. The monitor form knows its check +strategy, the outpost list knows how many monitors a probe carries, and neither +of them says what the runner actually does with the two together. + +**Topology** does. It is one screen answering *which location reaches which +monitor, right now* — and the useful part is where the answer differs from what +you configured. + +![The topology graph: the central check on the left, one node per monitor on the right](/screenshots/en/topology.jpg) + +## Read the canvas + +The left column is every place a check can come from: **Central** — the upcore +process itself — followed by every outpost this organization may use, including +the ones currently carrying nothing. An idle probe is not a problem, but it is +part of the fleet, so it stays on the canvas. + +The right column is one node per monitor. An edge between them means that +location is checking that monitor. + +Two monitor types are deliberately absent. A `group` monitor aggregates other +monitors instead of reaching anything, and a `push` monitor is called rather than +calling — neither is dispatched from a location at all. Their members and their +push endpoints stay on the canvas as monitors of their own. + +### Follow the colours + +An edge is painted with what that location last reported: + +| | | +| --- | --- | +| green | up | +| amber | degraded | +| red | down | +| grey | idle — this location has not reported inside the window | + +Hovering an edge opens the numbers behind it: the last status, latency and +message with the time it arrived, plus uptime, average latency and the number of +checks over the last **24 hours**, for that one location and that one monitor. + +::note +A monitor checked from exactly one place writes no per-location rows at all — the +runner only splits a round out when more than one location reported. Its +aggregated heartbeat *is* that location's verdict, so the graph reads it off the +monitor rather than showing an empty edge for the only thing keeping the monitor +up. +:: + +## Catch the three surprises + +An edge can carry a note, and each one names a case where the picture and the +configuration have come apart: + +::u-page-grid + :::u-page-card + --- + icon: i-lucide-life-buoy + variant: subtle + --- + #title + fallback + + #description + The monitor asked for remote locations and not one of them can serve it, so the + central check took over. Nothing else in the admin UI says this — the monitor + still reads as "checked from outposts", and it is not. + ::: + + :::u-page-card + --- + icon: i-lucide-power-off + variant: subtle + --- + #title + inactive + + #description + An assigned probe that is switched off. It is on the canvas because you asked + for it; it is not dispatched to. + ::: + + :::u-page-card + --- + icon: i-lucide-shield-alert + variant: subtle + --- + #title + unverified + + #description + An assigned probe upcore has never managed to reach back. Also skipped — see + [Outposts](/guide/outposts) for what verification is and why an unverified + probe stays inert. + ::: +:: + +## Use the tiles and the filter + +Four counters sit above the canvas: + +| Tile | Counts | +| --- | --- | +| **Checked centrally only** | Monitors no location serves. A `both` monitor is not one of these. | +| **Checked from outposts** | Monitors at least one probe is actually dispatched for. | +| **Locations ready** | Outposts that are active *and* verified, out of all of them. | +| **Needs attention** | Failing locations plus monitors that fell back. | + +**Only what needs attention** reduces the canvas to the monitors with something +to say — a location currently failing, an assignment the runner cannot serve, or +a monitor that is switched off — and drops the probes carrying none of them. Two +hundred monitors are not read by looking at all of them. + +The page refreshes every 30 seconds and can be paused. It is slower than the +outpost list on purpose: an edge changing colour under the cursor is an +interruption, and there is more of it here. + +## Know who may open it + +Admin only. Editors do not see it in the sidebar and `/api/outposts/topology` +refuses them, because the answer contains outpost rows — and the addresses a +probe is reachable at are not an editor's business. + +::note +The whole picture is one request: `GET /api/outposts/topology` on the +session-authenticated admin API, not on [`/api/v1`](/api/authentication). +:: + +## Where to go next + +::u-page-links +--- +links: + - label: Outposts + icon: i-lucide-globe + to: /guide/outposts + description: What a probe is, how it enrols, and how the locations vote. + - label: Monitors + icon: i-lucide-activity + to: /guide/monitors + description: Where a monitor's check strategy and outpost policy are set. +--- +:: diff --git a/content/en/2.guide/6.embeds-and-badges.md b/content/en/2.guide/7.embeds-and-badges.md similarity index 100% rename from content/en/2.guide/6.embeds-and-badges.md rename to content/en/2.guide/7.embeds-and-badges.md diff --git a/content/en/2.guide/7.users-and-access.md b/content/en/2.guide/8.users-and-access.md similarity index 72% rename from content/en/2.guide/7.users-and-access.md rename to content/en/2.guide/8.users-and-access.md index a537781..98e3f82 100644 --- a/content/en/2.guide/7.users-and-access.md +++ b/content/en/2.guide/8.users-and-access.md @@ -77,3 +77,25 @@ its own. The session carries no role. The role is resolved per request against the organization that was addressed, so a session replayed against another one finds no membership and is refused whatever it is elsewhere. + +### Switch organization + +An account can belong to more than one. On upcore Cloud the sidebar footer names +the organization you are in and opens the others in a popover — it is not +rendered self-hosted, and not for an account with a single membership, because a +menu with one entry leads back to where you already are. + +The switcher **cannot enter an organization itself**. Sessions are host-scoped and +minting a handoff from inside a tenant is refused on purpose: that refusal is what +keeps a foothold in one organization from becoming a session in every other one +the account can reach. An entry therefore links to the picker on the main domain +naming the target, and the picker enters it on arrival — where membership is +re-read server-side, so the named organization is a preselection and never an +authorization. + +## Secure the account itself + +Roles and keys decide what a session may do. What it takes to *get* a session — +passkeys, an authenticator app, recovery codes, the organization-wide requirement +and the list of signed-in devices — is [Account +security](/guide/account-security). diff --git a/content/en/2.guide/9.account-security.md b/content/en/2.guide/9.account-security.md new file mode 100644 index 0000000..6e582e1 --- /dev/null +++ b/content/en/2.guide/9.account-security.md @@ -0,0 +1,155 @@ +--- +title: Account security +description: Passkeys, an authenticator app, recovery codes, the organization-wide + requirement — and the list of devices your account is signed in on. +navigation: + icon: i-lucide-shield-check +--- + +A role decides what you may change. This page is about the other half: proving +that it is you, and seeing every device that has already proved it. + +Everything here lives under **My account**, and everything here belongs to the +*account* rather than to the organization. The same account can hold a session on +three subdomains; there is one set of factors behind all of them. + +![My account: the passkey card and the authenticator app card](/screenshots/en/account-security.jpg) + +## Add a passkey + +A passkey signs you in with a fingerprint, a face or the device PIN. The private +key never leaves the device and the signature is only valid for this address, so +a passkey **counts as two factors on its own** and cannot be phished. + +Add one under **My account → Passkeys**, name it after the device, and the login +page offers **Sign in with a passkey** from then on. Up to **10** per account — +enough for a laptop, a phone, and the hardware key in a drawer. + +## Set up an authenticator app + +A time-based code (TOTP) from 1Password, Aegis, Bitwarden, Google Authenticator +or anything else that speaks the standard. + +::steps{level="3"} + +### Scan the QR code + +Or type the secret in by hand — both are shown. + +### Confirm one code + +Enrolment is not finished until the app has produced a code that checks out. A +secret that was written but never confirmed is offered again next time rather +than started from nothing. + +### Write down the recovery codes + +Ten of them, single-use, shown **exactly once**. Only their hashes are stored, so +that one screen is the only place they exist in readable form. The card tells you +how many are left, and new ones can be generated — which invalidates the old set. + +:: + +Confirming the code also marks the session you are in as two-factor. You proved +possession of the device; being sent back to the login to prove it a second time +would be theatre. + +## Require it for everybody + +An admin turns this on under **Settings → Two-factor authentication → +Require a second factor from every member**. Every session in that organization +then has to be signed in with a passkey, a code from an authenticator app, or +through SSO. A password alone stops being enough. + +![The organization-wide two-factor setting](/screenshots/en/two-factor-required.jpg) + +::warning +Set up a factor of your own and sign in with it **first**. The setting applies to +you like it applies to everybody else. +:: + +A session that is short of what the organization asks for is not shown a +half-working admin UI — the route guard sends it to `/two-factor`, which is the +one page that can fix it. That page picks by what the account owns: + +- **It has a factor** → it proves one, and lands on `/admin`. +- **It has none** → it enrols one, and enrolling proves it in the same gesture. + +Neither path ends in "now sign in again". + +::note +On upcore Cloud the operator organization has the requirement forced on and the +toggle locked. An account signed in through SSO is already two factors as far as +this rule is concerned — its factors are the identity provider's business, and +there is nothing to enrol here. +:: + +## Keep the last factor + +Removing a passkey or turning off the authenticator app asks for your password. +Adding one never does: enrolment proves possession by itself, while removal +*weakens* the account, and the password is what makes that a decision rather than +something a borrowed session can do. An account from an identity provider has no +password and is not asked for one. + +On top of that, upcore refuses to remove your last factor while **any** +organization you belong to requires one, and the refusal names them. The check +spans every membership rather than only the tenant you happen to be on — a member +of a strict organization must not be able to disable their factor from a lax one +and lose access to the strict one. + +## Sign a device out + +**My account → Signed-in devices** lists every open sign-in of this account, on +any subdomain. + +![The signed-in devices list, with this browser marked](/screenshots/en/sessions.jpg) + +Each row carries the browser and operating system as far as they can be +recognised, the address it signed in from, when it started and when it was last +active. The browser you are reading this in is marked **This device** and is +offered as *sign out* rather than as *revoke* — that is the same thing said in +the word people expect. + +| | | +| --- | --- | +| **One row** | Signs that device out. | +| **Everywhere else** | Every session except this browser. The move for a lost phone or a laptop somebody no longer has. | + +Neither asks for a password. Taking access away is the opposite of weakening the +account, and a confirmation dialog is the right amount of friction. It takes +effect on the other device's **next request**, not at its next login. + +## Know the shape of a login + +A password login that needs a code does not get a session first. The password +step leaves a sealed note that is valid for five minutes and names nothing but +the account; the code step spends it and mints the session. A session is what +someone gets for having authenticated — not a marker for not having finished. + +Both steps are rate-limited: ten attempts per account per five minutes, and fifty +per source address, so six digits stay six digits rather than something guessable +at scale. + +::note +The endpoints behind this page are `/api/me/passkeys`, `/api/me/two-factor`, +`/api/me/step-up` and `/api/me/sessions` on the session-authenticated admin API. +None of them exist under [`/api/v1`](/api/authentication): an API key cannot +enrol a factor, remove one, or sign a device out. +:: + +## Where to go next + +::u-page-links +--- +links: + - label: Users & access + icon: i-lucide-users + to: /guide/users-and-access + description: Roles, API keys and single sign-on. + - label: Security + icon: i-lucide-shield-check + to: /operations/security + description: What upcore protects by itself, and what it leaves to you. +--- +:: diff --git a/content/en/3.operations/5.security.md b/content/en/3.operations/5.security.md index 5cdbe98..08267b4 100644 --- a/content/en/3.operations/5.security.md +++ b/content/en/3.operations/5.security.md @@ -58,6 +58,13 @@ the uploaded icons and the API key hashes. Admins can set custom CSS on public status pages and configure outbound webhook targets. Both are legitimate features and both are powerful. +### Require a second factor + +**Settings → Two-factor authentication.** upcore does not rate-limit logins, so a +password on its own is only as good as the password. Enrol your own passkey or +authenticator app and sign in with it *before* turning the requirement on — it +applies to you as well. See [Account security](/guide/account-security). + :: ::warning @@ -70,7 +77,8 @@ hostnames out of monitor names that appear on a public page. | | | | --- | --- | -| **Sessions** | Cookies sealed with `NUXT_SESSION_PASSWORD`. Passwords stored hashed, never in plain text. | +| **Sessions** | Cookies sealed with `NUXT_SESSION_PASSWORD`. Passwords stored hashed, never in plain text. Every session is a row of its own, so signing a device out takes effect on its next request rather than at its next login — see [Account security](/guide/account-security). | +| **Second factor** | Passkeys (WebAuthn) and TOTP with single-use recovery codes, of which only hashes are stored. An organization can require one from every member, and the last factor cannot be dropped while any organization the account belongs to still requires it. A password login that needs a code holds the half-finished state in a sealed five-minute note, never in a session. | | **Roles** | The admin API is guarded by middleware. Editors may write incidents and maintenance, and read — but not write — monitors and status pages. | | **API keys** | Only `sha256(secret)` is stored. Checked in order: key exists → secret matches → active and unexpired → source inside the CIDR binding → holds the required scope. An unknown prefix and a wrong secret return the same message. | | **Key management** | Not exposed through `/api/v1`, so a leaked key cannot mint another one or widen its own scopes. | diff --git a/content/en/5.cloud/1.overview.md b/content/en/5.cloud/1.overview.md index 178c74d..f819f51 100644 --- a/content/en/5.cloud/1.overview.md +++ b/content/en/5.cloud/1.overview.md @@ -7,7 +7,7 @@ navigation: --- Not everybody wants a container to look after. **upcore Cloud** is upcore run as -a service by onesrv: you sign up at [go.upcore.app](https://go.upcore.app), add a +a service by onesrv: you sign up at [go.upcore.app](https://go.upcore.app/register), add a monitor, and the chapters of this documentation about databases, updates, TLS and mail relays stop being your problem. @@ -148,6 +148,13 @@ limit, because you are the one running them. See [Outposts](/guide/outposts) for what a probe is and how enrolment works. +## Getting help + +Support runs through our Discord: [discord.gg/eWeaQZYcyd](https://discord.gg/eWeaQZYcyd). +Ask there about your cloud organization, a monitor behaving oddly or a plan +change — you reach us directly instead of filing a ticket. Self-hosted questions +are welcome in the same place. + ## Where to go next ::u-page-links diff --git a/content/en/5.cloud/2.self-hosted-or-cloud.md b/content/en/5.cloud/2.self-hosted-or-cloud.md index 517e4ba..df5b5f1 100644 --- a/content/en/5.cloud/2.self-hosted-or-cloud.md +++ b/content/en/5.cloud/2.self-hosted-or-cloud.md @@ -48,7 +48,7 @@ What differs is everything around the application. - Nobody on the team wants to own a database, its backups, and the restore nobody has tried. -[Sign up at go.upcore.app](https://go.upcore.app) — the free plan needs no card. +[Sign up at go.upcore.app](https://go.upcore.app/register) — the free plan needs no card. ## Change your mind later diff --git a/content/en/index.md b/content/en/index.md index f2ace71..c957b5f 100644 --- a/content/en/index.md +++ b/content/en/index.md @@ -151,6 +151,34 @@ description: A single container against a file. Nothing to provision, nothing to `/api/v1`, described by an OpenAPI document generated from the same schemas the endpoints validate against. :::: + + ::::u-page-card + --- + icon: i-lucide-network + spotlight: true + to: /guide/topology + --- + #title + A picture of who checks what + + #description + One graph of every location against every monitor — including the ones that + quietly fell back to the central check. + :::: + + ::::u-page-card + --- + icon: i-lucide-shield-check + spotlight: true + to: /guide/account-security + --- + #title + Passkeys and a second factor + + #description + WebAuthn, TOTP with recovery codes, a requirement an organization can put on + every member, and every device the account is signed in on. + :::: ::: :: @@ -241,7 +269,7 @@ description: upcore Cloud is the same application, operated by onesrv. Free to upcore Cloud #description - Sign up at [go.upcore.app](https://go.upcore.app) and add a monitor. No container, no + Sign up at [go.upcore.app](https://go.upcore.app/register) and add a monitor. No container, no database, no certificate and no update is yours to look after — and shared probe locations come with the plan. Self-hosting stays the default everywhere else in these docs. diff --git a/nuxt.config.ts b/nuxt.config.ts index 08a8689..f0827f4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -18,6 +18,24 @@ export default defineNuxtConfig({ url: 'https://docs.upcore.app', }, + nitro: { + hooks: { + // Nitro remembers a prerendered route under the Content-Type of the + // response it saw while crawling. IPX answers an SVG with a string body, + // and on that path the header comes back as `text/plain` instead of + // `image/svg+xml` — which a browser refuses to paint inside an , so + // the header wordmark rendered as a broken image. The written file is + // fine; only the remembered type is wrong. Handing the type back that the + // extension implies makes Nitro drop its override and serve the file the + // same way it serves every other SVG in public/. + 'prerender:generate'(route) { + if (route.fileName?.endsWith('.svg')) { + route.contentType = 'image/svg+xml' + } + }, + }, + }, + // English is the documentation language of the project (README, SECURITY, // the OpenAPI descriptions); German mirrors it because the admin UI itself // ships German-first. `en` stays unprefixed so existing links keep working. diff --git a/public/screenshots/de/account-security.jpg b/public/screenshots/de/account-security.jpg new file mode 100644 index 0000000..05b3ccc Binary files /dev/null and b/public/screenshots/de/account-security.jpg differ diff --git a/public/screenshots/de/sessions.jpg b/public/screenshots/de/sessions.jpg new file mode 100644 index 0000000..fd275ad Binary files /dev/null and b/public/screenshots/de/sessions.jpg differ diff --git a/public/screenshots/de/topology.jpg b/public/screenshots/de/topology.jpg new file mode 100644 index 0000000..c66ee24 Binary files /dev/null and b/public/screenshots/de/topology.jpg differ diff --git a/public/screenshots/de/two-factor-required.jpg b/public/screenshots/de/two-factor-required.jpg new file mode 100644 index 0000000..84de230 Binary files /dev/null and b/public/screenshots/de/two-factor-required.jpg differ diff --git a/public/screenshots/en/account-security.jpg b/public/screenshots/en/account-security.jpg new file mode 100644 index 0000000..920e01b Binary files /dev/null and b/public/screenshots/en/account-security.jpg differ diff --git a/public/screenshots/en/sessions.jpg b/public/screenshots/en/sessions.jpg new file mode 100644 index 0000000..9356af6 Binary files /dev/null and b/public/screenshots/en/sessions.jpg differ diff --git a/public/screenshots/en/topology.jpg b/public/screenshots/en/topology.jpg new file mode 100644 index 0000000..5aa3228 Binary files /dev/null and b/public/screenshots/en/topology.jpg differ diff --git a/public/screenshots/en/two-factor-required.jpg b/public/screenshots/en/two-factor-required.jpg new file mode 100644 index 0000000..f370ada Binary files /dev/null and b/public/screenshots/en/two-factor-required.jpg differ