Merge branch 'claude/elastic-chaplygin'
This commit is contained in:
@@ -1,54 +1,5 @@
|
|||||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import AuthentikProvider from "next-auth/providers/authentik";
|
import { authOptions } from "@/core/auth/auth-options";
|
||||||
|
|
||||||
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);
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -30,8 +30,16 @@ interface AuthProviderProps {
|
|||||||
function AuthProviderInner({ children }: AuthProviderProps) {
|
function AuthProviderInner({ children }: AuthProviderProps) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
|
// Show loading spinner while session is being validated
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Use session user if available, otherwise fallback to stub in dev mode
|
// 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
|
const user: User | null = session?.user
|
||||||
? {
|
? {
|
||||||
id: (session.user as any).id || "unknown",
|
id: (session.user as any).id || "unknown",
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export type { User, Role, CompanyId, AuthContextValue } from './types';
|
export type { User, Role, CompanyId, AuthContextValue } from './types';
|
||||||
export { AuthProvider, useAuth } from './auth-provider';
|
export { AuthProvider, useAuth } from './auth-provider';
|
||||||
|
export { authOptions } from './auth-options';
|
||||||
|
export { getAuthSession } from './require-auth';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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|.*\\..*).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -71,16 +71,24 @@ const COMPANY_HEADERS = new Set([
|
|||||||
"studii de teren",
|
"studii de teren",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Map group header text → CompanyId */
|
||||||
|
const HEADER_TO_COMPANY: Record<string, CompanyId> = {
|
||||||
|
beletage: "beletage",
|
||||||
|
"urban switch": "urban-switch",
|
||||||
|
"studii de teren": "studii-de-teren",
|
||||||
|
};
|
||||||
|
|
||||||
// ── Project line pattern: optional letter prefix + digits + space + name ──
|
// ── Project line pattern: optional letter prefix + digits + space + name ──
|
||||||
|
|
||||||
const PROJECT_LINE_RE = /^(\w?\d+)\s+(.+)$/;
|
const PROJECT_LINE_RE = /^(\w?\d+)\s+(.+)$/;
|
||||||
// Special pattern for "176 - 2025 - ReAC Ansamblu rezi Bibescu" style
|
// 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 {
|
export interface ManicTimeTag {
|
||||||
line: string;
|
line: string;
|
||||||
category: TagCategory | "header" | "unknown";
|
category: TagCategory | "header" | "unknown";
|
||||||
projectCode?: string;
|
projectCode?: string;
|
||||||
|
companyId?: CompanyId;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +128,25 @@ export function parseManicTimeFile(content: string): ParsedManicTimeFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tags: ManicTimeTag[] = [];
|
const tags: ManicTimeTag[] = [];
|
||||||
|
let currentCompany: CompanyId | undefined;
|
||||||
|
|
||||||
for (const group of groups) {
|
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) {
|
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,
|
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 {
|
return {
|
||||||
line: trimmed,
|
line: trimmed,
|
||||||
category: "project",
|
category: "project",
|
||||||
projectCode: `B-${trimmed.split(/\s/)[0]?.padStart(3, "0") ?? "000"}`,
|
projectCode: `B-${extNum.padStart(3, "0")}`,
|
||||||
label: trimmed,
|
label: extLabel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +372,7 @@ export function manicTimeTagToCreateData(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (category === "project") {
|
if (category === "project") {
|
||||||
base.companyId = "beletage";
|
base.companyId = mt.companyId ?? "beletage";
|
||||||
base.projectCode = mt.projectCode;
|
base.projectCode = mt.projectCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user