Add proper login to Admin UI, replace Caddy basic_auth
All checks were successful
Deploy to Server / deploy (push) Successful in 1m55s
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:
@@ -30,7 +30,7 @@ jobs:
|
||||
chmod 600 /tmp/deploy_key
|
||||
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'
|
||||
RUSTYCMS_API_KEY=${{ secrets.RUSTYCMS_API_KEY }}
|
||||
RUSTYCMS_BASE_URL=${{ secrets.RUSTYCMS_BASE_URL }}
|
||||
@@ -40,6 +40,14 @@ jobs:
|
||||
RUSTYCMS_STORE=${{ secrets.RUSTYCMS_STORE }}
|
||||
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 \
|
||||
-e "ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key" \
|
||||
./types/ root@167.86.74.105:/opt/rustycms/types/
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"LoginPage": {
|
||||
"title": "Anmelden",
|
||||
"apiKeyLabel": "API-Schlüssel",
|
||||
"apiKeyPlaceholder": "API-Schlüssel eingeben",
|
||||
"title": "Anmelden bei RustyCMS",
|
||||
"usernameLabel": "Benutzername",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"passwordLabel": "Passwort",
|
||||
"passwordPlaceholder": "Passwort",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -52,39 +56,39 @@
|
||||
"noAssets": "Noch keine Assets. Lade Bilder im Bereich Assets hoch.",
|
||||
"status": "Status",
|
||||
"statusDraft": "Entwurf",
|
||||
"statusPublished": "Ver\u00f6ffentlicht",
|
||||
"statusHint": "Entw\u00fcrfe sind \u00fcber die \u00f6ffentliche API nicht sichtbar."
|
||||
"statusPublished": "Veröffentlicht",
|
||||
"statusHint": "Entwürfe sind über die öffentliche API nicht sichtbar."
|
||||
},
|
||||
"SearchableSelect": {
|
||||
"placeholder": "\u2014 Bitte ausw\u00e4hlen \u2014",
|
||||
"clearLabel": "\u2014 Auswahl aufheben \u2014",
|
||||
"filterPlaceholder": "Filtern\u2026",
|
||||
"placeholder": "— Bitte auswählen —",
|
||||
"clearLabel": "— Auswahl aufheben —",
|
||||
"filterPlaceholder": "Filtern…",
|
||||
"emptyLabel": "Keine Treffer"
|
||||
},
|
||||
"ReferenceField": {
|
||||
"typeLabel": "Typ: {collection}",
|
||||
"typesLabel": "Typen: {collections}",
|
||||
"selectType": "\u2014 Typ w\u00e4hlen \u2014",
|
||||
"selectType": "— Typ wählen —",
|
||||
"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."
|
||||
},
|
||||
"ReferenceArrayField": {
|
||||
"typeLabel": "Typ: {collection}",
|
||||
"typesLabel": "Typen: {collections}",
|
||||
"componentType": "Komponententyp",
|
||||
"selectType": "\u2014 Typ w\u00e4hlen \u2014",
|
||||
"selectFromExisting": "\u2014 Aus vorhandenen w\u00e4hlen \u2014",
|
||||
"filterPlaceholder": "Filtern\u2026",
|
||||
"selectType": "— Typ wählen —",
|
||||
"selectFromExisting": "— Aus vorhandenen wählen —",
|
||||
"filterPlaceholder": "Filtern…",
|
||||
"emptyLabel": "Keine Treffer",
|
||||
"selectExistingAriaLabel": "Vorhandenen Eintrag zum Hinzuf\u00fcgen ausw\u00e4hlen",
|
||||
"selectExistingAriaLabel": "Vorhandenen Eintrag zum Hinzufügen auswählen",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"remove": "Entfernen",
|
||||
"newComponent": "Neue {collection}-Komponente",
|
||||
"createNewComponent": "+ Neue Komponente erstellen\u2026",
|
||||
"openInNewTab": "In neuem Tab \u00f6ffnen; dann Seite neu laden.",
|
||||
"openEntry": "Eintrag \u00f6ffnen",
|
||||
"createNewComponent": "+ Neue Komponente erstellen…",
|
||||
"openInNewTab": "In neuem Tab öffnen; dann Seite neu laden.",
|
||||
"openEntry": "Eintrag öffnen",
|
||||
"noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu."
|
||||
},
|
||||
"MarkdownEditor": {
|
||||
@@ -92,21 +96,21 @@
|
||||
"italic": "Kursiv",
|
||||
"code": "Code",
|
||||
"link": "Link",
|
||||
"bulletList": "Aufz\u00e4hlungsliste",
|
||||
"bulletListButton": "\u2022 Liste",
|
||||
"placeholder": "Markdown eingeben\u2026 **fett**, *kursiv*, [Link](url), - Liste",
|
||||
"bulletList": "Aufzählungsliste",
|
||||
"bulletListButton": "• Liste",
|
||||
"placeholder": "Markdown eingeben… **fett**, *kursiv*, [Link](url), - Liste",
|
||||
"preview": "Vorschau",
|
||||
"emptyPreview": "Leer \u2014 Vorschau erscheint beim Tippen."
|
||||
"emptyPreview": "Leer — Vorschau erscheint beim Tippen."
|
||||
},
|
||||
"PaginationLinks": {
|
||||
"back": "Zur\u00fcck",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"pageInfo": "Seite {page} von {totalPages} ({total} Eintr\u00e4ge)"
|
||||
"pageInfo": "Seite {page} von {totalPages} ({total} Einträge)"
|
||||
},
|
||||
"DataPreviewPanel": {
|
||||
"hide": "Daten-Vorschau ausblenden",
|
||||
"show": "Daten-Vorschau",
|
||||
"loading": "Laden\u2026",
|
||||
"loading": "Laden…",
|
||||
"errorLoading": "Fehler beim Laden"
|
||||
},
|
||||
"SchemaPanel": {
|
||||
@@ -125,14 +129,14 @@
|
||||
"sectionSchema": "Schema",
|
||||
"sectionDataPreview": "Aktuelle Daten",
|
||||
"copyCode": "Code kopieren",
|
||||
"loading": "Laden\u2026",
|
||||
"loading": "Laden…",
|
||||
"errorLoading": "Fehler beim Laden"
|
||||
},
|
||||
"ReferenceOrInlineField": {
|
||||
"reference": "Referenz",
|
||||
"inline": "Eingebettet",
|
||||
"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": {
|
||||
"label": "Sprache"
|
||||
@@ -170,38 +174,38 @@
|
||||
},
|
||||
"Dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "W\u00e4hle eine Sammlung zur Inhaltsverwaltung.",
|
||||
"subtitle": "Wähle eine Sammlung zur Inhaltsverwaltung.",
|
||||
"newContentType": "Neuer Inhaltstyp",
|
||||
"searchPlaceholder": "Inhaltstypen suchen…",
|
||||
"filterByTag": "Tag:",
|
||||
"tagAll": "Alle",
|
||||
"noResults": "Kein Inhaltstyp entspricht Suche oder Filter.",
|
||||
"noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist.",
|
||||
"recentSectionTitle": "Letzte 3 bearbeitete Beitr\u00e4ge",
|
||||
"recentSectionLink": "Alle Beitr\u00e4ge",
|
||||
"recentSectionEmpty": "Noch keine Beitr\u00e4ge.",
|
||||
"noCollections": "Keine Sammlungen geladen. Prüfe ob die RustyCMS-API unter {url} erreichbar ist.",
|
||||
"recentSectionTitle": "Letzte 3 bearbeitete Beiträge",
|
||||
"recentSectionLink": "Alle Beiträge",
|
||||
"recentSectionEmpty": "Noch keine Beiträge.",
|
||||
"loading": "Laden…"
|
||||
},
|
||||
"TypesPage": {
|
||||
"title": "Typen",
|
||||
"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…",
|
||||
"filterByTag": "Tag:",
|
||||
"tagAll": "Alle",
|
||||
"noResults": "Keine Typen entsprechen Ihrer Suche oder dem Filter.",
|
||||
"loading": "Laden\u2026",
|
||||
"loading": "Laden…",
|
||||
"errorLoading": "Fehler beim Laden der Typen: {error}",
|
||||
"noTypes": "Noch keine Typen vorhanden. Erstelle einen mit \"Neuer Typ\".",
|
||||
"colName": "Name",
|
||||
"colDescription": "Beschreibung",
|
||||
"colCategory": "Kategorie",
|
||||
"colActions": "Aktionen",
|
||||
"confirmDelete": "\"{name}\" l\u00f6schen?",
|
||||
"confirmDeleteFinal": "\"{name}\" wirklich l\u00f6schen? Dies kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
|
||||
"delete": "L\u00f6schen",
|
||||
"yesDelete": "Ja, l\u00f6schen",
|
||||
"deleting": "\u2026",
|
||||
"confirmDelete": "\"{name}\" löschen?",
|
||||
"confirmDeleteFinal": "\"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"delete": "Löschen",
|
||||
"yesDelete": "Ja, löschen",
|
||||
"deleting": "…",
|
||||
"cancel": "Abbrechen",
|
||||
"edit": "Bearbeiten"
|
||||
},
|
||||
@@ -211,41 +215,41 @@
|
||||
"nameRequired": "Name ist erforderlich.",
|
||||
"nameInvalid": "Name: nur Kleinbuchstaben, Ziffern und Unterstriche.",
|
||||
"fieldRequired": "Mindestens ein Feld erforderlich.",
|
||||
"fieldNamesUnique": "Feldnamen m\u00fcssen eindeutig sein.",
|
||||
"fieldNamesUnique": "Feldnamen müssen eindeutig sein.",
|
||||
"errorCreating": "Fehler beim Erstellen des Typs.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "z.\u00a0B. produkt, blogbeitrag",
|
||||
"namePlaceholder": "z. B. produkt, blogbeitrag",
|
||||
"nameHint": "Nur Kleinbuchstaben, Ziffern und Unterstriche.",
|
||||
"descriptionLabel": "Beschreibung",
|
||||
"categoryLabel": "Kategorie",
|
||||
"categoryPlaceholder": "z.\u00a0B. inhalt",
|
||||
"categoryPlaceholder": "z. B. inhalt",
|
||||
"tagsLabel": "Tags (kommagetrennt)",
|
||||
"tagsPlaceholder": "z.\u00a0B. inhalt, blog",
|
||||
"tagsPlaceholder": "z. B. inhalt, blog",
|
||||
"strictLabel": "Strikt (unbekannte Felder ablehnen)",
|
||||
"fieldsLabel": "Felder",
|
||||
"addField": "Feld hinzuf\u00fcgen",
|
||||
"addField": "Feld hinzufügen",
|
||||
"fieldNamePlaceholder": "Feldname",
|
||||
"fieldTypeLabel": "Feldtyp",
|
||||
"required": "Pflichtfeld",
|
||||
"removeField": "Feld entfernen",
|
||||
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)",
|
||||
"collectionPlaceholder": "Sammlung (z. B. seite)",
|
||||
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
|
||||
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
|
||||
"arrayItemType": "Array-Elementtyp",
|
||||
"itemTypePlaceholder": "z.\u00a0B. string, reference",
|
||||
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—w\u00e4hle unten, was ein Element ist.",
|
||||
"itemTypePlaceholder": "z. B. string, reference",
|
||||
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—wähle unten, was ein Element ist.",
|
||||
"arrayEachEntry": "Ein Listeneintrag ist",
|
||||
"itemKindString": "Einfacher Text (string)",
|
||||
"itemKindNumber": "Zahl (number)",
|
||||
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
|
||||
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
|
||||
"objectItemFieldsLabel": "Felder pro Listeneintrag",
|
||||
"addObjectField": "Unterfeld hinzuf\u00fcgen",
|
||||
"addObjectField": "Unterfeld hinzufügen",
|
||||
"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",
|
||||
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3",
|
||||
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.",
|
||||
"multiSelectOptionsPlaceholder": "z. B. option1, option2, option3",
|
||||
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es können mehrere ausgewählt werden.",
|
||||
"stringWidgetLabel": "Eingabeart",
|
||||
"stringWidgetSingleline": "Einzeilig",
|
||||
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
|
||||
@@ -257,34 +261,34 @@
|
||||
"codeLanguageJson": "JSON",
|
||||
"codeLanguageHtml": "HTML",
|
||||
"defaultValueLabel": "Standardwert",
|
||||
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]",
|
||||
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.",
|
||||
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"",
|
||||
"defaultValuePlaceholder": "z. B. \"text\", 0, true, [\"a\",\"b\"]",
|
||||
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Einträgen verwendet.",
|
||||
"defaultValueInvalid": "Ungültiges JSON für Standardwert im Feld \"{field}\"",
|
||||
"defaultValueBoolean": "Standard: angehakt",
|
||||
"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",
|
||||
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
|
||||
"creating": "Erstellen\u2026",
|
||||
"creating": "Erstellen…",
|
||||
"createType": "Typ erstellen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"EditTypePage": {
|
||||
"fieldRequired": "Mindestens ein Feld erforderlich.",
|
||||
"fieldNamesUnique": "Feldnamen m\u00fcssen eindeutig sein.",
|
||||
"fieldNamesUnique": "Feldnamen müssen eindeutig sein.",
|
||||
"errorSaving": "Fehler beim Speichern des Typs.",
|
||||
"missingName": "Typname fehlt.",
|
||||
"backToTypes": "Zur\u00fcck zu Typen",
|
||||
"loading": "Laden\u2026",
|
||||
"backToTypes": "Zurück zu Typen",
|
||||
"loading": "Laden…",
|
||||
"errorLoading": "Fehler beim Laden des Typs: {error}",
|
||||
"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",
|
||||
"descriptionLabel": "Beschreibung",
|
||||
"categoryLabel": "Kategorie",
|
||||
"categoryPlaceholder": "z.\u00a0B. inhalt",
|
||||
"categoryPlaceholder": "z. B. inhalt",
|
||||
"tagsLabel": "Tags (kommagetrennt)",
|
||||
"tagsPlaceholder": "z.\u00a0B. inhalt, blog",
|
||||
"tagsPlaceholder": "z. B. inhalt, blog",
|
||||
"strictLabel": "Strikt (unbekannte Felder ablehnen)",
|
||||
"extendsLabel": "Erweitert",
|
||||
"extendsDescription": "Dieser Typ erbt Felder von diesen Typen. Gehe zum jeweiligen Typ, um geerbte Felder zu bearbeiten.",
|
||||
@@ -293,7 +297,7 @@
|
||||
"addExtend": "Hinzufügen",
|
||||
"removeExtend": "Aus Erweiterungen entfernen",
|
||||
"fieldsLabel": "Felder",
|
||||
"addField": "Feld hinzuf\u00fcgen",
|
||||
"addField": "Feld hinzufügen",
|
||||
"moveFieldUp": "Feld nach oben",
|
||||
"moveFieldDown": "Feld nach unten",
|
||||
"moveFieldToTop": "Ganz nach oben",
|
||||
@@ -302,7 +306,7 @@
|
||||
"fieldTypeLabel": "Feldtyp",
|
||||
"required": "Pflichtfeld",
|
||||
"removeField": "Feld entfernen",
|
||||
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)",
|
||||
"collectionPlaceholder": "Sammlung (z. B. seite)",
|
||||
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
|
||||
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
|
||||
"patternLabel": "Pattern (Regex)",
|
||||
@@ -310,20 +314,20 @@
|
||||
"minLengthLabel": "Min. Länge",
|
||||
"maxLengthLabel": "Max. Länge",
|
||||
"arrayItemType": "Array-Elementtyp",
|
||||
"itemTypePlaceholder": "z.\u00a0B. string, reference",
|
||||
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—w\u00e4hle unten, was ein Element ist.",
|
||||
"itemTypePlaceholder": "z. B. string, reference",
|
||||
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—wähle unten, was ein Element ist.",
|
||||
"arrayEachEntry": "Ein Listeneintrag ist",
|
||||
"itemKindString": "Einfacher Text (string)",
|
||||
"itemKindNumber": "Zahl (number)",
|
||||
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
|
||||
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
|
||||
"objectItemFieldsLabel": "Felder pro Listeneintrag",
|
||||
"addObjectField": "Unterfeld hinzuf\u00fcgen",
|
||||
"addObjectField": "Unterfeld hinzufügen",
|
||||
"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",
|
||||
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3",
|
||||
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.",
|
||||
"multiSelectOptionsPlaceholder": "z. B. option1, option2, option3",
|
||||
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es können mehrere ausgewählt werden.",
|
||||
"stringWidgetLabel": "Eingabeart",
|
||||
"stringWidgetSingleline": "Einzeilig",
|
||||
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
|
||||
@@ -335,15 +339,15 @@
|
||||
"codeLanguageJson": "JSON",
|
||||
"codeLanguageHtml": "HTML",
|
||||
"defaultValueLabel": "Standardwert",
|
||||
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]",
|
||||
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.",
|
||||
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"",
|
||||
"defaultValuePlaceholder": "z. B. \"text\", 0, true, [\"a\",\"b\"]",
|
||||
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Einträgen verwendet.",
|
||||
"defaultValueInvalid": "Ungültiges JSON für Standardwert im Feld \"{field}\"",
|
||||
"defaultValueBoolean": "Standard: angehakt",
|
||||
"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",
|
||||
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
|
||||
"saving": "Speichern\u2026",
|
||||
"saving": "Speichern…",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
@@ -446,11 +450,11 @@
|
||||
"transformPresetMedium": "Mittel 800px",
|
||||
"transformPresetJpeg": "JPEG 1200px",
|
||||
"transformWidth": "Breite",
|
||||
"transformHeight": "H\u00f6he",
|
||||
"transformAspect": "Seitenverh\u00e4ltnis",
|
||||
"transformHeight": "Höhe",
|
||||
"transformAspect": "Seitenverhältnis",
|
||||
"transformFit": "Fit",
|
||||
"transformFormat": "Format",
|
||||
"transformQuality": "Qualit\u00e4t (1–100)",
|
||||
"transformQuality": "Qualität (1–100)",
|
||||
"creating": "Wird erstellt…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"LoginPage": {
|
||||
"title": "Login",
|
||||
"apiKeyLabel": "API key",
|
||||
"apiKeyPlaceholder": "Enter your API key",
|
||||
"title": "Sign in to RustyCMS",
|
||||
"usernameLabel": "Username",
|
||||
"usernamePlaceholder": "Username",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Password",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -453,4 +457,4 @@
|
||||
"transformQuality": "Quality (1–100)",
|
||||
"creating": "Creating…"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
admin-ui/package-lock.json
generated
40
admin-ui/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.3",
|
||||
@@ -5958,6 +5959,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7699,6 +7709,30 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -11281,6 +11315,12 @@
|
||||
"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": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.3",
|
||||
|
||||
25
admin-ui/src/app/api/auth/login/route.ts
Normal file
25
admin-ui/src/app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
10
admin-ui/src/app/api/auth/logout/route.ts
Normal file
10
admin-ui/src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { setApiKey } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,52 +10,92 @@ import { Input } from "@/components/ui/input";
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("LoginPage");
|
||||
const router = useRouter();
|
||||
const [key, setKey] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
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();
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
setError("API key is required.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setApiKey(trimmed);
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.apiKey) {
|
||||
setApiKey(data.apiKey);
|
||||
}
|
||||
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("networkError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<div className="rounded-xl border border-accent-200 bg-white p-6 shadow-sm">
|
||||
<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="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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="api-key" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
{t("apiKeyLabel")}
|
||||
<label htmlFor="username" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
{t("usernameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder={t("apiKeyPlaceholder")}
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
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>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
{t("submit")}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? t("loggingIn") : t("submit")}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
@@ -11,6 +12,11 @@ type AppShellProps = {
|
||||
|
||||
export function AppShell({ locale, children }: AppShellProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/login") {
|
||||
return <ErrorBoundary>{children}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-white">
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
const navLinkClass =
|
||||
@@ -21,6 +21,7 @@ type SidebarProps = {
|
||||
export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
|
||||
const t = useTranslations("Sidebar");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [, setLogoutVersion] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -237,10 +238,13 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
|
||||
) : hasStoredKey ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setApiKey(null);
|
||||
onClick={async () => {
|
||||
clearSession();
|
||||
setLogoutVersion((v) => v + 1);
|
||||
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`}
|
||||
>
|
||||
|
||||
15
admin-ui/src/lib/session.ts
Normal file
15
admin-ui/src/lib/session.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
28
admin-ui/src/middleware.ts
Normal file
28
admin-ui/src/middleware.ts
Normal 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).*)"],
|
||||
};
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3001"
|
||||
env_file: /opt/rustycms/.env.admin
|
||||
networks:
|
||||
- web
|
||||
|
||||
|
||||
Reference in New Issue
Block a user