diff --git a/k-notes-frontend/public/locales/de/translation.json b/k-notes-frontend/public/locales/de/translation.json index 4e3f97c..b4a0659 100644 --- a/k-notes-frontend/public/locales/de/translation.json +++ b/k-notes-frontend/public/locales/de/translation.json @@ -92,5 +92,8 @@ "Update": "Aktualisieren", "Welcome back": "Willkommen zurück", "work, todo, ideas": "Arbeit, Aufgaben, Ideen", - "Your notes will appear here. Click + to create one.": "Deine Notizen werden hier erscheinen. Klicke +, um eine zu erstellen." + "Your notes will appear here. Click + to create one.": "Deine Notizen werden hier erscheinen. Klicke +, um eine zu erstellen.", + "Sign in with SSO": "Mit SSO anmelden", + "Or continue with": "Oder fortfahren mit", + "Completing sign in...": "Anmeldung wird abgeschlossen..." } \ No newline at end of file diff --git a/k-notes-frontend/public/locales/en/translation.json b/k-notes-frontend/public/locales/en/translation.json index 203c5f4..acd4f40 100644 --- a/k-notes-frontend/public/locales/en/translation.json +++ b/k-notes-frontend/public/locales/en/translation.json @@ -92,5 +92,8 @@ "Update": "Update", "Welcome back": "Welcome back", "work, todo, ideas": "work, todo, ideas", - "Your notes will appear here. Click + to create one.": "Your notes will appear here. Click + to create one." + "Your notes will appear here. Click + to create one.": "Your notes will appear here. Click + to create one.", + "Sign in with SSO": "Sign in with SSO", + "Or continue with": "Or continue with", + "Completing sign in...": "Completing sign in..." } \ No newline at end of file diff --git a/k-notes-frontend/public/locales/es/translation.json b/k-notes-frontend/public/locales/es/translation.json index 804f3af..84891c0 100644 --- a/k-notes-frontend/public/locales/es/translation.json +++ b/k-notes-frontend/public/locales/es/translation.json @@ -96,5 +96,8 @@ "Update": "Actualizar", "Welcome back": "Bienvenido de nuevo", "work, todo, ideas": "trabajo, tareas, ideas", - "Your notes will appear here. Click + to create one.": "Tus notas aparecerán aquí. Haz clic en + para crear una." + "Your notes will appear here. Click + to create one.": "Tus notas aparecerán aquí. Haz clic en + para crear una.", + "Sign in with SSO": "Iniciar sesión con SSO", + "Or continue with": "O continuar con", + "Completing sign in...": "Completando inicio de sesión..." } \ No newline at end of file diff --git a/k-notes-frontend/public/locales/fr/translation.json b/k-notes-frontend/public/locales/fr/translation.json index d0d6ff7..812bcde 100644 --- a/k-notes-frontend/public/locales/fr/translation.json +++ b/k-notes-frontend/public/locales/fr/translation.json @@ -96,5 +96,8 @@ "Update": "Mettre à jour", "Welcome back": "Bon retour", "work, todo, ideas": "travail, tâches, idées", - "Your notes will appear here. Click + to create one.": "Tes notes apparaîtront ici. Clique sur + pour en créer une." + "Your notes will appear here. Click + to create one.": "Tes notes apparaîtront ici. Clique sur + pour en créer une.", + "Sign in with SSO": "Se connecter avec SSO", + "Or continue with": "Ou continuer avec", + "Completing sign in...": "Connexion en cours..." } \ No newline at end of file diff --git a/k-notes-frontend/public/locales/pl/translation.json b/k-notes-frontend/public/locales/pl/translation.json index 68e0955..7040651 100644 --- a/k-notes-frontend/public/locales/pl/translation.json +++ b/k-notes-frontend/public/locales/pl/translation.json @@ -100,5 +100,8 @@ "Update": "Aktualizuj", "Welcome back": "Witaj ponownie", "work, todo, ideas": "praca, zadania, pomysły", - "Your notes will appear here. Click + to create one.": "Twoje notatki pojawią się tutaj. Kliknij +, aby utworzyć notatkę." + "Your notes will appear here. Click + to create one.": "Twoje notatki pojawią się tutaj. Kliknij +, aby utworzyć notatkę.", + "Sign in with SSO": "Zaloguj się przez SSO", + "Or continue with": "Lub kontynuuj przez", + "Completing sign in...": "Kończenie logowania..." } \ No newline at end of file diff --git a/k-notes-frontend/src/App.tsx b/k-notes-frontend/src/App.tsx index e95bc14..d2010a7 100644 --- a/k-notes-frontend/src/App.tsx +++ b/k-notes-frontend/src/App.tsx @@ -5,6 +5,7 @@ import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; import DashboardPage from "@/pages/dashboard"; import PrivacyPolicyPage from "@/pages/privacy-policy"; +import OidcCallbackPage from "@/pages/oidc-callback"; import Layout from "@/components/layout"; import { useSync } from "@/lib/sync"; import { useMobileStatusBar } from "@/hooks/use-mobile-status-bar"; @@ -17,6 +18,7 @@ function App() { {/* Public Routes (accessible to everyone) */} } /> + } /> {/* Public Routes (only accessible if NOT logged in) */} }> @@ -40,3 +42,4 @@ function App() { } export default App; + diff --git a/k-notes-frontend/src/hooks/use-auth.ts b/k-notes-frontend/src/hooks/use-auth.ts index 82d0145..5a867c5 100644 --- a/k-notes-frontend/src/hooks/use-auth.ts +++ b/k-notes-frontend/src/hooks/use-auth.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api } from "@/lib/api"; +import { api, setAuthToken, clearAuthToken, getBaseUrl } from "@/lib/api"; import { useNavigate } from "react-router-dom"; export interface User { @@ -8,6 +8,20 @@ export interface User { created_at: string; } +// Token response from JWT/OIDC login +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +// Login can return either User (session mode) or Token (JWT mode) +export type LoginResult = User | TokenResponse; + +function isTokenResponse(result: LoginResult): result is TokenResponse { + return 'access_token' in result; +} + // Fetch current user async function fetchUser(): Promise { try { @@ -35,8 +49,13 @@ export function useLogin() { const navigate = useNavigate(); return useMutation({ - mutationFn: (credentials: any) => api.post("/auth/login", credentials), - onSuccess: () => { + mutationFn: (credentials: { email: string; password: string }): Promise => + api.post("/auth/login", credentials), + onSuccess: (result: LoginResult) => { + // If we got a token response, store the token + if (isTokenResponse(result)) { + setAuthToken(result.access_token); + } queryClient.invalidateQueries({ queryKey: ["user"] }); navigate("/"); }, @@ -48,8 +67,13 @@ export function useRegister() { const navigate = useNavigate(); return useMutation({ - mutationFn: (credentials: any) => api.post("/auth/register", credentials), - onSuccess: () => { + mutationFn: (credentials: { email: string; password: string }): Promise => + api.post("/auth/register", credentials), + onSuccess: (result: LoginResult) => { + // If we got a token response, store the token + if (isTokenResponse(result)) { + setAuthToken(result.access_token); + } queryClient.invalidateQueries({ queryKey: ["user"] }); navigate("/"); }, @@ -63,8 +87,25 @@ export function useLogout() { return useMutation({ mutationFn: () => api.post("/auth/logout", {}), onSuccess: () => { + // Clear both session data and JWT token + clearAuthToken(); + queryClient.setQueryData(["user"], null); + navigate("/login"); + }, + onError: () => { + // Even on error, clear local state + clearAuthToken(); queryClient.setQueryData(["user"], null); navigate("/login"); }, }); } + +// Hook to initiate OIDC login flow +export function useOidcLogin() { + return () => { + // Redirect to OIDC login endpoint + window.location.href = `${getBaseUrl()}/api/v1/auth/login/oidc`; + }; +} + diff --git a/k-notes-frontend/src/hooks/useConfig.ts b/k-notes-frontend/src/hooks/useConfig.ts index 91f9305..7265611 100644 --- a/k-notes-frontend/src/hooks/useConfig.ts +++ b/k-notes-frontend/src/hooks/useConfig.ts @@ -2,8 +2,13 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; +export type AuthMode = 'session' | 'jwt' | 'both'; + export interface ConfigResponse { allow_registration: boolean; + auth_mode: AuthMode; + oidc_enabled: boolean; + password_login_enabled: boolean; } export function useConfig() { @@ -13,3 +18,4 @@ export function useConfig() { staleTime: Infinity, // Config rarely changes }); } + diff --git a/k-notes-frontend/src/lib/api.ts b/k-notes-frontend/src/lib/api.ts index 7191d3c..858db52 100644 --- a/k-notes-frontend/src/lib/api.ts +++ b/k-notes-frontend/src/lib/api.ts @@ -6,6 +6,21 @@ declare global { } } +const TOKEN_STORAGE_KEY = 'k_notes_auth_token'; + +// JWT Token management +export function setAuthToken(token: string): void { + localStorage.setItem(TOKEN_STORAGE_KEY, token); +} + +export function getAuthToken(): string | null { + return localStorage.getItem(TOKEN_STORAGE_KEY); +} + +export function clearAuthToken(): void { + localStorage.removeItem(TOKEN_STORAGE_KEY); +} + const getApiUrl = () => { // 1. Runtime config (Docker) if (window.env?.API_URL) { @@ -40,17 +55,22 @@ export class ApiError extends Error { async function fetchWithAuth(endpoint: string, options: RequestInit = {}) { const url = `${getApiUrl()}${endpoint}`; + const token = getAuthToken(); - const headers = { + const headers: Record = { "Content-Type": "application/json", - ...options.headers, + ...(options.headers as Record || {}), }; + // Add Authorization header if we have a JWT token + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const config: RequestInit = { ...options, headers, - credentials: "include", // Important for cookies! - // signal: controller.signal, // Removing signal, using race instead + credentials: "include", // Still include for session-based auth }; try { @@ -60,8 +80,6 @@ async function fetchWithAuth(endpoint: string, options: RequestInit = {}) { ); const response = (await Promise.race([fetchPromise, timeoutPromise])) as Response; - // clearTimeout(timeoutId); // Not needed with race logic here (though leaking timer? No, race settles.) - if (!response.ok) { // Try to parse error message @@ -109,11 +127,18 @@ export const api = { }), delete: (endpoint: string) => fetchWithAuth(endpoint, { method: "DELETE" }), exportData: async () => { + const token = getAuthToken(); + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } const response = await fetch(`${getApiUrl()}/export`, { credentials: "include", + headers, }); if (!response.ok) throw new ApiError(response.status, "Failed to export data"); return response.blob(); }, importData: (data: any) => api.post("/import", data), }; + diff --git a/k-notes-frontend/src/pages/login.tsx b/k-notes-frontend/src/pages/login.tsx index acfa012..926313f 100644 --- a/k-notes-frontend/src/pages/login.tsx +++ b/k-notes-frontend/src/pages/login.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; -import { Settings } from "lucide-react"; +import { Settings, ExternalLink } from "lucide-react"; import { SettingsDialog } from "@/components/settings-dialog"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Link } from "react-router-dom"; -import { useLogin } from "@/hooks/use-auth"; +import { useLogin, useOidcLogin } from "@/hooks/use-auth"; import { useConfig } from "@/hooks/useConfig"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -26,6 +26,7 @@ export default function LoginPage() { const { mutate: login, isPending } = useLogin(); const { data: config } = useConfig(); const { t } = useTranslation(); + const startOidcLogin = useOidcLogin(); const form = useForm({ resolver: zodResolver(loginSchema), @@ -63,40 +64,71 @@ export default function LoginPage() { {t("Enter your email to sign in to your account")} - -
- - ( - - {t("Email")} - - - - - - )} - /> - ( - - {t("Password")} - - - - - - )} - /> - - - + {/* Divider only if both OIDC and password login are enabled */} + {config?.password_login_enabled && ( +
+
+ +
+
+ + {t("Or continue with")} + +
+
+ )} + + )} + + {/* Email/Password Form - only show if password login is enabled */} + {config?.password_login_enabled !== false && ( +
+ + ( + + {t("Email")} + + + + + + )} + /> + ( + + {t("Password")} + + + + + + )} + /> + + + + )}
{config?.allow_registration !== false && ( @@ -113,3 +145,4 @@ export default function LoginPage() { ); } + diff --git a/k-notes-frontend/src/pages/oidc-callback.tsx b/k-notes-frontend/src/pages/oidc-callback.tsx new file mode 100644 index 0000000..aac735a --- /dev/null +++ b/k-notes-frontend/src/pages/oidc-callback.tsx @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { setAuthToken } from "@/lib/api"; +import { useTranslation } from "react-i18next"; + +/** + * OIDC Callback Handler + * + * This page handles redirects from the OIDC provider after authentication. + * + * In Session mode: The backend sets a session cookie during the callback, + * so we just need to redirect to the dashboard. + * + * In JWT mode: The backend redirects here with a token in the URL fragment + * or query params, which we need to extract and store. + */ +export default function OidcCallbackPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + useEffect(() => { + // Check for token in URL hash (implicit flow) or query params + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + const accessToken = + hashParams.get("access_token") || searchParams.get("access_token"); + + if (accessToken) { + // JWT mode: store the token + setAuthToken(accessToken); + } + + // Invalidate user query to refetch with new auth state + queryClient.invalidateQueries({ queryKey: ["user"] }); + + // Redirect to dashboard + navigate("/", { replace: true }); + }, [navigate, searchParams, queryClient]); + + return ( +
+
+
+

+ {t("Completing sign in...")} +

+
+
+ ); +} diff --git a/k-notes-frontend/src/pages/register.tsx b/k-notes-frontend/src/pages/register.tsx index 1e95b8d..0adb1a4 100644 --- a/k-notes-frontend/src/pages/register.tsx +++ b/k-notes-frontend/src/pages/register.tsx @@ -36,10 +36,14 @@ export default function RegisterPage() { if (!isConfigLoading && config?.allow_registration === false) { toast.error(t("Registration is currently disabled")); navigate("/login"); + } else if (!isConfigLoading && config?.password_login_enabled === false) { + // Registration requires password login to be enabled + toast.error(t("Registration is not available")); + navigate("/login"); } }, [config, isConfigLoading, navigate, t]); - if (isConfigLoading || config?.allow_registration === false) { + if (isConfigLoading || config?.allow_registration === false || config?.password_login_enabled === false) { return null; // Or a loading spinner } diff --git a/notes-api/Cargo.toml b/notes-api/Cargo.toml index 69cfe7a..9fa77d3 100644 --- a/notes-api/Cargo.toml +++ b/notes-api/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" default-run = "notes-api" [features] -default = ["sqlite", "smart-features", "auth-oidc", "auth-jwt"] +default = ["sqlite", "smart-features"] sqlite = ["notes-infra/sqlite"] postgres = ["notes-infra/postgres"] smart-features = ["notes-infra/smart-features", "notes-infra/broker-nats"] diff --git a/notes-api/src/config.rs b/notes-api/src/config.rs index 7d35876..229954e 100644 --- a/notes-api/src/config.rs +++ b/notes-api/src/config.rs @@ -1,10 +1,10 @@ #[cfg(feature = "smart-features")] use notes_infra::factory::{EmbeddingProvider, VectorProvider}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::env; /// Authentication mode - determines how the API authenticates requests -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum AuthMode { /// Session-based authentication using cookies (default for backward compatibility) @@ -66,6 +66,9 @@ pub struct Config { /// Whether the application is running in production mode pub is_production: bool, + + /// Frontend URL for OIDC redirect (defaults to first CORS origin) + pub frontend_url: String, } impl Default for Config { @@ -100,6 +103,7 @@ impl Default for Config { jwt_audience: None, jwt_expiry_hours: 24, is_production: false, + frontend_url: "http://localhost:5173".to_string(), } } } @@ -219,6 +223,8 @@ impl Config { jwt_audience, jwt_expiry_hours, is_production, + frontend_url: env::var("FRONTEND_URL") + .unwrap_or_else(|_| "http://localhost:5173".to_string()), } } } diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs index c424759..88dc5ad 100644 --- a/notes-api/src/dto.rs +++ b/notes-api/src/dto.rs @@ -7,6 +7,8 @@ use validator::Validate; use notes_domain::{Email, Note, Password, Tag}; +use crate::config::AuthMode; + /// Request to create a new note #[derive(Debug, Deserialize, Validate)] pub struct CreateNoteRequest { @@ -165,6 +167,9 @@ impl From for NoteVersionResponse { #[derive(Debug, Serialize)] pub struct ConfigResponse { pub allow_registration: bool, + pub auth_mode: AuthMode, + pub oidc_enabled: bool, + pub password_login_enabled: bool, } /// Note Link response DTO diff --git a/notes-api/src/routes/auth.rs b/notes-api/src/routes/auth.rs index 4cc5344..e355435 100644 --- a/notes-api/src/routes/auth.rs +++ b/notes-api/src/routes/auth.rs @@ -387,25 +387,19 @@ async fn oidc_callback( .await .map_err(|_| ApiError::Internal("Session error".into()))?; - // In JWT mode, return token as JSON + // In JWT mode, redirect to frontend with token in URL fragment #[cfg(feature = "auth-jwt")] if matches!(auth_mode, AuthMode::Jwt | AuthMode::Both) { let token = create_jwt_for_user(&user, &state)?; - return Ok(Json(TokenResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_hours * 3600, - }) - .into_response()); + let redirect_url = format!( + "{}/auth/callback#access_token={}", + state.config.frontend_url, token + ); + return Ok(axum::response::Redirect::to(&redirect_url).into_response()); } - // Session mode: return user info - Ok(Json(UserResponse { - id: user.id, - email: user.email, - created_at: user.created_at, - }) - .into_response()) + // Session mode: redirect to frontend (session cookie already set) + Ok(axum::response::Redirect::to(&state.config.frontend_url).into_response()) } /// Fallback OIDC callback when auth-axum-login is not enabled @@ -470,15 +464,15 @@ async fn oidc_callback( .await .map_err(|_| ApiError::Internal("Session error".into()))?; - // Return token as JSON + // Redirect to frontend with token in URL fragment #[cfg(feature = "auth-jwt")] { let token = create_jwt_for_user(&user, &state)?; - return Ok(Json(TokenResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_hours * 3600, - })); + let redirect_url = format!( + "{}/auth/callback#access_token={}", + state.config.frontend_url, token + ); + return Ok(axum::response::Redirect::to(&redirect_url)); } #[cfg(not(feature = "auth-jwt"))] diff --git a/notes-api/src/routes/config.rs b/notes-api/src/routes/config.rs index 94e14c3..09d1c4b 100644 --- a/notes-api/src/routes/config.rs +++ b/notes-api/src/routes/config.rs @@ -10,5 +10,11 @@ use crate::state::AppState; pub async fn get_config(State(state): State) -> ApiResult> { Ok(Json(ConfigResponse { allow_registration: state.config.allow_registration, + auth_mode: state.config.auth_mode, + #[cfg(feature = "auth-oidc")] + oidc_enabled: state.oidc_service.is_some(), + #[cfg(not(feature = "auth-oidc"))] + oidc_enabled: false, + password_login_enabled: cfg!(feature = "auth-axum-login"), })) }