feat(core): setup postgres, minio, and authentik next-auth

This commit is contained in:
AI Assistant
2026-02-27 10:29:54 +02:00
parent 3b1ba589f0
commit 0ad7e835bd
18 changed files with 1654 additions and 105 deletions
+55
View File
@@ -0,0 +1,55 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
export const authOptions: NextAuthOptions = {
providers: [
AuthentikProvider({
clientId: process.env.AUTHENTIK_CLIENT_ID || "",
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || "",
issuer: process.env.AUTHENTIK_ISSUER || "",
}),
],
callbacks: {
async jwt({ token, user, profile }) {
if (user) {
token.id = user.id;
}
if (profile) {
// Map Authentik groups/roles to our internal roles
// This assumes Authentik sends groups in the profile
const groups = (profile as any).groups || [];
let role = "user";
if (groups.includes("architools-admin")) role = "admin";
else if (groups.includes("architools-manager")) role = "manager";
token.role = role;
// Map company based on groups or attributes
let company = "group";
if (groups.includes("company-beletage")) company = "beletage";
else if (groups.includes("company-urban-switch"))
company = "urban-switch";
else if (groups.includes("company-studii-de-teren"))
company = "studii-de-teren";
token.company = company;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.id;
(session.user as any).role = token.role || "user";
(session.user as any).company = token.company || "group";
}
return session;
},
},
pages: {
// We can add custom sign-in pages later if needed
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+130
View File
@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const namespace = searchParams.get("namespace");
const key = searchParams.get("key");
if (!namespace) {
return NextResponse.json(
{ error: "Namespace is required" },
{ status: 400 },
);
}
try {
if (key) {
// Get single item
const item = await prisma.keyValueStore.findUnique({
where: {
namespace_key: {
namespace,
key,
},
},
});
return NextResponse.json({ value: item ? item.value : null });
} else {
// Get all items in namespace
const items = await prisma.keyValueStore.findMany({
where: { namespace },
});
// Return as a record { [key]: value }
const result: Record<string, any> = {};
for (const item of items) {
result[item.key] = item.value;
}
return NextResponse.json({ items: result });
}
} catch (error) {
console.error("Storage GET error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { namespace, key, value } = body;
if (!namespace || !key) {
return NextResponse.json(
{ error: "Namespace and key are required" },
{ status: 400 },
);
}
await prisma.keyValueStore.upsert({
where: {
namespace_key: {
namespace,
key,
},
},
update: {
value,
},
create: {
namespace,
key,
value,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Storage POST error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function DELETE(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const namespace = searchParams.get("namespace");
const key = searchParams.get("key");
if (!namespace) {
return NextResponse.json(
{ error: "Namespace is required" },
{ status: 400 },
);
}
try {
if (key) {
// Delete single item
await prisma.keyValueStore
.delete({
where: {
namespace_key: {
namespace,
key,
},
},
})
.catch(() => {
// Ignore error if item doesn't exist
});
} else {
// Clear namespace
await prisma.keyValueStore.deleteMany({
where: { namespace },
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Storage DELETE error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+42 -20
View File
@@ -1,7 +1,8 @@
'use client';
"use client";
import { createContext, useContext, useMemo, useCallback } from 'react';
import type { AuthContextValue, User, Role } from './types';
import { createContext, useContext, useMemo, useCallback } from "react";
import { SessionProvider, useSession } from "next-auth/react";
import type { AuthContextValue, User, Role, CompanyId } from "./types";
const ROLE_HIERARCHY: Record<Role, number> = {
admin: 4,
@@ -13,55 +14,76 @@ const ROLE_HIERARCHY: Record<Role, number> = {
const AuthContext = createContext<AuthContextValue | null>(null);
// Stub user for development (no auth required)
// Stub user for development fallback
const STUB_USER: User = {
id: 'dev-user',
name: 'Utilizator Intern',
email: 'dev@architools.local',
role: 'admin',
company: 'beletage',
id: "dev-user",
name: "Utilizator Intern",
email: "dev@architools.local",
role: "admin",
company: "beletage",
};
interface AuthProviderProps {
children: React.ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
// In the current phase, always return the stub user
// Future: replace with Authentik OIDC token resolution
const user = STUB_USER;
function AuthProviderInner({ children }: AuthProviderProps) {
const { data: session, status } = useSession();
// Use session user if available, otherwise fallback to stub in dev mode
// In production, we should probably force login if no session
const user: User | null = session?.user
? {
id: (session.user as any).id || "unknown",
name: session.user.name || "Unknown User",
email: session.user.email || "",
role: ((session.user as any).role as Role) || "user",
company: ((session.user as any).company as CompanyId) || "group",
}
: process.env.NODE_ENV === "development"
? STUB_USER
: null;
const hasRole = useCallback(
(requiredRole: Role) => {
if (!user) return false;
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[requiredRole];
},
[user.role]
[user],
);
const canAccessModule = useCallback(
(_moduleId: string) => {
// Future: check module-level permissions
return true;
return !!user;
},
[]
[user],
);
const value: AuthContextValue = useMemo(
() => ({
user,
role: user.role,
isAuthenticated: true,
role: user?.role || "guest",
isAuthenticated: !!user,
hasRole,
canAccessModule,
}),
[user, hasRole, canAccessModule]
[user, hasRole, canAccessModule],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function AuthProvider({ children }: AuthProviderProps) {
return (
<SessionProvider>
<AuthProviderInner>{children}</AuthProviderInner>
</SessionProvider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
@@ -0,0 +1,112 @@
import type { StorageService } from "../types";
export class DatabaseStorageAdapter implements StorageService {
async get<T>(namespace: string, key: string): Promise<T | null> {
try {
const res = await fetch(
`/api/storage?namespace=${encodeURIComponent(namespace)}&key=${encodeURIComponent(key)}`,
);
if (!res.ok) return null;
const data = await res.json();
return data.value as T | null;
} catch (error) {
console.error("DatabaseStorageAdapter get error:", error);
return null;
}
}
async set<T>(namespace: string, key: string, value: T): Promise<void> {
try {
await fetch("/api/storage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace, key, value }),
});
} catch (error) {
console.error("DatabaseStorageAdapter set error:", error);
}
}
async delete(namespace: string, key: string): Promise<void> {
try {
await fetch(
`/api/storage?namespace=${encodeURIComponent(namespace)}&key=${encodeURIComponent(key)}`,
{
method: "DELETE",
},
);
} catch (error) {
console.error("DatabaseStorageAdapter delete error:", error);
}
}
async list(namespace: string): Promise<string[]> {
try {
const res = await fetch(
`/api/storage?namespace=${encodeURIComponent(namespace)}`,
);
if (!res.ok) return [];
const data = await res.json();
return Object.keys(data.items || {});
} catch (error) {
console.error("DatabaseStorageAdapter list error:", error);
return [];
}
}
async query<T>(
namespace: string,
predicate: (item: T) => boolean,
): Promise<T[]> {
try {
const res = await fetch(
`/api/storage?namespace=${encodeURIComponent(namespace)}`,
);
if (!res.ok) return [];
const data = await res.json();
const items = Object.values(data.items || {}) as T[];
return items.filter(predicate);
} catch (error) {
console.error("DatabaseStorageAdapter query error:", error);
return [];
}
}
async clear(namespace: string): Promise<void> {
try {
await fetch(`/api/storage?namespace=${encodeURIComponent(namespace)}`, {
method: "DELETE",
});
} catch (error) {
console.error("DatabaseStorageAdapter clear error:", error);
}
}
async export(namespace: string): Promise<Record<string, unknown>> {
try {
const res = await fetch(
`/api/storage?namespace=${encodeURIComponent(namespace)}`,
);
if (!res.ok) return {};
const data = await res.json();
return data.items || {};
} catch (error) {
console.error("DatabaseStorageAdapter export error:", error);
return {};
}
}
async import(
namespace: string,
data: Record<string, unknown>,
): Promise<void> {
try {
// Import items one by one (or we could create a bulk endpoint, but this is fine for now)
for (const [key, value] of Object.entries(data)) {
await this.set(namespace, key, value);
}
} catch (error) {
console.error("DatabaseStorageAdapter import error:", error);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Client } from "minio";
const globalForMinio = globalThis as unknown as {
minioClient: Client | undefined;
};
export const minioClient =
globalForMinio.minioClient ??
new Client({
endPoint: process.env.MINIO_ENDPOINT || "localhost",
port: parseInt(process.env.MINIO_PORT || "9000"),
useSSL: process.env.MINIO_USE_SSL === "true",
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
});
if (process.env.NODE_ENV !== "production")
globalForMinio.minioClient = minioClient;
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
// Helper to ensure bucket exists
export async function ensureBucketExists() {
try {
const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1");
console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`);
}
} catch (error) {
console.error("Error checking/creating MinIO bucket:", error);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+13 -7
View File
@@ -1,14 +1,19 @@
'use client';
"use client";
import { createContext, useContext, useMemo } from 'react';
import type { StorageService } from './types';
import { LocalStorageAdapter } from './adapters/local-storage';
import { createContext, useContext, useMemo } from "react";
import type { StorageService } from "./types";
import { LocalStorageAdapter } from "./adapters/local-storage";
import { DatabaseStorageAdapter } from "./adapters/database-adapter";
const StorageContext = createContext<StorageService | null>(null);
function createAdapter(): StorageService {
// Future: select adapter based on environment variable
// const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER;
const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER;
if (adapterType === "database") {
return new DatabaseStorageAdapter();
}
return new LocalStorageAdapter();
}
@@ -28,6 +33,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
export function useStorageService(): StorageService {
const ctx = useContext(StorageContext);
if (!ctx) throw new Error('useStorageService must be used within StorageProvider');
if (!ctx)
throw new Error("useStorageService must be used within StorageProvider");
return ctx;
}
+62 -8
View File
@@ -1,14 +1,25 @@
'use client';
"use client";
import { useTheme } from 'next-themes';
import { Moon, Sun, PanelLeft } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { useTheme } from "next-themes";
import {
Moon,
Sun,
PanelLeft,
User as UserIcon,
LogOut,
LogIn,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/shared/components/ui/dropdown-menu';
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/shared/components/ui/dropdown-menu";
import { useAuth } from "@/core/auth";
import { signIn, signOut } from "next-auth/react";
interface HeaderProps {
onToggleSidebar?: () => void;
@@ -16,6 +27,7 @@ interface HeaderProps {
export function Header({ onToggleSidebar }: HeaderProps) {
const { setTheme } = useTheme();
const { user, isAuthenticated } = useAuth();
return (
<header className="flex h-14 items-center justify-between border-b bg-card px-4">
@@ -40,17 +52,59 @@ export function Header({ onToggleSidebar }: HeaderProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<DropdownMenuItem onClick={() => setTheme("light")}>
Luminos
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Întunecat
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<DropdownMenuItem onClick={() => setTheme("system")}>
Sistem
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-full bg-muted"
>
<UserIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{isAuthenticated && user ? (
<>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<LogOut className="mr-2 h-4 w-4" />
<span>Deconectare</span>
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuLabel>Neautentificat</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signIn("authentik")}>
<LogIn className="mr-2 h-4 w-4" />
<span>Autentificare (Authentik)</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);