Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/consts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class VectorDatabaseType(str, Enum):
LOCAL_SESSION_MAX_AGE_SECONDS = int(os.getenv("LOCAL_SESSION_MAX_AGE_SECONDS", "3600") or 3600)
CAS_RENEW_BEFORE_SECONDS = int(os.getenv("CAS_RENEW_BEFORE_SECONDS", "300") or 300)
CAS_RENEW_TIMEOUT_SECONDS = int(os.getenv("CAS_RENEW_TIMEOUT_SECONDS", "10") or 10)
CAS_SYNTHETIC_EMAIL_DOMAIN = os.getenv("CAS_SYNTHETIC_EMAIL_DOMAIN", "cas.local")
CAS_SYNTHETIC_EMAIL_DOMAIN = os.getenv("CAS_SYNTHETIC_EMAIL_DOMAIN", "")
CAS_LOGOUT_URL = os.getenv("CAS_LOGOUT_URL", "")
CAS_SSL_VERIFY = os.getenv("CAS_SSL_VERIFY", "true").lower() == "true"
CAS_CA_BUNDLE = os.getenv("CAS_CA_BUNDLE", "")
Expand Down
2 changes: 1 addition & 1 deletion backend/services/cas_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def parse_service_validate_response(xml_text: str, fallback_session_index: str =

if not email:
safe_user = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in cas_user_id)
email = f"{safe_user}@{CAS_SYNTHETIC_EMAIL_DOMAIN}"
email = f"{safe_user}{CAS_SYNTHETIC_EMAIL_DOMAIN}"

