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;
}