feat(core): setup postgres, minio, and authentik next-auth
This commit is contained in:
@@ -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 };
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user