Add proper login to Admin UI, replace Caddy basic_auth
All checks were successful
Deploy to Server / deploy (push) Successful in 1m55s

- iron-session for encrypted httpOnly session cookies
- POST /api/auth/login: verifies ADMIN_USERNAME/ADMIN_PASSWORD, sets session, returns API key
- POST /api/auth/logout: destroys session
- middleware.ts: protects all routes, redirects to /login if unauthenticated
- Login page: username + password form (no more browser popup)
- Sidebar: logout calls API route and clears session
- docker-compose.prod.yml: admin-ui reads /opt/rustycms/.env.admin
- deploy.yml: generates .env.admin from Gitea secrets
- Caddy: basic_auth removed from /admin* block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-03-15 21:49:32 +01:00
parent c644c08222
commit b432621919
13 changed files with 303 additions and 118 deletions

View File

@@ -30,7 +30,7 @@ jobs:
chmod 600 /tmp/deploy_key chmod 600 /tmp/deploy_key
SSH="ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key root@167.86.74.105" SSH="ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key root@167.86.74.105"
# .env aus Secrets generieren # API .env aus Secrets generieren
$SSH "cat > /opt/rustycms/.env" << 'ENVEOF' $SSH "cat > /opt/rustycms/.env" << 'ENVEOF'
RUSTYCMS_API_KEY=${{ secrets.RUSTYCMS_API_KEY }} RUSTYCMS_API_KEY=${{ secrets.RUSTYCMS_API_KEY }}
RUSTYCMS_BASE_URL=${{ secrets.RUSTYCMS_BASE_URL }} RUSTYCMS_BASE_URL=${{ secrets.RUSTYCMS_BASE_URL }}
@@ -40,6 +40,14 @@ jobs:
RUSTYCMS_STORE=${{ secrets.RUSTYCMS_STORE }} RUSTYCMS_STORE=${{ secrets.RUSTYCMS_STORE }}
ENVEOF ENVEOF
# Admin UI .env aus Secrets generieren
$SSH "cat > /opt/rustycms/.env.admin" << 'ENVEOF'
RUSTYCMS_API_KEY=${{ secrets.RUSTYCMS_API_KEY }}
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
ENVEOF
rsync -avz --delete \ rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key" \ -e "ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key" \
./types/ root@167.86.74.105:/opt/rustycms/types/ ./types/ root@167.86.74.105:/opt/rustycms/types/

View File

