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
This commit is contained in:
2025-12-26 00:56:23 +05:30
parent 14b43cb3c5
commit 2c18c7258b
16 changed files with 355 additions and 254 deletions

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

138
auth/src/AuthPage.tsx Normal file
View File

@@ -0,0 +1,138 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
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,
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();
if (isLogin) {
await login(username, password);
} else {
await register(username, password);
}
};
// ✅ Auto-return if already logged in
React.useEffect(() => {
if (currentUser) onBack();
}, [currentUser, onBack]);
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>
{isLogin ? "Sign In" : "Create Account"}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isLogin
? "Please log in to continue"
: "Create an account to get started"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : isLogin ? (
"Login"
) : (
"Register"
)}
</Button>
</form>
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mt: 3 }}
>
{isLogin ? "Dont have an account?" : "Already have an account?"}{' '}
<Link
component="button"
underline="hover"
color="primary"
onClick={onSwitchMode}
sx={{ fontWeight: 500 }}
>
{isLogin ? "Register" : "Login"}
</Link>
</Typography>
</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;
}

111
auth/src/context.tsx Normal file
View File

@@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import axios from "axios";
import { tokenStore } from "./token";
import { attachAuthInterceptors } from "./axios";
export interface AuthUser {
_id: string;
username: string;
[key: string]: any;
}
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,
apiClient,
}: {
children: React.ReactNode;
authBaseUrl: string;
apiClient: any; // your domain api client
}) {
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 = axios.create({
baseURL: authBaseUrl,
headers: { "Content-Type": "application/json" },
});
attachAuthInterceptors(auth);
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 apiClient.post("/authors", { name: null, avatar: null });
} 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");
const author = await apiClient.get("/authors/me");
setCurrentUser({ ...me.data, ...author.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;
}

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

@@ -0,0 +1,5 @@
export { AuthProvider, useAuth } from "./context";
export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage";
export type { AuthMode } from "./AuthPage";
export { tokenStore } from "./token"

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