6 Commits

Author SHA1 Message Date
86101a1b1c cleanup 2025-12-28 19:57:55 +05:30
bb9c411c92 refactor(auth): separate auth and author responsibilities and centralize auth client creation
- Replace manual axios auth client with createApiClient in auth context
- Decouple domain author logic from auth provider
- Make AuthorModel extend AuthUser explicitly
- Route login/register/logout exclusively through auth package
- Derive application-level currentUser from auth identity
- Fix provider hierarchy and hook usage across Blog and Profile
- Align main.jsx to use base AuthProvider + AuthorProvider layering
2025-12-28 19:48:43 +05:30
5f6ae489fa AuthUser as model 2025-12-26 23:16:55 +05:30
2c18c7258b refactor(auth): extract auth into shared package and unify auth flow
- Introduce new @local/auth package with token store, axios helpers, auth client, and AuthPage UI
- Unify login/register into a single AuthPage with mode switching
- Centralize JWT handling via tokenStore and axios interceptors
- Remove direct localStorage token access from blog app
- Replace blog Login/Register views with single auth view
- Update router (View, VIEW_TREE, VIEW_URL) to support unified auth view
- Fix hook usage by lifting useAuth() to top-level and passing via props
- Refactor Blog view navigation to support auth mode routing
- Clean up ArticleProvider to rely on auth state, not tokens
- Align AuthProvider to delegate token management to auth package
- Remove legacy Login/Register components and props
- Normalize API client creation via shared createApiClient
- Improve type safety and state consistency across auth/article flows
2025-12-26 00:56:23 +05:30
14b43cb3c5 hotfix for build args and bumped up version
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-13 19:31:39 +05:30
8f398c35df Auth / Author Flow Hardening and Client Separation (#1)
All checks were successful
continuous-integration/drone/tag Build is passing
# Merge Request: Auth / Author Flow Hardening and Client Separation

## Summary
This change set improves the authentication–author lifecycle by clearly separating **Auth** and **Blog API** clients, ensuring an **Author is created at registration time**, and preventing user-controlled mutation of immutable identity fields in the UI.

The result is a cleaner contract between services, fewer edge cases around missing authors, and more predictable client behavior.

---

## Key Changes

### 1. Username Made Read-Only in Profile UI
- Disabled the `username` field in `Profile.tsx`
- Prevents accidental or malicious mutation of identity-bound fields
- Aligns UI behavior with backend ownership rules

---

### 2. Dedicated Auth vs Blog API Clients
- Introduced a separate Axios client for the Auth service (`auth`)
- Blog service continues to use `api`
- Both clients:
  - Automatically attach JWT tokens
  - Share centralized `401` handling and token invalidation logic

**Why:**
Auth and Blog are separate concerns and potentially separate services. Explicit clients reduce coupling and eliminate ambiguous routing.

---

### 3. Registration Flow Now Creates Author Automatically
- `register()` now:
  1. Registers the user via Auth service
  2. Creates a corresponding Author via Blog API

This guarantees:
- Every authenticated user has an Author record
- No race condition or implicit author creation later

---

### 4. Correct Endpoint Usage for “Current User”
- `/auth/me` is now correctly called via the Auth client
- `/authors/me` replaces ID-based lookup for the current author
- Eliminates dependency on user ID leaking across service boundaries

---

### 5. Centralized Token & Auth Error Handling
- Shared request interceptor to attach JWT tokens
- Shared response interceptor to handle `401` consistently
- Token invalidation is now uniform across services

---

### 6. Environment Configuration Updated
- Added `VITE_AUTH_BASE_URL` to support separate Auth service routing
- Explicit environment contract avoids accidental misconfiguration

---

## Impact
- Cleaner service boundaries
- Deterministic user → author lifecycle
- Reduced client-side complexity and edge cases
- More secure handling of identity fields

---

## Notes / Follow-ups
- Optional auto-login after registration is scaffolded but commented
- Logout or redirect handling on `401` can be wired later via an event bus or global handler

---

**Risk Level:** Low
**Behavioral Change:** Yes (author auto-created on registration)
**Backward Compatibility:** Requires Auth + Blog services to be reachable separately

Reviewed-on: #1
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2025-12-13 13:15:20 +00:00
25 changed files with 404 additions and 303 deletions

View File

@@ -66,6 +66,8 @@ steps:
environment: environment:
API_BASE_URL: API_BASE_URL:
from_secret: API_BASE_URL from_secret: API_BASE_URL
AUTH_BASE_URL:
from_secret: AUTH_BASE_URL
volumes: volumes:
- name: dockersock - name: dockersock
path: /var/run/docker.sock path: /var/run/docker.sock
@@ -76,6 +78,7 @@ steps:
- | - |
docker build --network=host \ docker build --network=host \
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \ --build-arg VITE_API_BASE_URL="$API_BASE_URL" \
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
-t apps/blog:$IMAGE_TAG \ -t apps/blog:$IMAGE_TAG \
-t apps/blog:latest \ -t apps/blog:latest \
/drone/src /drone/src

View File

@@ -15,7 +15,8 @@ COPY . .
# Build the app # Build the app
ARG VITE_API_BASE_URL ARG VITE_API_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build ARG VITE_AUTH_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
# Stage 2: Static file server (BusyBox) # Stage 2: Static file server (BusyBox)
FROM busybox:latest FROM busybox:latest

11
auth/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -1,26 +1,49 @@
import * as React from 'react'; import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material'; import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; 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<void>;
register(username: string, password: string): Promise<void>;
loading: boolean;
error: string | null;
currentUser: any;
}
export function AuthPage({
mode,
onBack, onBack,
onRegister onSwitchMode,
}: LoginProps) { login,
const { login, loading, error, currentUser } = useAuth(); register,
loading,
error,
currentUser,
}: AuthPageProps) {
const [username, setUsername] = React.useState(''); const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const isLogin = mode === "login";
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isLogin) {
await login(username, password); await login(username, password);
} else {
await register(username, password);
}
}; };
// ✅ Auto-return if already logged in // ✅ Auto-return if already logged in
React.useEffect(() => { React.useEffect(() => {
if (currentUser) onBack(); if (currentUser) onBack();
}, [currentUser]); }, [currentUser, onBack]);
return ( return (
<Box <Box
@@ -39,11 +62,13 @@ export default function Login({
</IconButton> </IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom> <Typography variant="h4" fontWeight="bold" gutterBottom>
Sign In {isLogin ? "Sign In" : "Create Account"}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography variant="body2" color="text.secondary" gutterBottom>
Please log in to continue {isLogin
? "Please log in to continue"
: "Create an account to get started"}
</Typography> </Typography>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -55,6 +80,7 @@ export default function Login({
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
autoFocus
/> />
<TextField <TextField
fullWidth fullWidth
@@ -80,7 +106,13 @@ export default function Login({
sx={{ mt: 3 }} sx={{ mt: 3 }}
disabled={loading} disabled={loading}
> >
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'} {loading ? (
<CircularProgress size={24} color="inherit" />
) : isLogin ? (
"Login"
) : (
"Register"
)}
</Button> </Button>
</form> </form>
@@ -90,15 +122,15 @@ export default function Login({
align="center" align="center"
sx={{ mt: 3 }} sx={{ mt: 3 }}
> >
Dont have an account?{' '} {isLogin ? "Dont have an account?" : "Already have an account?"}{' '}
<Link <Link
component="button" component="button"
underline="hover" underline="hover"
color="primary" color="primary"
onClick={onRegister} onClick={onSwitchMode}
sx={{ fontWeight: 500 }} sx={{ fontWeight: 500 }}
> >
Register {isLogin ? "Register" : "Login"}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>

32
auth/src/authClient.ts Normal file
View File

@@ -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();
},
};

35
auth/src/axios.ts Normal file
View File

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

96
auth/src/contexts.tsx Normal file
View File

@@ -0,0 +1,96 @@
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<void>;
register(username: string, password: string): Promise<void>;
logout(): void;
}
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({
children,
authBaseUrl,
}: {
children: React.ReactNode;
authBaseUrl: string;
}) {
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(tokenStore.get());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 });
} 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 (
<AuthContext.Provider
value={{ currentUser, token, loading, error, login, logout, register }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextModel {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

6
auth/src/index.ts Normal file
View File

@@ -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"

11
auth/src/models.ts Normal file
View File

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

0
auth/src/props.ts Normal file
View File

15
auth/src/token.ts Normal file
View File

@@ -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);
},
};

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.2.1", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.2.5", "version": "0.3.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -10,34 +10,36 @@ import ArticleView from './components/Article/ArticleView';
import ArticleEditor from './components/Article/ArticleEditor'; import ArticleEditor from './components/Article/ArticleEditor';
import Latest from './components/Latest'; import Latest from './components/Latest';
import Footer from './components/Footer'; import Footer from './components/Footer';
import Login from './components/Login';
import Register from './components/Register';
import Profile from './components/Profile'; import Profile from './components/Profile';
import { useArticles } from './providers/Article'; import { useArticles } from './providers/Article';
import { useAuth } from './providers/Author'; import { useAuth as useAuthor } from './providers/Author';
import { View, useViewRouter } from "./types/views"; import { View, useViewRouter } from "./types/views";
import { ArticleModel, ArticlesModel } from "./types/models"; import { ArticleModel, ArticlesModel } from "./types/models";
import { ArticleViewProps, ArticleEditorProps } from "./types/props"; import { ArticleViewProps, ArticleEditorProps } from "./types/props";
import { useAuth, AuthPage, AuthMode } from '../../auth/src';
function HomeView({ function HomeView({
currentUser, currentUser,
open_login, open_auth,
open_profile, open_profile,
open_create, open_create,
articles, articles,
openArticle openArticle,
}: any) { }: any) {
return ( return (
<> <>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? ( {!currentUser ? (
<Button variant="outlined" onClick={open_login}>Login</Button> <Button variant='outlined' onClick={() => open_auth('login')}>
Login
</Button>
) : ( ) : (
<> <>
<Button variant="outlined" onClick={open_profile}> <Button variant='outlined' onClick={open_profile}>
{currentUser.username} {currentUser.username}
</Button> </Button>
<Button variant="contained" onClick={open_create}> <Button variant='contained' onClick={open_create}>
New Article New Article
</Button> </Button>
</> </>
@@ -52,11 +54,13 @@ function HomeView({
export default function Blog(props: { disableCustomTheme?: boolean }) { export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles(); const { articles, loading, error } = useArticles();
const { currentUser } = useAuth(); const auth = useAuth();
const { currentUser } = useAuthor();
const [ui, setUI] = React.useState({ const [ui, setUI] = React.useState({
selectedArticle: null as ArticleModel | null, selectedArticle: null as ArticleModel | null,
view: "home" as View, view: 'home' as View,
authMode: 'login' as AuthMode,
}); });
useEffect(() => { useEffect(() => {
@@ -73,6 +77,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
setUI({ setUI({
selectedArticle: article, selectedArticle: article,
view: 'article', view: 'article',
authMode: 'login'
}); });
} }
} }
@@ -85,7 +90,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
} = useViewRouter(setUI); } = useViewRouter(setUI);
type RouterContext = { type RouterContext = {
ui: any; ui: typeof ui;
articles: ArticlesModel; articles: ArticlesModel;
currentUser: any; currentUser: any;
openArticle: (article: ArticleModel) => void; openArticle: (article: ArticleModel) => void;
@@ -97,20 +102,27 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
navigationMap?: Record<string, string>; navigationMap?: Record<string, string>;
}; };
// @ts-ignore
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = { const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
home: { home: {
component: HomeView, component: HomeView,
}, },
login: { auth: {
component: Login, component: AuthPage,
navigationMap: { extraProps: ({ ui }) => ({
open_register: 'onRegister', mode: ui.authMode,
}, onSwitchMode: () =>
}, setUI((prev) => ({
...prev,
register: { authMode: prev.authMode === 'login' ? 'register' : 'login',
component: Register, })),
login: auth.login,
register: auth.register,
loading: auth.loading,
error: auth.error,
currentUser: currentUser,
}),
}, },
profile: { profile: {
@@ -123,6 +135,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
open_editor: 'onEdit', open_editor: 'onEdit',
}, },
extraProps: ({ ui, articles }) => ({ extraProps: ({ ui, articles }) => ({
// @ts-ignore
article: articles.readById(ui.selectedArticle._id), article: articles.readById(ui.selectedArticle._id),
}) satisfies Partial<ArticleViewProps>, }) satisfies Partial<ArticleViewProps>,
}, },
@@ -130,7 +143,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
editor: { editor: {
component: ArticleEditor, component: ArticleEditor,
extraProps: ({ ui, articles }) => ({ 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<ArticleEditorProps>, }) satisfies Partial<ArticleEditorProps>,
}, },
@@ -147,10 +160,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const navigationMap= entry['navigationMap'] || {} const navigationMap= entry['navigationMap'] || {}
const ViewComponent = entry.component; const ViewComponent = entry.component;
const childNav = navigateToChildren( const childNav = {
ui.view, ...navigateToChildren(ui.view, navigationMap),
navigationMap open_auth: (mode: AuthMode = 'login') =>
); setUI((prev) => ({
...prev,
view: 'auth',
authMode: mode,
})),
};
const ctx: RouterContext = { const ctx: RouterContext = {
ui, ui,
@@ -234,7 +252,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
{ui.view === 'home' && ( {ui.view === 'home' && (
<Box <Box
component="footer" component='footer'
sx={{ sx={{
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,

View File

@@ -9,7 +9,8 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author'; import { useAuth as useAuthor } from '../providers/Author';
import { useAuth } from '../../../auth/src';
import { useUpload } from "../providers/Upload"; import { useUpload } from "../providers/Upload";
import ImageUploadField from './ImageUploadField'; import ImageUploadField from './ImageUploadField';
import { ProfileProps } from '../types/props'; import { ProfileProps } from '../types/props';
@@ -17,7 +18,9 @@ import { ProfileProps } from '../types/props';
export default function Profile({ export default function Profile({
onBack onBack
}: ProfileProps) { }: ProfileProps) {
const { currentUser, loading, error, logout, updateProfile } = useAuth(); const { logout } = useAuth();
const { currentUser, updateProfile, loading, error } = useAuthor();
const { uploadFile } = useUpload(); const { uploadFile } = useUpload();
const [formData, setFormData] = React.useState({ const [formData, setFormData] = React.useState({
username: currentUser?.username || '', username: currentUser?.username || '',
@@ -133,6 +136,7 @@ export default function Profile({
label="Username" label="Username"
name="username" name="username"
margin="normal" margin="normal"
disabled={true}
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
/> />

View File

@@ -1,113 +0,0 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
import { RegisterProps } from '../types/props';
export default function Register({
onBack
}: RegisterProps) {
const { register, loading, error, currentUser } = useAuth();
const [username, setUsername] = React.useState('');
const [password1, setPassword1] = React.useState('');
const [password2, setPassword2] = React.useState('');
const [localError, setLocalError] = React.useState<string | null>(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 (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<ArrowBackRoundedIcon />
</IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom>
Sign Up
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Please sign up to continue
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password1}
onChange={(e) => setPassword1(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
required
/>
{(localError || error) && (
<Alert severity="error" sx={{ mt: 2 }}>
{localError || error}
</Alert>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Register'}
</Button>
</form>
</Box>
);
}

View File

@@ -1,12 +1,12 @@
import React, { createContext, useState, useContext, useEffect } from 'react'; import React, { createContext, useState, useContext, useEffect } from "react";
import { api } from '../utils/api'; import { api } from "../utils/api";
import { import {
ArticleModel, ArticleModel,
ArticlesModel, ArticlesModel,
createArticlesModelObject, createArticlesModelObject,
} from '../types/models'; } from "../types/models";
import { ArticleContextModel } from '../types/contexts'; import { ArticleContextModel } from "../types/contexts";
import { useAuth } from './Author'; import { useAuth } from "./Author";
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined); const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
@@ -14,7 +14,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject()); const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject());
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { token, currentUser } = useAuth(); const { currentUser } = useAuth();
/** 🔹 Author IDs must be strings for API, so we normalize here */ /** 🔹 Author IDs must be strings for API, so we normalize here */
const normalizeArticleForApi = (article: Partial<ArticleModel>) => { const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
@@ -109,14 +109,8 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
/** 🔹 Auto-fetch articles whenever user logs in/out */ /** 🔹 Auto-fetch articles whenever user logs in/out */
useEffect(() => { 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(); fetchArticles();
} }, [currentUser]); // refetch on login / logout
}, [token]);
return ( return (
<ArticleContext.Provider value={{ <ArticleContext.Provider value={{

View File

@@ -1,63 +1,50 @@
import React, { createContext, useState, useEffect, useContext } from 'react'; import React, { createContext, useState, useEffect, useContext } from "react";
import { api } from '../utils/api'; import { api } from "../utils/api";
import { AuthorModel } from '../types/models'; import { AuthorModel } from "../types/models";
import { AuthContextModel } from '../types/contexts'; import { AuthContextModel } from "../types/contexts";
import { useAuth as useBaseAuth } from "../../../auth/src";
const AuthContext = createContext<AuthContextModel | undefined>(undefined); const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { currentUser: authUser, logout } = useBaseAuth();
const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null); const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null);
const [authors, setAuthors] = useState<AuthorModel[]>([]); const [authors, setAuthors] = useState<AuthorModel[]>([]);
const [token, setToken] = useState<string | null>(localStorage.getItem('token')); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** 🔹 Register new user */ /**
const register = async (username: string, password: string) => { * Hydrate application-level currentUser
*/
const hydrateCurrentUser = async () => {
if (!authUser) return;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await api.post('/auth/register', { username, password }); const res = await api.get<AuthorModel>("/authors/me");
return res.data;
} catch (err: any) { /**
console.error('Registration failed:', err); * Explicit precedence:
setError(err.response?.data?.detail || 'Registration failed'); * 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,
};
setCurrentUser(fullUser);
} catch (err) {
console.error("Failed to hydrate current user:", err);
logout();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
/** 🔹 Login and store JWT token */
const login = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await api.post('/auth/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) */ /** 🔹 Fetch all authors (JWT handled by api interceptor) */
const refreshAuthors = async () => { const refreshAuthors = async () => {
try { try {
@@ -95,39 +82,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}; };
/** 🔹 Auto-load current user if token exists */ /**
const fetchCurrentUser = async () => { * React strictly to auth lifecycle
if (!token) return; */
try {
const me = await api.get<{ _id: string; username: string; email: string }>('/auth/me');
const author = await api.get<AuthorModel>(`/authors/${me.data._id}`);
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 */
useEffect(() => { useEffect(() => {
if (token) fetchCurrentUser(); if (authUser) {
}, [token]); hydrateCurrentUser();
} else {
setCurrentUser(null);
setAuthors([]);
setError(null);
}
}, [authUser]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
currentUser, currentUser,
authors, authors,
token,
loading, loading,
error, error,
login,
logout,
register,
refreshAuthors, refreshAuthors,
updateProfile, updateProfile,
}} }}

View File

@@ -16,12 +16,9 @@ export interface ArticleContextModel {
export interface AuthContextModel { export interface AuthContextModel {
currentUser: AuthorModel | null; currentUser: AuthorModel | null;
authors: AuthorModel[]; authors: AuthorModel[];
token: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshAuthors: () => Promise<void>; refreshAuthors: () => Promise<void>;
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>; updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
} }

View File

@@ -2,20 +2,18 @@ import {
createInList, readInList, updateInList, deleteInList, createInList, readInList, updateInList, deleteInList,
createById, readById, updateById, deleteById createById, readById, updateById, deleteById
} from "../utils/articles"; } from "../utils/articles";
import { AuthUser } from "../../../auth/src";
export interface AuthorModel { export interface AuthorModel extends AuthUser {
// meta fields // meta fields
_id?: string | null; _id?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
// model fields // model fields
username: string;
name: string; name: string;
email: string;
avatar: string; avatar: string;
is_active: boolean;
} }
export interface ArticleModel { export interface ArticleModel {

View File

@@ -9,11 +9,6 @@ export interface LatestProps {
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
} }
export interface LoginProps {
onBack: () => void;
onRegister: () => void;
}
export interface MainContentProps { export interface MainContentProps {
articles: ArticlesModel; articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void; onSelectArticle: (index: ArticleModel) => void;
@@ -23,10 +18,6 @@ export interface ProfileProps {
onBack: () => void; onBack: () => void;
} }
export interface RegisterProps {
onBack: () => void;
}
export interface ArticleViewProps { export interface ArticleViewProps {
article: ArticleModel; article: ArticleModel;
onBack: () => void; onBack: () => void;

View File

@@ -1,9 +1,8 @@
import {ArticleModel} from "./models"; import { ArticleModel } from "./models";
export type View = export type View =
| "home" | "home"
| "login" | "auth"
| "register"
| "article" | "article"
| "editor" | "editor"
| "profile" | "profile"
@@ -17,25 +16,26 @@ export type ViewNode = {
export const VIEW_TREE: Record<View, ViewNode> = { export const VIEW_TREE: Record<View, ViewNode> = {
home: { home: {
parent: null, parent: null,
children: ["login", "article", "profile", "create"], children: ["auth", "article", "profile", "create"],
}, },
login: {
auth: {
parent: "home", parent: "home",
children: ["register"],
},
register: {
parent: "login",
}, },
article: { article: {
parent: "home", parent: "home",
children: ["editor"], children: ["editor"],
}, },
editor: { editor: {
parent: "article", parent: "article",
}, },
profile: { profile: {
parent: "home", parent: "home",
}, },
create: { create: {
parent: "home", parent: "home",
}, },
@@ -43,11 +43,15 @@ export const VIEW_TREE: Record<View, ViewNode> = {
export const VIEW_URL: Record<View, (ui?: any) => string> = { export const VIEW_URL: Record<View, (ui?: any) => string> = {
home: () => "/", home: () => "/",
login: () => "/login",
register: () => "/register", auth: () => "/auth",
profile: () => "/profile", profile: () => "/profile",
create: () => "/create", create: () => "/create",
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`, article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`, editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
}; };

View File

@@ -1,33 +1,19 @@
// src/utils/api.ts import { createApiClient } from "../../../auth/src";
import axios from 'axios';
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
const API_BASE = import.meta.env.VITE_API_BASE_URL; const API_BASE = import.meta.env.VITE_API_BASE_URL;
export const api = axios.create({ /**
baseURL: API_BASE, * Auth service client
headers: { * - login
'Content-Type': 'application/json', * - register
}, * - me
}); * - logout
* - introspect
*/
export const auth = createApiClient(AUTH_BASE);
// 🔹 Attach token from localStorage before each request /**
api.interceptors.request.use((config) => { * Main application API (blog, articles, etc.)
const token = localStorage.getItem('token'); */
if (token) { export const api = createApiClient(API_BASE);
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 🔹 Handle expired or invalid tokens globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn('Token expired or invalid. Logging out...');
localStorage.removeItem('token');
// Optionally: trigger a redirect or event
}
return Promise.reject(error);
}
);

View File

@@ -2,18 +2,22 @@ import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Blog from './blog/Blog'; import Blog from './blog/Blog';
import { ArticleProvider } from './blog/providers/Article'; 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 { UploadProvider } from "./blog/providers/Upload";
import { AuthProvider } from "../auth/src";
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
root.render( root.render(
<UploadProvider> <UploadProvider>
<AuthProvider> <AuthProvider authBaseUrl={AUTH_BASE}>
<AuthorProvider>
<ArticleProvider> <ArticleProvider>
<Blog /> <Blog />
</ArticleProvider> </ArticleProvider>
</AuthorProvider>
</AuthProvider> </AuthProvider>
</UploadProvider>, </UploadProvider>
); );

1
src/vite-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string; readonly VITE_API_BASE_URL: string;
readonly VITE_AUTH_BASE_URL: string;
} }
interface ImportMeta { interface ImportMeta {