diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 560b3ad..47cfcf1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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/ diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index 04e632d..0bc93e0 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -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…" } -} +} \ No newline at end of file diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index 204dde7..0ee0e0a 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -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…" } -} +} \ No newline at end of file diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json index 2f73b76..72d08e4 100644 --- a/admin-ui/package-lock.json +++ b/admin-ui/package-lock.json @@ -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", diff --git a/admin-ui/package.json b/admin-ui/package.json index e047f3b..0d8197b 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -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", diff --git a/admin-ui/src/app/api/auth/login/route.ts b/admin-ui/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..ff09680 --- /dev/null +++ b/admin-ui/src/app/api/auth/login/route.ts @@ -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(request, response, sessionOptions); + session.authenticated = true; + await session.save(); + + return response; +} diff --git a/admin-ui/src/app/api/auth/logout/route.ts b/admin-ui/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f3b00ce --- /dev/null +++ b/admin-ui/src/app/api/auth/logout/route.ts @@ -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(request, response, sessionOptions); + session.destroy(); + return response; +} diff --git a/admin-ui/src/app/login/page.tsx b/admin-ui/src/app/login/page.tsx index ce6efde..6106e62 100644 --- a/admin-ui/src/app/login/page.tsx +++ b/admin-ui/src/app/login/page.tsx @@ -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(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 ( -
-
+
+
+
+ + + + + + + RustyCMS +

{t("title")}

-
+
+ + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + className="w-full" + required />
{error &&

{error}

} -
-

{t("hint")}

-

- - ← Back to dashboard - -

); diff --git a/admin-ui/src/components/AppShell.tsx b/admin-ui/src/components/AppShell.tsx index 2a5a17a..bbe04c5 100644 --- a/admin-ui/src/components/AppShell.tsx +++ b/admin-ui/src/components/AppShell.tsx @@ -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 {children}; + } return (
diff --git a/admin-ui/src/components/Sidebar.tsx b/admin-ui/src/components/Sidebar.tsx index ff905be..2b7e5ef 100644 --- a/admin-ui/src/components/Sidebar.tsx +++ b/admin-ui/src/components/Sidebar.tsx @@ -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 ? (