diff --git a/auth/package.json b/auth/package.json new file mode 100644 index 0000000..4a14219 --- /dev/null +++ b/auth/package.json @@ -0,0 +1,11 @@ +{ + "name": "@local/auth", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + } +} diff --git a/src/blog/components/Login.tsx b/auth/src/AuthPage.tsx similarity index 65% rename from src/blog/components/Login.tsx rename to auth/src/AuthPage.tsx index 6bba4cf..c426240 100644 --- a/src/blog/components/Login.tsx +++ b/auth/src/AuthPage.tsx @@ -1,26 +1,49 @@ import * as React from 'react'; import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; -import { useAuth } from '../providers/Author'; -import { LoginProps } from '../types/props'; -export default function Login({ +export type AuthMode = "login" | "register"; + +export interface AuthPageProps { + mode: AuthMode; + onBack(): void; + onSwitchMode(): void; + login(username: string, password: string): Promise; + register(username: string, password: string): Promise; + loading: boolean; + error: string | null; + currentUser: any; +} + +export function AuthPage({ + mode, onBack, - onRegister -}: LoginProps) { - const { login, loading, error, currentUser } = useAuth(); + onSwitchMode, + login, + register, + loading, + error, + currentUser, +}: AuthPageProps) { + const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); + const isLogin = mode === "login"; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - await login(username, password); + if (isLogin) { + await login(username, password); + } else { + await register(username, password); + } }; // ✅ Auto-return if already logged in React.useEffect(() => { if (currentUser) onBack(); - }, [currentUser]); + }, [currentUser, onBack]); return ( - Sign In + {isLogin ? "Sign In" : "Create Account"} - Please log in to continue + {isLogin + ? "Please log in to continue" + : "Create an account to get started"}
@@ -55,6 +80,7 @@ export default function Login({ value={username} onChange={(e) => setUsername(e.target.value)} required + autoFocus /> - {loading ? : 'Login'} + {loading ? ( + + ) : isLogin ? ( + "Login" + ) : ( + "Register" + )} @@ -90,15 +122,15 @@ export default function Login({ align="center" sx={{ mt: 3 }} > - Don’t have an account?{' '} + {isLogin ? "Don’t have an account?" : "Already have an account?"}{' '} - Register + {isLogin ? "Register" : "Login"}
diff --git a/auth/src/authClient.ts b/auth/src/authClient.ts new file mode 100644 index 0000000..bfcc0e4 --- /dev/null +++ b/auth/src/authClient.ts @@ -0,0 +1,32 @@ +import { createApiClient } from "./axios"; +import { tokenStore } from "./token"; + +// @ts-ignore +const authApi = createApiClient(import.meta.env.VITE_AUTH_BASE_URL); + +export const authClient = { + async login(username: string, password: string) { + const res = await authApi.post("/login", { username, password }); + const { access_token } = res.data; + + if (!access_token) { + throw new Error("No access token returned"); + } + + tokenStore.set(access_token); + return this.getIdentity(); + }, + + logout() { + tokenStore.clear(); + }, + + async getIdentity() { + const res = await authApi.get("/me"); + return res.data; + }, + + isAuthenticated() { + return !!tokenStore.get(); + }, +}; diff --git a/auth/src/axios.ts b/auth/src/axios.ts new file mode 100644 index 0000000..f47154b --- /dev/null +++ b/auth/src/axios.ts @@ -0,0 +1,35 @@ +import axios, { AxiosInstance } from "axios"; +import { tokenStore } from "./token"; + +export function attachAuthInterceptors(client: AxiosInstance) { + client.interceptors.request.use((config) => { + const token = tokenStore.get(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + client.interceptors.response.use( + (res) => res, + (error) => { + if (error.response?.status === 401) { + tokenStore.clear(); + } + return Promise.reject(error); + } + ); +} + +/** + * Factory for app APIs that need auth + */ +export function createApiClient(baseURL: string): AxiosInstance { + const client = axios.create({ + baseURL, + headers: { "Content-Type": "application/json" }, + }); + + attachAuthInterceptors(client); + return client; +} diff --git a/auth/src/contexts.tsx b/auth/src/contexts.tsx new file mode 100644 index 0000000..d2745c9 --- /dev/null +++ b/auth/src/contexts.tsx @@ -0,0 +1,97 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { tokenStore } from "./token"; +import { createApiClient } from "./axios"; +import { AuthUser } from "./models"; + +interface AuthContextModel { + currentUser: AuthUser | null; + token: string | null; + loading: boolean; + error: string | null; + login(username: string, password: string): Promise; + register(username: string, password: string): Promise; + logout(): void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ + children, + authBaseUrl, +}: { + children: React.ReactNode; + authBaseUrl: string; +}) { + const [currentUser, setCurrentUser] = useState(null); + const [token, setToken] = useState(tokenStore.get()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const auth = createApiClient(authBaseUrl); + + const login = async (username: string, password: string) => { + try { + setLoading(true); + setError(null); + + const res = await auth.post("/login", { username, password }); + const { access_token, user } = res.data; + + tokenStore.set(access_token); + setToken(access_token); + setCurrentUser(user); + } catch (e: any) { + setError(e.response?.data?.detail ?? "Login failed"); + } finally { + setLoading(false); + } + }; + + const register = async (username: string, password: string) => { + try { + setLoading(true); + setError(null); + + await auth.post("/register", { username, password }); + await login(username, password); + } catch (e: any) { + setError(e.response?.data?.detail ?? "Registration failed"); + } finally { + setLoading(false); + } + }; + + const logout = () => { + tokenStore.clear(); + setToken(null); + setCurrentUser(null); + }; + + const fetchCurrentUser = async () => { + if (!token) return; + try { + const me = await auth.get("/me"); + setCurrentUser({ ...me.data }); + } catch { + logout(); + } + }; + + useEffect(() => { + fetchCurrentUser(); + }, [token]); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextModel { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); + return ctx; +} diff --git a/auth/src/index.ts b/auth/src/index.ts new file mode 100644 index 0000000..3fe14d6 --- /dev/null +++ b/auth/src/index.ts @@ -0,0 +1,6 @@ +export { AuthProvider, useAuth } from "./contexts"; +export { createApiClient } from "./axios"; +export { AuthPage } from "./AuthPage"; +export type { AuthUser } from "./models"; +export type { AuthMode } from "./AuthPage"; +export { tokenStore } from "./token" diff --git a/auth/src/models.ts b/auth/src/models.ts new file mode 100644 index 0000000..fa1c352 --- /dev/null +++ b/auth/src/models.ts @@ -0,0 +1,11 @@ +export interface AuthUser { + // meta fields + _id?: string | null; + created_at: string; + updated_at: string; + + // model fields + username: string; + email: string; + is_active: boolean; +} diff --git a/auth/src/props.ts b/auth/src/props.ts new file mode 100644 index 0000000..e69de29 diff --git a/auth/src/token.ts b/auth/src/token.ts new file mode 100644 index 0000000..3f5f607 --- /dev/null +++ b/auth/src/token.ts @@ -0,0 +1,15 @@ +const TOKEN_KEY = "token"; + +export const tokenStore = { + get(): string | null { + return localStorage.getItem(TOKEN_KEY); + }, + + set(token: string) { + localStorage.setItem(TOKEN_KEY, token); + }, + + clear() { + localStorage.removeItem(TOKEN_KEY); + }, +}; diff --git a/src/blog/Blog.tsx b/src/blog/Blog.tsx index 7b14258..f0146d4 100644 --- a/src/blog/Blog.tsx +++ b/src/blog/Blog.tsx @@ -10,34 +10,36 @@ import ArticleView from './components/Article/ArticleView'; import ArticleEditor from './components/Article/ArticleEditor'; import Latest from './components/Latest'; import Footer from './components/Footer'; -import Login from './components/Login'; -import Register from './components/Register'; import Profile from './components/Profile'; import { useArticles } from './providers/Article'; -import { useAuth } from './providers/Author'; +import { useAuth as useAuthor } from './providers/Author'; import { View, useViewRouter } from "./types/views"; import { ArticleModel, ArticlesModel } from "./types/models"; import { ArticleViewProps, ArticleEditorProps } from "./types/props"; +import { useAuth, AuthPage, AuthMode } from '../../auth/src'; + function HomeView({ currentUser, - open_login, + open_auth, open_profile, open_create, articles, - openArticle + openArticle, }: any) { return ( <> - + {!currentUser ? ( - + ) : ( <> - - @@ -52,11 +54,13 @@ function HomeView({ export default function Blog(props: { disableCustomTheme?: boolean }) { const { articles, loading, error } = useArticles(); - const { currentUser } = useAuth(); + const auth = useAuth(); + const { currentUser } = useAuthor(); const [ui, setUI] = React.useState({ selectedArticle: null as ArticleModel | null, - view: "home" as View, + view: 'home' as View, + authMode: 'login' as AuthMode, }); useEffect(() => { @@ -73,6 +77,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { setUI({ selectedArticle: article, view: 'article', + authMode: 'login' }); } } @@ -85,7 +90,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { } = useViewRouter(setUI); type RouterContext = { - ui: any; + ui: typeof ui; articles: ArticlesModel; currentUser: any; openArticle: (article: ArticleModel) => void; @@ -97,20 +102,27 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { navigationMap?: Record; }; + // @ts-ignore const VIEW_COMPONENTS: Record> = { home: { component: HomeView, }, - login: { - component: Login, - navigationMap: { - open_register: 'onRegister', - }, - }, - - register: { - component: Register, + auth: { + component: AuthPage, + extraProps: ({ ui }) => ({ + mode: ui.authMode, + onSwitchMode: () => + setUI((prev) => ({ + ...prev, + authMode: prev.authMode === 'login' ? 'register' : 'login', + })), + login: auth.login, + register: auth.register, + loading: auth.loading, + error: auth.error, + currentUser: currentUser, + }), }, profile: { @@ -123,6 +135,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { open_editor: 'onEdit', }, extraProps: ({ ui, articles }) => ({ + // @ts-ignore article: articles.readById(ui.selectedArticle._id), }) satisfies Partial, }, @@ -130,7 +143,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { editor: { component: ArticleEditor, extraProps: ({ ui, articles }) => ({ - article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id) : null, + article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id as string) : null, }) satisfies Partial, }, @@ -147,10 +160,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { const navigationMap= entry['navigationMap'] || {} const ViewComponent = entry.component; - const childNav = navigateToChildren( - ui.view, - navigationMap - ); + const childNav = { + ...navigateToChildren(ui.view, navigationMap), + open_auth: (mode: AuthMode = 'login') => + setUI((prev) => ({ + ...prev, + view: 'auth', + authMode: mode, + })), + }; const ctx: RouterContext = { ui, @@ -234,7 +252,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { {ui.view === 'home' && ( (null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLocalError(null); - - // ✅ Local validation - if (password1 !== password2) { - setLocalError("Passwords don't match"); - return; - } - - if (password1.length < 6) { - setLocalError('Password must be at least 6 characters long'); - return; - } - - // ✅ Call backend - await register(username, password1); - }; - - if (currentUser) { - // ✅ if logged in, auto-return to the article list - onBack(); - return null; - } - - return ( - - - - - - - Sign Up - - - - Please sign up to continue - - -
- setUsername(e.target.value)} - required - /> - setPassword1(e.target.value)} - required - /> - setPassword2(e.target.value)} - required - /> - - {(localError || error) && ( - - {localError || error} - - )} - - - -
- ); -} diff --git a/src/blog/providers/Article.tsx b/src/blog/providers/Article.tsx index 6e28585..222e3e5 100644 --- a/src/blog/providers/Article.tsx +++ b/src/blog/providers/Article.tsx @@ -1,12 +1,12 @@ -import React, { createContext, useState, useContext, useEffect } from 'react'; -import { api } from '../utils/api'; +import React, { createContext, useState, useContext, useEffect } from "react"; +import { api } from "../utils/api"; import { ArticleModel, ArticlesModel, createArticlesModelObject, -} from '../types/models'; -import { ArticleContextModel } from '../types/contexts'; -import { useAuth } from './Author'; +} from "../types/models"; +import { ArticleContextModel } from "../types/contexts"; +import { useAuth } from "./Author"; const ArticleContext = createContext(undefined); @@ -14,7 +14,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child const [articles, setArticles] = useState(createArticlesModelObject()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { token, currentUser } = useAuth(); + const { currentUser } = useAuth(); /** 🔹 Author IDs must be strings for API, so we normalize here */ const normalizeArticleForApi = (article: Partial) => { @@ -109,14 +109,8 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child /** 🔹 Auto-fetch articles whenever user logs in/out */ useEffect(() => { - // Always load once on mount - // If endpoint requires JWT, fallback safely - if (!token) { - fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected) - } else { - fetchArticles(); - } - }, [token]); + fetchArticles(); + }, [currentUser]); // refetch on login / logout return ( (undefined); export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { currentUser: authUser, logout } = useBaseAuth(); const [currentUser, setCurrentUser] = useState(null); const [authors, setAuthors] = useState([]); - const [token, setToken] = useState(localStorage.getItem('token')); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - /** 🔹 Register new user */ - const register = async (username: string, password: string) => { + /** + * Hydrate application-level currentUser + */ + const hydrateCurrentUser = async () => { + if (!authUser) return; + try { setLoading(true); setError(null); - const res = await auth.post('/register', { username, password }); + const res = await api.get("/authors/me"); - // auto-login - // await login(username, password); + /** + * Explicit precedence: + * Auth service is source of truth for inherited fields + */ + const fullUser: AuthorModel = { + ...res.data, + username: authUser.username, + email: authUser.email, + is_active: authUser.is_active, + }; - // now create author - await api.post('/authors', { name: null, avatar: null }); - - return res.data; - } catch (err: any) { - console.error('Registration failed:', err); - setError(err.response?.data?.detail || 'Registration failed'); + setCurrentUser(fullUser); + } catch (err) { + console.error("Failed to hydrate current user:", err); + logout(); } finally { setLoading(false); } }; - /** 🔹 Login and store JWT token */ - const login = async (username: string, password: string) => { - try { - setLoading(true); - setError(null); - - const res = await auth.post('/login', { username, password }); - const { access_token, user } = res.data; - - if (access_token) { - localStorage.setItem('token', access_token); - setToken(access_token); - setCurrentUser(user); - } - } catch (err: any) { - console.error('Login failed:', err); - setError(err.response?.data?.detail || 'Invalid credentials'); - } finally { - setLoading(false); - } - }; - - /** 🔹 Logout and clear everything */ - const logout = () => { - localStorage.removeItem('token'); - setToken(null); - setCurrentUser(null); - setAuthors([]); - }; - /** 🔹 Fetch all authors (JWT handled by api interceptor) */ const refreshAuthors = async () => { try { @@ -102,39 +82,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }; - /** 🔹 Auto-load current user if token exists */ - const fetchCurrentUser = async () => { - if (!token) return; - try { - const me = await auth.get('/me'); - - const author = await api.get(`/authors/me`); - - const fullUser = { ...me.data, ...author.data }; - - setCurrentUser(fullUser); - } catch (err) { - console.error('Failed to fetch current user:', err); - logout(); - } - }; - - /** 🔹 On mount, try to fetch user if token exists */ + /** + * React strictly to auth lifecycle + */ useEffect(() => { - if (token) fetchCurrentUser(); - }, [token]); + if (authUser) { + hydrateCurrentUser(); + } else { + setCurrentUser(null); + setAuthors([]); + setError(null); + } + }, [authUser]); return ( Promise; - register: (username: string, password: string) => Promise; - logout: () => void; + refreshAuthors: () => Promise; updateProfile: (user: AuthorModel) => Promise; } diff --git a/src/blog/types/models.ts b/src/blog/types/models.ts index f0413a2..4de53bf 100644 --- a/src/blog/types/models.ts +++ b/src/blog/types/models.ts @@ -2,20 +2,18 @@ import { createInList, readInList, updateInList, deleteInList, createById, readById, updateById, deleteById } from "../utils/articles"; +import { AuthUser } from "../../../auth/src"; -export interface AuthorModel { +export interface AuthorModel extends AuthUser { // meta fields _id?: string | null; created_at: string; updated_at: string; // model fields - username: string; name: string; - email: string; avatar: string; - is_active: boolean; } export interface ArticleModel { diff --git a/src/blog/types/props.ts b/src/blog/types/props.ts index 4432ea4..0772a44 100644 --- a/src/blog/types/props.ts +++ b/src/blog/types/props.ts @@ -9,11 +9,6 @@ export interface LatestProps { onLoadMore?: (offset: number, limit: number) => Promise; // optional async callback } -export interface LoginProps { - onBack: () => void; - onRegister: () => void; -} - export interface MainContentProps { articles: ArticlesModel; onSelectArticle: (index: ArticleModel) => void; @@ -23,10 +18,6 @@ export interface ProfileProps { onBack: () => void; } -export interface RegisterProps { - onBack: () => void; -} - export interface ArticleViewProps { article: ArticleModel; onBack: () => void; diff --git a/src/blog/types/views.ts b/src/blog/types/views.ts index cb0d394..d2c17d3 100644 --- a/src/blog/types/views.ts +++ b/src/blog/types/views.ts @@ -1,9 +1,8 @@ -import {ArticleModel} from "./models"; +import { ArticleModel } from "./models"; export type View = | "home" - | "login" - | "register" + | "auth" | "article" | "editor" | "profile" @@ -17,25 +16,26 @@ export type ViewNode = { export const VIEW_TREE: Record = { home: { parent: null, - children: ["login", "article", "profile", "create"], + children: ["auth", "article", "profile", "create"], }, - login: { + + auth: { parent: "home", - children: ["register"], - }, - register: { - parent: "login", }, + article: { parent: "home", children: ["editor"], }, + editor: { parent: "article", }, + profile: { parent: "home", }, + create: { parent: "home", }, @@ -43,11 +43,15 @@ export const VIEW_TREE: Record = { export const VIEW_URL: Record string> = { home: () => "/", - login: () => "/login", - register: () => "/register", + + auth: () => "/auth", + profile: () => "/profile", + create: () => "/create", + article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`, + editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`, }; diff --git a/src/blog/utils/api.ts b/src/blog/utils/api.ts index e6d29f4..4faf7b9 100644 --- a/src/blog/utils/api.ts +++ b/src/blog/utils/api.ts @@ -1,53 +1,19 @@ -// src/utils/api.ts -import axios from 'axios'; +import { createApiClient } from "../../../auth/src"; const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; const API_BASE = import.meta.env.VITE_API_BASE_URL; -//------------------------------------------------------ -// COMMON TOKEN ATTACHMENT LOGIC -//------------------------------------------------------ -const attachToken = (config: any) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}; +/** + * Auth service client + * - login + * - register + * - me + * - logout + * - introspect + */ +export const auth = createApiClient(AUTH_BASE); -const handleAuthError = (error: any) => { - if (error.response?.status === 401) { - console.warn('Token expired or invalid. Logging out...'); - localStorage.removeItem('token'); - // Optional: eventBus, redirect, logout callback - } - return Promise.reject(error); -}; - -//------------------------------------------------------ -// AUTH SERVICE CLIENT -//------------------------------------------------------ -export const auth = axios.create({ - baseURL: AUTH_BASE, - headers: { - 'Content-Type': 'application/json', - }, -}); - -//------------------------------------------------------ -// BLOG SERVICE CLIENT -//------------------------------------------------------ -export const api = axios.create({ - baseURL: API_BASE, - headers: { - 'Content-Type': 'application/json', - }, -}); - -// Attach token + 401 handling -api.interceptors.request.use(attachToken); -api.interceptors.response.use((res) => res, handleAuthError); - -// Auth service ALSO needs token for /me, /logout, /introspect -auth.interceptors.request.use(attachToken); -auth.interceptors.response.use((res) => res, handleAuthError); +/** + * Main application API (blog, articles, etc.) + */ +export const api = createApiClient(API_BASE); diff --git a/src/main.jsx b/src/main.jsx index 1becdc0..33e40a8 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,18 +2,22 @@ import * as React from 'react'; import { createRoot } from 'react-dom/client'; import Blog from './blog/Blog'; import { ArticleProvider } from './blog/providers/Article'; -import { AuthProvider } from './blog/providers/Author'; +import { AuthProvider as AuthorProvider } from './blog/providers/Author'; import { UploadProvider } from "./blog/providers/Upload"; +import { AuthProvider } from "../auth/src"; const rootElement = document.getElementById('root'); const root = createRoot(rootElement); +const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; root.render( - - - - + + + + + + - , + );