@@ -1,10 +1,14 @@
{ {
"LoginPage": { "LoginPage": {
"title": "Anmelden", "title": "Anmelden bei RustyCMS",
"apiKeyLabel": "API-Schlüssel", "usernameLabel": "Benutzername",
"apiKeyPlaceholder": "API-Schlüssel eingeben", "usernamePlaceholder": "Benutzername",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort",
"submit": "Anmelden", "submit": "Anmelden",
"hint": "Gleicher Schlüssel wie RUSTYCMS_API_KEY auf dem Server. Ohne Schlüssel nur Lesen, mit Schlüssel Bearbeiten." "loggingIn": "Anmelden…",
"invalidCredentials": "Ungültige Zugangsdaten",
"networkError": "Netzwerkfehler, bitte erneut versuchen"
}, },
"Sidebar": { "Sidebar": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -52,39 +56,39 @@
"noAssets": "Noch keine Assets. Lade Bilder im Bereich Assets hoch.", "noAssets": "Noch keine Assets. Lade Bilder im Bereich Assets hoch.",
"status": "Status", "status": "Status",
"statusDraft": "Entwurf", "statusDraft": "Entwurf",
"statusPublished": "Ver\u00f6ffentlicht", "statusPublished": "Veröffentlicht",
"statusHint": "Entw\u00fcrfe sind \u00fcber die \u00f6ffentliche API nicht sichtbar." "statusHint": "Entwürfe sind über die öffentliche API nicht sichtbar."
}, },
"SearchableSelect": { "SearchableSelect": {
"placeholder": "\u2014 Bitte ausw\u00e4hlen \u2014", "placeholder": " Bitte auswählen —",
"clearLabel": "\u2014 Auswahl aufheben \u2014", "clearLabel": " Auswahl aufheben ",
"filterPlaceholder": "Filtern\u2026", "filterPlaceholder": "Filtern",
"emptyLabel": "Keine Treffer" "emptyLabel": "Keine Treffer"
}, },
"ReferenceField": { "ReferenceField": {
"typeLabel": "Typ: {collection}", "typeLabel": "Typ: {collection}",
"typesLabel": "Typen: {collections}", "typesLabel": "Typen: {collections}",
"selectType": "\u2014 Typ w\u00e4hlen \u2014", "selectType": "— Typ wählen —",
"newEntry": "Neuer Eintrag", "newEntry": "Neuer Eintrag",
"openEntry": "Eintrag \u00f6ffnen", "openEntry": "Eintrag öffnen",
"noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu." "noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu."
}, },
"ReferenceArrayField": { "ReferenceArrayField": {
"typeLabel": "Typ: {collection}", "typeLabel": "Typ: {collection}",
"typesLabel": "Typen: {collections}", "typesLabel": "Typen: {collections}",
"componentType": "Komponententyp", "componentType": "Komponententyp",
"selectType": "\u2014 Typ w\u00e4hlen \u2014", "selectType": "— Typ wählen —",
"selectFromExisting": "\u2014 Aus vorhandenen w\u00e4hlen \u2014", "selectFromExisting": " Aus vorhandenen wählen —",
"filterPlaceholder": "Filtern\u2026", "filterPlaceholder": "Filtern",
"emptyLabel": "Keine Treffer", "emptyLabel": "Keine Treffer",
"selectExistingAriaLabel": "Vorhandenen Eintrag zum Hinzuf\u00fcgen ausw\u00e4hlen", "selectExistingAriaLabel": "Vorhandenen Eintrag zum Hinzufügen auswählen",
"moveUp": "Nach oben", "moveUp": "Nach oben",
"moveDown": "Nach unten", "moveDown": "Nach unten",
"remove": "Entfernen", "remove": "Entfernen",
"newComponent": "Neue {collection}-Komponente", "newComponent": "Neue {collection}-Komponente",
"createNewComponent": "+ Neue Komponente erstellen\u2026", "createNewComponent": "+ Neue Komponente erstellen",
"openInNewTab": "In neuem Tab \u00f6ffnen; dann Seite neu laden.", "openInNewTab": "In neuem Tab öffnen; dann Seite neu laden.",
"openEntry": "Eintrag \u00f6ffnen", "openEntry": "Eintrag öffnen",
"noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu." "noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu."
}, },
"MarkdownEditor": { "MarkdownEditor": {
@@ -92,21 +96,21 @@
"italic": "Kursiv", "italic": "Kursiv",
"code": "Code", "code": "Code",
"link": "Link", "link": "Link",
"bulletList": "Aufz\u00e4hlungsliste", "bulletList": "Aufzählungsliste",
"bulletListButton": "\u2022 Liste", "bulletListButton": " Liste",
"placeholder": "Markdown eingeben\u2026 **fett**, *kursiv*, [Link](url), - Liste", "placeholder": "Markdown eingeben **fett**, *kursiv*, [Link](url), - Liste",
"preview": "Vorschau", "preview": "Vorschau",
"emptyPreview": "Leer \u2014 Vorschau erscheint beim Tippen." "emptyPreview": "Leer Vorschau erscheint beim Tippen."
}, },
"PaginationLinks": { "PaginationLinks": {
"back": "Zur\u00fcck", "back": "Zurück",
"next": "Weiter", "next": "Weiter",
"pageInfo": "Seite {page} von {totalPages} ({total} Eintr\u00e4ge)" "pageInfo": "Seite {page} von {totalPages} ({total} Einträge)"
}, },
"DataPreviewPanel": { "DataPreviewPanel": {
"hide": "Daten-Vorschau ausblenden", "hide": "Daten-Vorschau ausblenden",
"show": "Daten-Vorschau", "show": "Daten-Vorschau",
"loading": "Laden\u2026", "loading": "Laden",
"errorLoading": "Fehler beim Laden" "errorLoading": "Fehler beim Laden"
}, },
"SchemaPanel": { "SchemaPanel": {
@@ -125,14 +129,14 @@
"sectionSchema": "Schema", "sectionSchema": "Schema",
"sectionDataPreview": "Aktuelle Daten", "sectionDataPreview": "Aktuelle Daten",
"copyCode": "Code kopieren", "copyCode": "Code kopieren",
"loading": "Laden\u2026", "loading": "Laden",
"errorLoading": "Fehler beim Laden" "errorLoading": "Fehler beim Laden"
}, },
"ReferenceOrInlineField": { "ReferenceOrInlineField": {
"reference": "Referenz", "reference": "Referenz",
"inline": "Eingebettet", "inline": "Eingebettet",
"inlineObject": "Eingebettetes Objekt (keine Referenz)", "inlineObject": "Eingebettetes Objekt (keine Referenz)",
"noInlineSchema": "Kein Inline-Schema. Seite neu laden oder API pr\u00fcfen (useFields / collection)." "noInlineSchema": "Kein Inline-Schema. Seite neu laden oder API prüfen (useFields / collection)."
}, },
"LocaleSwitcher": { "LocaleSwitcher": {
"label": "Sprache" "label": "Sprache"
@@ -170,38 +174,38 @@
}, },
"Dashboard": { "Dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "W\u00e4hle eine Sammlung zur Inhaltsverwaltung.", "subtitle": "Wähle eine Sammlung zur Inhaltsverwaltung.",
"newContentType": "Neuer Inhaltstyp", "newContentType": "Neuer Inhaltstyp",
"searchPlaceholder": "Inhaltstypen suchen…", "searchPlaceholder": "Inhaltstypen suchen…",
"filterByTag": "Tag:", "filterByTag": "Tag:",
"tagAll": "Alle", "tagAll": "Alle",
"noResults": "Kein Inhaltstyp entspricht Suche oder Filter.", "noResults": "Kein Inhaltstyp entspricht Suche oder Filter.",
"noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist.", "noCollections": "Keine Sammlungen geladen. Prüfe ob die RustyCMS-API unter {url} erreichbar ist.",
"recentSectionTitle": "Letzte 3 bearbeitete Beitr\u00e4ge", "recentSectionTitle": "Letzte 3 bearbeitete Beiträge",
"recentSectionLink": "Alle Beitr\u00e4ge", "recentSectionLink": "Alle Beiträge",
"recentSectionEmpty": "Noch keine Beitr\u00e4ge.", "recentSectionEmpty": "Noch keine Beiträge.",
"loading": "Laden…" "loading": "Laden…"
}, },
"TypesPage": { "TypesPage": {
"title": "Typen", "title": "Typen",
"newType": "Neuer Typ", "newType": "Neuer Typ",
"description": "Inhaltstypen (Sammlungen). Schema bearbeiten oder Typ l\u00f6schen. Beim L\u00f6schen wird nur die Typdefinitionsdatei entfernt; vorhandene Inhaltseintr\u00e4ge bleiben erhalten.", "description": "Inhaltstypen (Sammlungen). Schema bearbeiten oder Typ löschen. Beim Löschen wird nur die Typdefinitionsdatei entfernt; vorhandene Inhaltseinträge bleiben erhalten.",
"searchPlaceholder": "Inhaltstypen suchen…", "searchPlaceholder": "Inhaltstypen suchen…",
"filterByTag": "Tag:", "filterByTag": "Tag:",
"tagAll": "Alle", "tagAll": "Alle",
"noResults": "Keine Typen entsprechen Ihrer Suche oder dem Filter.", "noResults": "Keine Typen entsprechen Ihrer Suche oder dem Filter.",
"loading": "Laden\u2026", "loading": "Laden",
"errorLoading": "Fehler beim Laden der Typen: {error}", "errorLoading": "Fehler beim Laden der Typen: {error}",
"noTypes": "Noch keine Typen vorhanden. Erstelle einen mit \"Neuer Typ\".", "noTypes": "Noch keine Typen vorhanden. Erstelle einen mit \"Neuer Typ\".",
"colName": "Name", "colName": "Name",
"colDescription": "Beschreibung", "colDescription": "Beschreibung",
"colCategory": "Kategorie", "colCategory": "Kategorie",
"colActions": "Aktionen", "colActions": "Aktionen",
"confirmDelete": "\"{name}\" l\u00f6schen?", "confirmDelete": "\"{name}\" löschen?",
"confirmDeleteFinal": "\"{name}\" wirklich l\u00f6schen? Dies kann nicht r\u00fcckg\u00e4ngig gemacht werden.", "confirmDeleteFinal": "\"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"delete": "L\u00f6schen", "delete": "Löschen",
"yesDelete": "Ja, l\u00f6schen", "yesDelete": "Ja, löschen",
"deleting": "\u2026", "deleting": "",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"edit": "Bearbeiten" "edit": "Bearbeiten"
}, },
@@ -211,41 +215,41 @@
"nameRequired": "Name ist erforderlich.", "nameRequired": "Name ist erforderlich.",
"nameInvalid": "Name: nur Kleinbuchstaben, Ziffern und Unterstriche.", "nameInvalid": "Name: nur Kleinbuchstaben, Ziffern und Unterstriche.",
"fieldRequired": "Mindestens ein Feld erforderlich.", "fieldRequired": "Mindestens ein Feld erforderlich.",
"fieldNamesUnique": "Feldnamen m\u00fcssen eindeutig sein.", "fieldNamesUnique": "Feldnamen müssen eindeutig sein.",
"errorCreating": "Fehler beim Erstellen des Typs.", "errorCreating": "Fehler beim Erstellen des Typs.",
"nameLabel": "Name", "nameLabel": "Name",
"namePlaceholder": "z.\u00a0B. produkt, blogbeitrag", "namePlaceholder": "z. B. produkt, blogbeitrag",
"nameHint": "Nur Kleinbuchstaben, Ziffern und Unterstriche.", "nameHint": "Nur Kleinbuchstaben, Ziffern und Unterstriche.",
"descriptionLabel": "Beschreibung", "descriptionLabel": "Beschreibung",
"categoryLabel": "Kategorie", "categoryLabel": "Kategorie",
"categoryPlaceholder": "z.\u00a0B. inhalt", "categoryPlaceholder": "z. B. inhalt",
"tagsLabel": "Tags (kommagetrennt)", "tagsLabel": "Tags (kommagetrennt)",
"tagsPlaceholder": "z.\u00a0B. inhalt, blog", "tagsPlaceholder": "z. B. inhalt, blog",
"strictLabel": "Strikt (unbekannte Felder ablehnen)", "strictLabel": "Strikt (unbekannte Felder ablehnen)",
"fieldsLabel": "Felder", "fieldsLabel": "Felder",
"addField": "Feld hinzuf\u00fcgen", "addField": "Feld hinzufügen",
"fieldNamePlaceholder": "Feldname", "fieldNamePlaceholder": "Feldname",
"fieldTypeLabel": "Feldtyp", "fieldTypeLabel": "Feldtyp",
"required": "Pflichtfeld", "required": "Pflichtfeld",
"removeField": "Feld entfernen", "removeField": "Feld entfernen",
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)", "collectionPlaceholder": "Sammlung (z. B. seite)",
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)", "allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)", "allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
"arrayItemType": "Array-Elementtyp", "arrayItemType": "Array-Elementtyp",
"itemTypePlaceholder": "z.\u00a0B. string, reference", "itemTypePlaceholder": "z. B. string, reference",
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—w\u00e4hle unten, was ein Element ist.", "arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—wähle unten, was ein Element ist.",
"arrayEachEntry": "Ein Listeneintrag ist", "arrayEachEntry": "Ein Listeneintrag ist",
"itemKindString": "Einfacher Text (string)", "itemKindString": "Einfacher Text (string)",
"itemKindNumber": "Zahl (number)", "itemKindNumber": "Zahl (number)",
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)", "itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)", "itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
"objectItemFieldsLabel": "Felder pro Listeneintrag", "objectItemFieldsLabel": "Felder pro Listeneintrag",
"addObjectField": "Unterfeld hinzuf\u00fcgen", "addObjectField": "Unterfeld hinzufügen",
"objectFieldNamePlaceholder": "Unterfeldname", "objectFieldNamePlaceholder": "Unterfeldname",
"arrayReferenceHelp": "Alle Slugs in der Liste m\u00fcssen in dieser Sammlung existieren (oder in der Whitelist stehen).", "arrayReferenceHelp": "Alle Slugs in der Liste müssen in dieser Sammlung existieren (oder in der Whitelist stehen).",
"multiSelectOptions": "Optionen", "multiSelectOptions": "Optionen",
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3", "multiSelectOptionsPlaceholder": "z. B. option1, option2, option3",
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.", "multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es können mehrere ausgewählt werden.",
"stringWidgetLabel": "Eingabeart", "stringWidgetLabel": "Eingabeart",
"stringWidgetSingleline": "Einzeilig", "stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)", "stringWidgetTextarea": "Mehrzeilig (Textbereich)",
@@ -257,34 +261,34 @@
"codeLanguageJson": "JSON", "codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML", "codeLanguageHtml": "HTML",
"defaultValueLabel": "Standardwert", "defaultValueLabel": "Standardwert",
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]", "defaultValuePlaceholder": "z. B. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.", "defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Einträgen verwendet.",
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"", "defaultValueInvalid": "Ungültiges JSON für Standardwert im Feld \"{field}\"",
"defaultValueBoolean": "Standard: angehakt", "defaultValueBoolean": "Standard: angehakt",
"defaultValueEmpty": "Leer lassen = keiner", "defaultValueEmpty": "Leer lassen = keiner",
"defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard ausw\u00e4hlen.", "defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard auswählen.",
"defaultValueArrayPlaceholder": "Kommagetrennte Werte", "defaultValueArrayPlaceholder": "Kommagetrennte Werte",
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)", "fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
"creating": "Erstellen\u2026", "creating": "Erstellen",
"createType": "Typ erstellen", "createType": "Typ erstellen",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"EditTypePage": { "EditTypePage": {
"fieldRequired": "Mindestens ein Feld erforderlich.", "fieldRequired": "Mindestens ein Feld erforderlich.",
"fieldNamesUnique": "Feldnamen m\u00fcssen eindeutig sein.", "fieldNamesUnique": "Feldnamen müssen eindeutig sein.",
"errorSaving": "Fehler beim Speichern des Typs.", "errorSaving": "Fehler beim Speichern des Typs.",
"missingName": "Typname fehlt.", "missingName": "Typname fehlt.",
"backToTypes": "Zur\u00fcck zu Typen", "backToTypes": "Zurück zu Typen",
"loading": "Laden\u2026", "loading": "Laden",
"errorLoading": "Fehler beim Laden des Typs: {error}", "errorLoading": "Fehler beim Laden des Typs: {error}",
"title": "Typ bearbeiten: {name}", "title": "Typ bearbeiten: {name}",
"description": "Beschreibung, Kategorie, Tags und Felder \u00e4ndern. Die Schemadatei wird auf dem Server aktualisiert.", "description": "Beschreibung, Kategorie, Tags und Felder ändern. Die Schemadatei wird auf dem Server aktualisiert.",
"nameLabel": "Name", "nameLabel": "Name",
"descriptionLabel": "Beschreibung", "descriptionLabel": "Beschreibung",
"categoryLabel": "Kategorie", "categoryLabel": "Kategorie",
"categoryPlaceholder": "z.\u00a0B. inhalt", "categoryPlaceholder": "z. B. inhalt",
"tagsLabel": "Tags (kommagetrennt)", "tagsLabel": "Tags (kommagetrennt)",
"tagsPlaceholder": "z.\u00a0B. inhalt, blog", "tagsPlaceholder": "z. B. inhalt, blog",
"strictLabel": "Strikt (unbekannte Felder ablehnen)", "strictLabel": "Strikt (unbekannte Felder ablehnen)",
"extendsLabel": "Erweitert", "extendsLabel": "Erweitert",
"extendsDescription": "Dieser Typ erbt Felder von diesen Typen. Gehe zum jeweiligen Typ, um geerbte Felder zu bearbeiten.", "extendsDescription": "Dieser Typ erbt Felder von diesen Typen. Gehe zum jeweiligen Typ, um geerbte Felder zu bearbeiten.",
@@ -293,7 +297,7 @@
"addExtend": "Hinzufügen", "addExtend": "Hinzufügen",
"removeExtend": "Aus Erweiterungen entfernen", "removeExtend": "Aus Erweiterungen entfernen",
"fieldsLabel": "Felder", "fieldsLabel": "Felder",
"addField": "Feld hinzuf\u00fcgen", "addField": "Feld hinzufügen",
"moveFieldUp": "Feld nach oben", "moveFieldUp": "Feld nach oben",
"moveFieldDown": "Feld nach unten", "moveFieldDown": "Feld nach unten",
"moveFieldToTop": "Ganz nach oben", "moveFieldToTop": "Ganz nach oben",
@@ -302,7 +306,7 @@
"fieldTypeLabel": "Feldtyp", "fieldTypeLabel": "Feldtyp",
"required": "Pflichtfeld", "required": "Pflichtfeld",
"removeField": "Feld entfernen", "removeField": "Feld entfernen",
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)", "collectionPlaceholder": "Sammlung (z. B. seite)",
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)", "allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)", "allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
"patternLabel": "Pattern (Regex)", "patternLabel": "Pattern (Regex)",
@@ -310,20 +314,20 @@
"minLengthLabel": "Min. Länge", "minLengthLabel": "Min. Länge",
"maxLengthLabel": "Max. Länge", "maxLengthLabel": "Max. Länge",
"arrayItemType": "Array-Elementtyp", "arrayItemType": "Array-Elementtyp",
"itemTypePlaceholder": "z.\u00a0B. string, reference", "itemTypePlaceholder": "z. B. string, reference",
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—w\u00e4hle unten, was ein Element ist.", "arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—wähle unten, was ein Element ist.",
"arrayEachEntry": "Ein Listeneintrag ist", "arrayEachEntry": "Ein Listeneintrag ist",
"itemKindString": "Einfacher Text (string)", "itemKindString": "Einfacher Text (string)",
"itemKindNumber": "Zahl (number)", "itemKindNumber": "Zahl (number)",
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)", "itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)", "itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
"objectItemFieldsLabel": "Felder pro Listeneintrag", "objectItemFieldsLabel": "Felder pro Listeneintrag",
"addObjectField": "Unterfeld hinzuf\u00fcgen", "addObjectField": "Unterfeld hinzufügen",
"objectFieldNamePlaceholder": "Unterfeldname", "objectFieldNamePlaceholder": "Unterfeldname",
"arrayReferenceHelp": "Alle Slugs in der Liste m\u00fcssen in dieser Sammlung existieren (oder in der Whitelist stehen).", "arrayReferenceHelp": "Alle Slugs in der Liste müssen in dieser Sammlung existieren (oder in der Whitelist stehen).",
"multiSelectOptions": "Optionen", "multiSelectOptions": "Optionen",
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3", "multiSelectOptionsPlaceholder": "z. B. option1, option2, option3",
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.", "multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es können mehrere ausgewählt werden.",
"stringWidgetLabel": "Eingabeart", "stringWidgetLabel": "Eingabeart",
"stringWidgetSingleline": "Einzeilig", "stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)", "stringWidgetTextarea": "Mehrzeilig (Textbereich)",
@@ -335,15 +339,15 @@
"codeLanguageJson": "JSON", "codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML", "codeLanguageHtml": "HTML",
"defaultValueLabel": "Standardwert", "defaultValueLabel": "Standardwert",
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]", "defaultValuePlaceholder": "z. B. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.", "defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Einträgen verwendet.",
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"", "defaultValueInvalid": "Ungültiges JSON für Standardwert im Feld \"{field}\"",
"defaultValueBoolean": "Standard: angehakt", "defaultValueBoolean": "Standard: angehakt",
"defaultValueEmpty": "Leer lassen = keiner", "defaultValueEmpty": "Leer lassen = keiner",
"defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard ausw\u00e4hlen.", "defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard auswählen.",
"defaultValueArrayPlaceholder": "Kommagetrennte Werte", "defaultValueArrayPlaceholder": "Kommagetrennte Werte",
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)", "fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
"saving": "Speichern\u2026", "saving": "Speichern",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
@@ -446,11 +450,11 @@
"transformPresetMedium": "Mittel 800px", "transformPresetMedium": "Mittel 800px",
"transformPresetJpeg": "JPEG 1200px", "transformPresetJpeg": "JPEG 1200px",
"transformWidth": "Breite", "transformWidth": "Breite",
"transformHeight": "H\u00f6he", "transformHeight": "Höhe",
"transformAspect": "Seitenverh\u00e4ltnis", "transformAspect": "Seitenverhältnis",
"transformFit": "Fit", "transformFit": "Fit",
"transformFormat": "Format", "transformFormat": "Format",
"transformQuality": "Qualit\u00e4t (1100)", "transformQuality": "Qualität (1100)",
"creating": "Wird erstellt…" "creating": "Wird erstellt…"
} }
} }