return CasPrincipal(
cas_user_id=str(cas_user_id),
Expand Down
2 changes: 1 addition & 1 deletion deploy/env/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ CAS_SESSION_MAX_AGE_SECONDS=3600
LOCAL_SESSION_MAX_AGE_SECONDS=3600
CAS_RENEW_BEFORE_SECONDS=300
CAS_RENEW_TIMEOUT_SECONDS=10
CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local
CAS_SYNTHETIC_EMAIL_DOMAIN=@cas.local
CAS_LOGOUT_URL=/logout
CAS_SSL_VERIFY=true
CAS_CA_BUNDLE=
58 changes: 43 additions & 15 deletions frontend/components/auth/loginModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import { useCallback, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Form, Input, Button, Typography, Space, Divider, Alert } from "antd";
import {
Modal,
Form,
Input,
Button,
Typography,
Space,
Divider,
Alert,
} from "antd";
import { UserRound, LockKeyhole, Github, Link2, KeyRound } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";

Expand All @@ -21,7 +30,9 @@ const providerIconMap: Record<string, React.ReactNode> = {

function OAuthLoginButtons() {
const { t } = useTranslation("common");
const [providers, setProviders] = useState<Array<{ name: string; display_name: string; icon: string }>>([]);
const [providers, setProviders] = useState<
Array<{ name: string; display_name: string; icon: string }>
>([]);

useEffect(() => {
oauthService.getEnabledProviders().then((p) => setProviders(p));
Expand All @@ -41,7 +52,8 @@ function OAuthLoginButtons() {
icon={providerIconMap[provider.icon] || <Link2 size={18} />}
onClick={() => oauthService.startOAuthLogin(provider.name)}
>
{t("auth.oauthLogin", { provider: provider.display_name }) || `${provider.display_name} Login`}
{t("auth.oauthLogin", { provider: provider.display_name }) ||
`${provider.display_name} Login`}
</Button>
))}
</div>
Expand All @@ -54,7 +66,14 @@ function CasLoginButton() {
const [config, setConfig] = useState<CasConfig | null>(null);

useEffect(() => {
casService.getConfig().then(setConfig);
let cancelled = false;
casService.getConfig().then((nextConfig) => {
if (!cancelled) setConfig(nextConfig);
});

return () => {
cancelled = true;
};
}, []);

if (!config?.enabled || config.login_mode !== "button") return null;
Expand All @@ -67,7 +86,8 @@ function CasLoginButton() {
icon={<KeyRound size={18} />}
onClick={() => casService.startLogin()}
>
{t("auth.casLogin", { provider: config.display_name }) || `${config.display_name} Login`}
{t("auth.casLogin", { provider: config.display_name }) ||
`${config.display_name} Login`}
</Button>
</div>
);
Expand Down Expand Up @@ -122,11 +142,18 @@ export function LoginModal() {

useEffect(() => {
if (!isLoginModalOpen || isAuthenticated || isSpeedMode) return;
let cancelled = false;

casService.getConfig().then((config) => {
if (cancelled) return;
if (config.enabled && config.login_mode === "force") {
casService.startLogin();
}
});

return () => {
cancelled = true;
};
}, [isLoginModalOpen, isAuthenticated, isSpeedMode]);

const resetForm = () => {
Expand Down Expand Up @@ -342,22 +369,23 @@ export function LoginModal() {
</Button>
</Form.Item>

<CasLoginButton />
{isLoginModalOpen && !isAuthenticated && !isSpeedMode && (
<CasLoginButton />
)}

{/* OAuth login section */}
<OAuthLoginButtons />

{/* Registration link section (hidden when opened from session expired flow) */}

<div className="text-center">
<Space>
<Text type="secondary">{t("auth.noAccount")}</Text>
<Button type="link" onClick={handleRegisterClick} className="p-0">
{t("auth.registerNow")}
</Button>
</Space>
</div>

<div className="text-center">
<Space>
<Text type="secondary">{t("auth.noAccount")}</Text>
<Button type="link" onClick={handleRegisterClick} className="p-0">
{t("auth.registerNow")}
</Button>
</Space>
</div>
</Form>
</div>
</Modal>
Expand Down
26 changes: 2 additions & 24 deletions frontend/components/navigation/SideNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import { SIDER_CONFIG } from "@/const/layoutConstants";
import { AUTH_EVENTS } from "@/const/auth";
import { getEffectiveRoutePath } from "@/lib/auth";
import { authEvents } from "@/lib/authEvents";
import { authFlowState } from "@/lib/authFlow";
import { casService } from "@/services/casService";

interface SideNavigationProps {
collapsed?: boolean;
Expand Down Expand Up @@ -273,17 +271,7 @@ export function SideNavigation({ collapsed }: SideNavigationProps) {
// Pre-check authentication - show auth prompt if user is not authenticated
if (!isAuthenticated && !isSpeedMode && route.path !== "/") {
setPendingNavigationPath(route.path);
casService.getConfig().then((config) => {
if (
!authFlowState.isExplicitLogoutInProgress() &&
config.enabled &&
config.login_mode === "force"
) {
casService.startLogin(route.path);
return;
}
openAuthPromptModal();
});
openAuthPromptModal(route.path);
return; // Prevent navigation
}

Expand All @@ -309,17 +297,7 @@ export function SideNavigation({ collapsed }: SideNavigationProps) {
setSelectedKey(child.path);
if (!isAuthenticated && !isSpeedMode && child.path !== "/") {
setPendingNavigationPath(child.path);
casService.getConfig().then((config) => {
if (
!authFlowState.isExplicitLogoutInProgress() &&
config.enabled &&
config.login_mode === "force"
) {
casService.startLogin(child.path);
return;
}
openAuthPromptModal();
});
openAuthPromptModal(child.path);
return;
}
router.push(child.path);
Expand Down
15 changes: 9 additions & 6 deletions frontend/hooks/auth/useAuthenticationUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,15 @@ export function useAuthenticationUI({
[]
);

const openAuthPromptModal = useCallback(() => {
if (isSharePage) return;
redirectToCasIfForced(effectivePath).then((redirected) => {
if (!redirected) setIsAuthPromptModalOpen(true);
});
}, [effectivePath, isSharePage, redirectToCasIfForced]);
const openAuthPromptModal = useCallback(
(redirect?: string) => {
if (isSharePage) return;
redirectToCasIfForced(redirect || effectivePath).then((redirected) => {
if (!redirected) setIsAuthPromptModalOpen(true);
});
},
[effectivePath, isSharePage, redirectToCasIfForced]
);

const closeAuthPromptModal = useCallback(() => {
setIsAuthPromptModalOpen(false);
Expand Down
64 changes: 53 additions & 11 deletions frontend/services/casService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
display_name: string;
}

interface GetCasConfigOptions {
forceRefresh?: boolean;
}

const disabledConfig: CasConfig = {
enabled: false,
login_mode: "disabled",
Expand All @@ -17,21 +21,56 @@
display_name: "CAS",
};

const SUCCESS_CACHE_TTL_MS = 5 * 60 * 1000;
const FAILURE_CACHE_TTL_MS = 30 * 1000;

let cachedConfig: CasConfig | null = null;
let cacheExpiresAt = 0;
let inFlightConfigPromise: Promise<CasConfig> | null = null;

const cacheConfig = (config: CasConfig, ttlMs: number) => {
cachedConfig = config;
cacheExpiresAt = Date.now() + ttlMs;
};

export const casService = {
getConfig: async (): Promise<CasConfig> => {
try {
const response = await fetch(API_ENDPOINTS.cas.config);
if (!response.ok) return disabledConfig;
const data = await response.json();
return { ...disabledConfig, ...(data.data || {}) };
} catch (error) {
log.warn("Failed to fetch CAS config:", error);
return disabledConfig;
getConfig: async (options: GetCasConfigOptions = {}): Promise<CasConfig> => {
const now = Date.now();
if (!options.forceRefresh && cachedConfig && cacheExpiresAt > now) {
return cachedConfig;
}

if (inFlightConfigPromise) {
return inFlightConfigPromise;
}

inFlightConfigPromise = (async () => {
try {
const response = await fetch(API_ENDPOINTS.cas.config);
if (!response.ok) {
cacheConfig(disabledConfig, FAILURE_CACHE_TTL_MS);
return disabledConfig;
}

const data = await response.json();
const config = { ...disabledConfig, ...(data.data || {}) };

Check warning on line 56 in frontend/services/casService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The empty object is useless.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ8HOaQdHYiRC2s7O97h&open=AZ8HOaQdHYiRC2s7O97h&pullRequest=3315
cacheConfig(config, SUCCESS_CACHE_TTL_MS);
return config;
} catch (error) {
log.warn("Failed to fetch CAS config:", error);
cacheConfig(disabledConfig, FAILURE_CACHE_TTL_MS);
return disabledConfig;
} finally {
inFlightConfigPromise = null;
}
})();

return inFlightConfigPromise;
},

startLogin: (redirect?: string): void => {
const target = redirect || window.location.pathname + window.location.search;
const target =
redirect || window.location.pathname + window.location.search;

Check warning on line 73 in frontend/services/casService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ8HOaQdHYiRC2s7O97j&open=AZ8HOaQdHYiRC2s7O97j&pullRequest=3315

Check warning on line 73 in frontend/services/casService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ8HOaQdHYiRC2s7O97i&open=AZ8HOaQdHYiRC2s7O97i&pullRequest=3315
window.location.href = `${API_ENDPOINTS.cas.login}?redirect=${encodeURIComponent(target)}`;
},

Expand Down Expand Up @@ -63,7 +102,10 @@

window.addEventListener("message", onMessage);
document.body.appendChild(iframe);
window.setTimeout(() => finish(false), Math.max(1, timeoutSeconds) * 1000);
window.setTimeout(

Check warning on line 105 in frontend/services/casService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ8HOaQdHYiRC2s7O97k&open=AZ8HOaQdHYiRC2s7O97k&pullRequest=3315
() => finish(false),
Math.max(1, timeoutSeconds) * 1000
);
});
},
};
4 changes: 2 additions & 2 deletions frontend/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export interface AuthenticationContextType {

// Auth prompt modal (for side navigation pre-check)
isAuthPromptModalOpen: boolean;
openAuthPromptModal: () => void;
openAuthPromptModal: (redirect?: string) => void;
closeAuthPromptModal: () => void;

// Session expired modal
Expand Down Expand Up @@ -199,7 +199,7 @@ export interface AuthenticationUIReturn {

// Auth prompt modal (for side navigation pre-check)
isAuthPromptModalOpen: boolean;
openAuthPromptModal: () => void;
openAuthPromptModal: (redirect?: string) => void;
closeAuthPromptModal: () => void;

// Session expired modal
Expand Down
Loading