feat: Implement Keycloak authentication and a basic CMS
Integrates Keycloak for secure administrator authentication using Arctic. Introduces a full CMS dashboard for managing CV content, supporting multi-language editing for profile, experience, and education sections. Refactors application routes for modularity and adds initial unit tests for database queries. Also includes minor UI/UX refinements, animation setup, and local Keycloak docker-compose configuration. Fixes: - Corrected KeyCloak import. - Restored missing getEducation function. - Ensured proper HTTP redirects. - Fixed PKCE code verifier length.
This commit is contained in:
326
src/routes/admin.tsx
Normal file
326
src/routes/admin.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { html } from "@elysiajs/html";
|
||||
import { cookie } from "@elysiajs/cookie";
|
||||
import { KeyCloak, generateState, generateCodeVerifier } from "arctic";
|
||||
import * as elements from "typed-html";
|
||||
import { BaseHtml } from "../components/BaseHtml";
|
||||
import { getAdminProfile, getAdminExperience, getAdminEducation } from "../db/queries";
|
||||
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation } from "../db/mutations";
|
||||
|
||||
// Initialize Keycloak (Arctic)
|
||||
// Ensure these env vars are set!
|
||||
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID || "";
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || "";
|
||||
const redirectURI = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:3000/admin/callback";
|
||||
|
||||
console.log("--- Keycloak Config Debug ---");
|
||||
console.log("Realm URL:", realmURL);
|
||||
console.log("Client ID:", clientId);
|
||||
console.log("Redirect URI:", redirectURI);
|
||||
console.log("Client Secret Length:", clientSecret.length); // Check if secret is loaded
|
||||
console.log("-----------------------------");
|
||||
|
||||
const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
|
||||
|
||||
const AdminLayout = ({ children }: elements.Children) => (
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<nav class="bg-white dark:bg-gray-800 shadow p-4 mb-8">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<div class="font-bold text-xl">CV Admin</div>
|
||||
<div class="flex gap-4">
|
||||
<a href="/admin/dashboard" class="hover:text-blue-500">Dashboard</a>
|
||||
<a href="/" target="_blank" class="hover:text-blue-500">View Site</a>
|
||||
<a href="/admin/logout" class="text-red-500">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mx-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InputGroup = ({ label, name, value, type = "text", required = false }: any) => (
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
|
||||
<input type={type} name={name} value={value || ""} required={required}
|
||||
class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const TextAreaGroup = ({ label, name, value }: any) => (
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
|
||||
<textarea name={name} rows="4" class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600">{value || ""}</textarea>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const adminRoutes = new Elysia()
|
||||
.use(cookie())
|
||||
.use(html())
|
||||
// Auth Middleware
|
||||
.derive(({ cookie: { auth_session } }) => {
|
||||
return {
|
||||
isLoggedIn: !!auth_session?.value
|
||||
};
|
||||
})
|
||||
|
||||
// 1. Login Page (Redirect to Keycloak)
|
||||
.get("/admin/login", ({ isLoggedIn, set, html, cookie: { oauth_state, oauth_code_verifier } }) => {
|
||||
if (isLoggedIn) return Response.redirect("/admin/dashboard");
|
||||
|
||||
// Generate State & Verifier for PKCE flow
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
|
||||
// In a real production app with Arctic, you'd use generateState() / generateCodeVerifier() helpers if available,
|
||||
// or just random strings. Arctic 1.x/2.x/3.x changes API slightly.
|
||||
// Arctic 3.x Keycloak.createAuthorizationURL requires state and scopes.
|
||||
// Note: Arctic handles the heavy lifting usually.
|
||||
|
||||
// IMPORTANT: Arctic's createAuthorizationURL signature:
|
||||
// (state: string, codeVerifier: string, scopes: string[])
|
||||
|
||||
// We need to temporarily store state/verifier in cookies to verify later
|
||||
oauth_state.set({
|
||||
value: state,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
maxAge: 600 // 10 min
|
||||
});
|
||||
|
||||
oauth_code_verifier.set({
|
||||
value: codeVerifier,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
maxAge: 600 // 10 min
|
||||
});
|
||||
|
||||
try {
|
||||
const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
||||
|
||||
return html(
|
||||
<BaseHtml>
|
||||
<div class="flex h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded shadow-md w-96 text-center">
|
||||
<h1 class="text-2xl font-bold mb-6 dark:text-white">Admin Access</h1>
|
||||
<a href={url.toString()} class="block w-full bg-blue-600 text-white p-3 rounded font-bold hover:bg-blue-700 transition-colors">
|
||||
Login with Keycloak
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</BaseHtml>
|
||||
);
|
||||
} catch (e) {
|
||||
return "Error generating Keycloak URL. Check .env configuration.";
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Callback Handler
|
||||
.get("/admin/callback", async ({ query, cookie: { oauth_state, oauth_code_verifier, auth_session }, set }) => {
|
||||
const code = query.code as string;
|
||||
const state = query.state as string;
|
||||
const storedState = oauth_state.value;
|
||||
const storedVerifier = oauth_code_verifier.value;
|
||||
|
||||
if (!code || !state || !storedState || !storedVerifier || state !== storedState) {
|
||||
return new Response("Invalid State or Code", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokens = await keycloak.validateAuthorizationCode(code, storedVerifier);
|
||||
|
||||
// Ideally, you decode the tokens.idToken to get user info and check roles.
|
||||
// For now, if we got tokens, we assume the user authenticated successfully with Keycloak.
|
||||
// In a real app, you MUST check `tokens.idToken` claims (e.g. `sub`, `email`).
|
||||
|
||||
// Set a simple session cookie
|
||||
auth_session.set({
|
||||
value: tokens.accessToken(), // Or just "true" if you don't need the token
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 86400 // 1 day
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
oauth_state.remove();
|
||||
oauth_code_verifier.remove();
|
||||
|
||||
return Response.redirect("/admin/dashboard");
|
||||
} catch (e: any) {
|
||||
console.error("Keycloak Error:", e);
|
||||
return new Response(`Authentication failed: ${e.message}\n\nStack: ${e.stack}`, { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
.get("/admin/logout", ({ cookie: { auth_session }, set }) => {
|
||||
auth_session.remove();
|
||||
// Optional: Redirect to Keycloak logout endpoint as well
|
||||
return Response.redirect("/admin/login");
|
||||
})
|
||||
|
||||
// Protected Routes Guard
|
||||
.onBeforeHandle(({ isLoggedIn, set }) => {
|
||||
if (!isLoggedIn) return Response.redirect("/admin/login");
|
||||
})
|
||||
|
||||
// ... Dashboard and POST handlers remain the same ...
|
||||
.get("/admin/dashboard", ({ html }) => {
|
||||
const profile = getAdminProfile();
|
||||
const experience = getAdminExperience();
|
||||
const education = getAdminEducation();
|
||||
|
||||
return html(
|
||||
<BaseHtml>
|
||||
<AdminLayout>
|
||||
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
{/* Profile Section */}
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4">Profile</h2>
|
||||
<form action="/admin/profile" method="POST">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputGroup label="Email" name="email" value={profile.email} />
|
||||
<InputGroup label="Phone" name="phone" value={profile.phone} />
|
||||
<InputGroup label="Website" name="website" value={profile.website} />
|
||||
<InputGroup label="Avatar URL" name="avatar_url" value={profile.avatar_url} />
|
||||
<InputGroup label="Github" name="github_url" value={profile.github_url} />
|
||||
<InputGroup label="LinkedIn" name="linkedin_url" value={profile.linkedin_url} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6 border-t pt-4 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-2 text-blue-600">English</h3>
|
||||
<InputGroup label="Name" name="name_en" value={profile.name_en} />
|
||||
<InputGroup label="Job Title" name="job_title_en" value={profile.job_title_en} />
|
||||
<TextAreaGroup label="Summary" name="summary_en" value={profile.summary_en} />
|
||||
<InputGroup label="Location" name="location_en" value={profile.location_en} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-2 text-blue-600">German</h3>
|
||||
<InputGroup label="Name" name="name_de" value={profile.name_de} />
|
||||
<InputGroup label="Job Title" name="job_title_de" value={profile.job_title_de} />
|
||||
<TextAreaGroup label="Summary" name="summary_de" value={profile.summary_de} />
|
||||
<InputGroup label="Location" name="location_de" value={profile.location_de} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="mt-4 bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Save Profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Experience Section */}
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Experience</h2>
|
||||
<form action="/admin/experience/new" method="POST">
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Job</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
{experience.map((exp: any) => (
|
||||
<form action={`/admin/experience/${exp.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
|
||||
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
|
||||
<InputGroup label="Order" name="display_order" value={exp.display_order} type="number" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
|
||||
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
|
||||
<InputGroup label="Role" name="role_en" value={exp.role_en} />
|
||||
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
|
||||
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
|
||||
<InputGroup label="Role" name="role_de" value={exp.role_de} />
|
||||
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2">
|
||||
<button formaction={`/admin/experience/${exp.id}/delete`} class="text-red-500 text-sm hover:underline">Delete</button>
|
||||
<button class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Education Section */}
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Education</h2>
|
||||
<form action="/admin/education/new" method="POST">
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Education</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
{education.map((edu: any) => (
|
||||
<form action={`/admin/education/${edu.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
|
||||
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
|
||||
<InputGroup label="Order" name="display_order" value={edu.display_order} type="number" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
|
||||
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
|
||||
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
|
||||
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
|
||||
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
|
||||
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
|
||||
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2">
|
||||
<button formaction={`/admin/education/${edu.id}/delete`} class="text-red-500 text-sm hover:underline">Delete</button>
|
||||
<button class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</AdminLayout>
|
||||
</BaseHtml>
|
||||
);
|
||||
})
|
||||
// Handlers
|
||||
.post("/admin/profile", ({ body, set }) => {
|
||||
updateProfile(1, body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/experience/new", ({ set }) => {
|
||||
createExperience();
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/experience/:id", ({ params, body, set }) => {
|
||||
updateExperience(Number(params.id), body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/experience/:id/delete", ({ params, set }) => {
|
||||
deleteExperience(Number(params.id));
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/education/new", ({ set }) => {
|
||||
createEducation();
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/education/:id", ({ params, body, set }) => {
|
||||
updateEducation(Number(params.id), body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
.post("/admin/education/:id/delete", ({ params, set }) => {
|
||||
deleteEducation(Number(params.id));
|
||||
return Response.redirect("/admin/dashboard");
|
||||
});
|
||||
Reference in New Issue
Block a user