View File

@@ -1,10 +1,14 @@
{ {
"LoginPage": { "LoginPage": {
"title": "Login", "title": "Sign in to RustyCMS",
"apiKeyLabel": "API key", "usernameLabel": "Username",
"apiKeyPlaceholder": "Enter your API key", "usernamePlaceholder": "Username",
"passwordLabel": "Password",
"passwordPlaceholder": "Password",
"submit": "Login", "submit": "Login",
"hint": "Use the same key as RUSTYCMS_API_KEY on the server. Without a key you can only read; with a key you can edit." "loggingIn": "Signing in…",
"invalidCredentials": "Invalid credentials",
"networkError": "Network error, please try again"
}, },
"Sidebar": { "Sidebar": {
"dashboard": "Dashboard", "dashboard": "Dashboard",

View File

@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"iron-session": "^8.0.4",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.8.3", "next-intl": "^4.8.3",
@@ -5958,6 +5959,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7699,6 +7709,30 @@
"tslib": "^2.8.1" "tslib": "^2.8.1"
} }
}, },
"node_modules/iron-session": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
"integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
"funding": [
"https://github.com/sponsors/vvo",
"https://github.com/sponsors/brc-dd"
],
"license": "MIT",
"dependencies": {
"cookie": "^0.7.2",
"iron-webcrypto": "^1.2.1",
"uncrypto": "^0.1.3"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
"integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-alphabetical": { "node_modules/is-alphabetical": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -11281,6 +11315,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"iron-session": "^8.0.4",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.8.3", "next-intl": "^4.8.3",

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions, type SessionData } from "@/lib/session";
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
const expectedUsername = process.env.ADMIN_USERNAME ?? "admin";
const expectedPassword = process.env.ADMIN_PASSWORD;
if (!expectedPassword || username !== expectedUsername || password !== expectedPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const response = NextResponse.json({
ok: true,
apiKey: process.env.RUSTYCMS_API_KEY ?? null,
});
const session = await getIronSession<SessionData>(request, response, sessionOptions);
session.authenticated = true;
await session.save();
return response;
}

View File

@@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions, type SessionData } from "@/lib/session";
export async function POST(request: NextRequest) {
const response = NextResponse.json({ ok: true });
const session = await getIronSession<SessionData>(request, response, sessionOptions);
session.destroy();
return response;
}

