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:
11
auth/package.json
Normal file
11
auth/package.json
Normal 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
138
auth/src/AuthPage.tsx
Normal 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 ? "Don’t 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
32
auth/src/authClient.ts
Normal 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
35
auth/src/axios.ts
Normal 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
111
auth/src/context.tsx
Normal 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
5
auth/src/index.ts
Normal 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
0
auth/src/props.ts
Normal file
15
auth/src/token.ts
Normal file
15
auth/src/token.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user