Initial commit: Trading Portal - FastAPI + React + PostgreSQL
This commit is contained in:
Executable
+70
@@ -0,0 +1,70 @@
|
||||
import { useAppDispatch } from '../../app/hooks';
|
||||
import { loginThunk } from './authSlice';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
export default function LoginPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useT();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false); // P2-12
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true); // P2-12: show loading state
|
||||
try {
|
||||
await dispatch(loginThunk({ username, password })).unwrap();
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : t('login.error'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<h2>{t('login.subtitle')}</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">{t('login.username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('login.username')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">{t('login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('login.password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={isSubmitting} style={{ minHeight: 44, touchAction: 'manipulation' }}>
|
||||
{isSubmitting ? t('login.signing') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{/* Register temporarily hidden while features stabilize */}
|
||||
{/* <p className="auth-link"> */}
|
||||
{/* Don't have an account? <a href="/register">Register</a> */}
|
||||
{/* </p> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
import { useAppDispatch } from '../../app/hooks';
|
||||
import { registerThunk } from './authSlice';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(registerThunk({ username, email, password })).unwrap();
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
|
||||
<h1>Trading Portal</h1>
|
||||
<h2>Create Account</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" style={{ minHeight: 44, touchAction: 'manipulation' }}>
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-link">
|
||||
Already have an account? <a href="/login">Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { AuthState, User } from '../../types/trading';
|
||||
import * as api from '../api/apiService';
|
||||
|
||||
function loadInitialState(): AuthState {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
user: null,
|
||||
accessToken: token,
|
||||
refreshToken: localStorage.getItem('refresh_token'),
|
||||
isAuthenticated: !!token,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const loginThunk = createAsyncThunk(
|
||||
'auth/login',
|
||||
async ({ username, password }: { username: string; password: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const result = await api.login(username, password);
|
||||
localStorage.setItem('access_token', result.access_token);
|
||||
localStorage.setItem('refresh_token', result.refresh_token);
|
||||
// Fetch user info after login
|
||||
const user = await api.getMe();
|
||||
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Login failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const registerThunk = createAsyncThunk(
|
||||
'auth/register',
|
||||
async (
|
||||
{ username, email, password }: { username: string; email: string; password: string },
|
||||
{ rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
await api.register(username, email, password);
|
||||
// Auto-login after registration
|
||||
const result = await api.login(username, password);
|
||||
localStorage.setItem('access_token', result.access_token);
|
||||
localStorage.setItem('refresh_token', result.refresh_token);
|
||||
const user = await api.getMe();
|
||||
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Registration failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchCurrentUser = createAsyncThunk(
|
||||
'auth/fetchCurrentUser',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
return user as User;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return rejectWithValue(err.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch user');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const logoutThunk = createAsyncThunk(
|
||||
'auth/logout',
|
||||
async () => {
|
||||
await api.logout();
|
||||
},
|
||||
);
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: loadInitialState(),
|
||||
reducers: {
|
||||
setCredentials(state, action: PayloadAction<{ user: User; accessToken: string; refreshToken: string }>) {
|
||||
state.user = action.payload.user;
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
},
|
||||
clearCredentials(state) {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
},
|
||||
clearError(state) {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Login
|
||||
.addCase(loginThunk.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.user = action.payload.user;
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.isAuthenticated = true;
|
||||
})
|
||||
.addCase(loginThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Login failed';
|
||||
})
|
||||
// Register
|
||||
.addCase(registerThunk.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(registerThunk.fulfilled, (state) => {
|
||||
state.isLoading = false;
|
||||
})
|
||||
.addCase(registerThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Registration failed';
|
||||
})
|
||||
// Fetch current user
|
||||
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
|
||||
state.user = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
})
|
||||
.addCase(fetchCurrentUser.rejected, (state) => {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
})
|
||||
// Logout
|
||||
.addCase(logoutThunk.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.isAuthenticated = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCredentials, clearCredentials, clearError } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
Reference in New Issue
Block a user