View File

@@ -3,7 +3,6 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { setApiKey } from "@/lib/api"; import { setApiKey } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -11,52 +10,92 @@ import { Input } from "@/components/ui/input";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("LoginPage"); const t = useTranslations("LoginPage");
const router = useRouter(); const router = useRouter();
const [key, setKey] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const trimmed = key.trim(); setLoading(true);
if (!trimmed) { setError(null);
setError("API key is required.");
try {
const res = await fetch("/admin/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? t("invalidCredentials"));
return; return;
} }
setError(null);
setApiKey(trimmed); const data = await res.json();
if (data.apiKey) {
setApiKey(data.apiKey);
}
router.push("/"); router.push("/");
router.refresh(); router.refresh();
} catch {
setError(t("networkError"));
} finally {
setLoading(false);
}
} }
return ( return (
<div className="mx-auto max-w-sm py-8"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-violet-50/95 via-accent-50/90 to-amber-50/85">
<div className="rounded-xl border border-accent-200 bg-white p-6 shadow-sm"> <div className="w-full max-w-sm rounded-xl border border-accent-200 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-md bg-accent-200/80 text-accent-800">
<svg className="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</span>
<span className="text-lg font-bold tracking-tight text-gray-900">RustyCMS</span>
</div>
<h1 className="mb-4 text-xl font-semibold text-gray-900">{t("title")}</h1> <h1 className="mb-4 text-xl font-semibold text-gray-900">{t("title")}</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="api-key" className="mb-1 block text-sm font-medium text-gray-700"> <label htmlFor="username" className="mb-1 block text-sm font-medium text-gray-700">
{t("apiKeyLabel")} {t("usernameLabel")}
</label> </label>
<Input <Input
id="api-key" id="username"
type="password" type="text"
autoComplete="off" autoComplete="username"
value={key} value={username}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder={t("apiKeyPlaceholder")} placeholder={t("usernamePlaceholder")}
className="w-full" className="w-full"
required
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
{t("passwordLabel")}
</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
className="w-full"
required
/> />
</div> </div>
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full"> <Button type="submit" className="w-full" disabled={loading}>
{t("submit")} {loading ? t("loggingIn") : t("submit")}
</Button> </Button>
</form> </form>
<p className="mt-4 text-xs text-gray-500">{t("hint")}</p>
<p className="mt-3">
<Link href="/" className="text-sm text-accent-600 hover:underline">
Back to dashboard
</Link>
</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
@@ -11,6 +12,11 @@ type AppShellProps = {
export function AppShell({ locale, children }: AppShellProps) { export function AppShell({ locale, children }: AppShellProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
if (pathname === "/login") {
return <ErrorBoundary>{children}</ErrorBoundary>;
}
return ( return (
<div className="flex h-screen overflow-hidden bg-white"> <div className="flex h-screen overflow-hidden bg-white">

View File

@@ -2,11 +2,11 @@
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { fetchCollections, fetchContentList, getApiKey, setApiKey } from "@/lib/api"; import { fetchCollections, fetchContentList, getApiKey, clearSession } from "@/lib/api";
import { LocaleSwitcher } from "./LocaleSwitcher"; import { LocaleSwitcher } from "./LocaleSwitcher";
const navLinkClass = const navLinkClass =
@@ -21,6 +21,7 @@ type SidebarProps = {
export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) { export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const t = useTranslations("Sidebar"); const t = useTranslations("Sidebar");
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [, setLogoutVersion] = useState(0); const [, setLogoutVersion] = useState(0);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -237,10 +238,13 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
) : hasStoredKey ? ( ) : hasStoredKey ? (
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
setApiKey(null); clearSession();
setLogoutVersion((v) => v + 1); setLogoutVersion((v) => v + 1);
onClose?.(); onClose?.();
await fetch("/admin/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}} }}
className={`${navLinkClass} w-full text-left text-gray-700 hover:bg-accent-100/80 hover:text-gray-900`} className={`${navLinkClass} w-full text-left text-gray-700 hover:bg-accent-100/80 hover:text-gray-900`}
> >

View File

@@ -0,0 +1,15 @@
import type { SessionOptions } from "iron-session";
export type SessionData = {
authenticated: boolean;
};
export const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET ?? "fallback-dev-secret-change-in-production!!",
cookieName: "rustycms_session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
},
};

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions, type SessionData } from "@/lib/session";
const PUBLIC_PREFIXES = ["/admin/login", "/admin/api/auth"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const response = NextResponse.next();
const session = await getIronSession<SessionData>(request, response, sessionOptions);
if (!session.authenticated) {
const loginUrl = new URL("/admin/login", request.url);
return NextResponse.redirect(loginUrl);
}
return response;
}
export const config = {
matcher: ["/admin/((?!_next|favicon.ico).*)"],
};

View File

@@ -19,6 +19,7 @@ services:
restart: unless-stopped restart: unless-stopped
expose: expose:
- "3001" - "3001"
env_file: /opt/rustycms/.env.admin
networks: networks:
- web - web