diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 37b04f7..8bb86e7 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,54 +1,5 @@ -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 - }, -}; +import NextAuth from "next-auth"; +import { authOptions } from "@/core/auth/auth-options"; const handler = NextAuth(authOptions); diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts new file mode 100644 index 0000000..d949b55 --- /dev/null +++ b/src/core/auth/auth-options.ts @@ -0,0 +1,47 @@ +import type { 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 + 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 + 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; + }, + }, +}; diff --git a/src/core/auth/auth-provider.tsx b/src/core/auth/auth-provider.tsx index aebeef8..638a88e 100644 --- a/src/core/auth/auth-provider.tsx +++ b/src/core/auth/auth-provider.tsx @@ -30,8 +30,16 @@ interface AuthProviderProps { function AuthProviderInner({ children }: AuthProviderProps) { const { data: session, status } = useSession(); + // Show loading spinner while session is being validated + if (status === "loading") { + return ( +
+
+
+ ); + } + // 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", diff --git a/src/core/auth/index.ts b/src/core/auth/index.ts index 1fbbcee..d3ccdac 100644 --- a/src/core/auth/index.ts +++ b/src/core/auth/index.ts @@ -1,2 +1,4 @@ export type { User, Role, CompanyId, AuthContextValue } from './types'; export { AuthProvider, useAuth } from './auth-provider'; +export { authOptions } from './auth-options'; +export { getAuthSession } from './require-auth'; diff --git a/src/core/auth/require-auth.ts b/src/core/auth/require-auth.ts new file mode 100644 index 0000000..688291b --- /dev/null +++ b/src/core/auth/require-auth.ts @@ -0,0 +1,12 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "./auth-options"; + +/** + * Get the authenticated session, or null if not authenticated. + * Usage in API routes: + * const session = await getAuthSession(); + * if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + */ +export async function getAuthSession() { + return getServerSession(authOptions); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..cd9142f --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,47 @@ +import { getToken } from "next-auth/jwt"; +import { NextRequest, NextResponse } from "next/server"; + +export async function middleware(request: NextRequest) { + // In development, skip auth enforcement (dev stub user handles it) + if (process.env.NODE_ENV === "development") { + return NextResponse.next(); + } + + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + }); + + // Authenticated — allow through + if (token) { + return NextResponse.next(); + } + + const { pathname } = request.nextUrl; + + // API routes: return 401 JSON instead of redirect + if (pathname.startsWith("/api/")) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 }, + ); + } + + // Page routes: redirect to NextAuth sign-in with callbackUrl + const signInUrl = new URL("/api/auth/signin", request.url); + signInUrl.searchParams.set("callbackUrl", request.url); + return NextResponse.redirect(signInUrl); +} + +export const config = { + matcher: [ + /* + * Match all paths EXCEPT: + * - /api/auth/* (NextAuth endpoints — must be public for login flow) + * - /_next/* (Next.js internals: static files, HMR, chunks) + * - /favicon.ico, /robots.txt, /sitemap.xml + * - Files with extensions (images, fonts, etc.) + */ + "/((?!api/auth|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", + ], +}; diff --git a/src/modules/tag-manager/services/manictime-service.ts b/src/modules/tag-manager/services/manictime-service.ts index 98664a9..c37b978 100644 --- a/src/modules/tag-manager/services/manictime-service.ts +++ b/src/modules/tag-manager/services/manictime-service.ts @@ -71,16 +71,24 @@ const COMPANY_HEADERS = new Set([ "studii de teren", ]); +/** Map group header text → CompanyId */ +const HEADER_TO_COMPANY: Record = { + beletage: "beletage", + "urban switch": "urban-switch", + "studii de teren": "studii-de-teren", +}; + // ── Project line pattern: optional letter prefix + digits + space + name ── const PROJECT_LINE_RE = /^(\w?\d+)\s+(.+)$/; // Special pattern for "176 - 2025 - ReAC Ansamblu rezi Bibescu" style -const PROJECT_LINE_EXTENDED_RE = /^(\d+)\s+-\s+.+$/; +const PROJECT_LINE_EXTENDED_RE = /^(\d+)\s+-\s+(?:\d{4}\s+-\s+)?(.+)$/; export interface ManicTimeTag { line: string; category: TagCategory | "header" | "unknown"; projectCode?: string; + companyId?: CompanyId; label: string; } @@ -120,9 +128,25 @@ export function parseManicTimeFile(content: string): ParsedManicTimeFile { } const tags: ManicTimeTag[] = []; + let currentCompany: CompanyId | undefined; + for (const group of groups) { + // Check if the first line of this group is a company header + const firstLine = group[0]; + if (firstLine) { + const headerCompany = HEADER_TO_COMPANY[firstLine.toLowerCase()]; + if (headerCompany) { + currentCompany = headerCompany; + } + } + for (const line of group) { - tags.push(classifyLine(line)); + const tag = classifyLine(line); + // Assign company context to project tags + if (tag.category === "project" && currentCompany) { + tag.companyId = currentCompany; + } + tags.push(tag); } } @@ -155,12 +179,15 @@ function classifyLine(line: string): ManicTimeTag { label, }; } - if (PROJECT_LINE_EXTENDED_RE.test(trimmed)) { + const extendedMatch = trimmed.match(PROJECT_LINE_EXTENDED_RE); + if (extendedMatch?.[1] && extendedMatch[2]) { + const extNum = extendedMatch[1]; + const extLabel = extendedMatch[2].trim(); return { line: trimmed, category: "project", - projectCode: `B-${trimmed.split(/\s/)[0]?.padStart(3, "0") ?? "000"}`, - label: trimmed, + projectCode: `B-${extNum.padStart(3, "0")}`, + label: extLabel, }; } @@ -345,7 +372,7 @@ export function manicTimeTagToCreateData( }; if (category === "project") { - base.companyId = "beletage"; + base.companyId = mt.companyId ?? "beletage"; base.projectCode = mt.projectCode; }