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

@@ -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 (1100)",
"transformQuality": "Qualität (1100)",
"creating": "Wird erstellt…"
}
}
}

View File

@@ -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 (1100)",
"creating": "Creating…"
}
}
}

View File

@@ -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",

View File

@@ -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",

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 { 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>
);

View File

@@ -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">

View File

@@ -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`}
>

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).*)"],
};