Compare commits

...

3 Commits

7 changed files with 76 additions and 11 deletions

1
.env
View File

@ -1,2 +1,3 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_GOOGLE_CLIENT_ID=google_client_id
VERSION=1.0.0

View File

@ -1,7 +1,20 @@
import { useEffect } from 'react'
import { Toaster } from 'sonner'
import { AppRoutes } from './app/AppRoutes'
const SESSION_KEY = 'yimaru_session_active'
export default function App() {
useEffect(() => {
if (!sessionStorage.getItem(SESSION_KEY)) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('member_id')
localStorage.removeItem('role')
sessionStorage.setItem(SESSION_KEY, '1')
}
}, [])
return (
<>
<AppRoutes />

View File

@ -109,6 +109,12 @@ http.interceptors.response.use(
}
}
// Backend is down (network error, timeout, connection refused)
if (!error.response) {
clearAuthAndRedirect();
return Promise.reject(error);
}
return Promise.reject(error);
}
);

2
src/globals.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const __BUILD_HASH__: string
declare const __BUILD_TIME__: string

View File

@ -1,11 +1,16 @@
import { useState, useCallback } from "react"
import { Outlet } from "react-router-dom"
import { Navigate, Outlet } from "react-router-dom"
import { Sidebar } from "../components/sidebar/Sidebar"
import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const token = localStorage.getItem("access_token")
if (!token) {
return <Navigate to="/login" replace />
}
const handleMenuClick = useCallback(() => {
setSidebarOpen(true)
}, [])

View File

@ -71,6 +71,7 @@ export function LoginPage() {
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [googleReady, setGoogleReady] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
const googleBtnRef = useRef<HTMLDivElement>(null);
@ -156,6 +157,16 @@ export function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors: { email?: string; password?: string } = {};
if (!email.trim()) errors.email = "Please enter your email address";
if (!password) errors.password = "Please enter your password";
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
setFieldErrors({});
setError(null);
setLoading(true);
@ -309,7 +320,7 @@ export function LoginPage() {
</>
)}
<form onSubmit={handleSubmit} className="space-y-5" autoComplete="on" method="post">
<form onSubmit={handleSubmit} className="space-y-5" autoComplete="on" method="post" noValidate>
{/* Email */}
<div>
<label
@ -325,10 +336,15 @@ export function LoginPage() {
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-11 rounded-xl"
onChange={(e) => {
setEmail(e.target.value);
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
}}
className={`h-11 rounded-xl ${fieldErrors.email ? "border-red-400 focus-visible:ring-red-400/40" : ""}`}
/>
{fieldErrors.email && (
<p className="mt-1.5 text-xs text-red-500">{fieldErrors.email}</p>
)}
</div>
{/* Password */}
@ -355,9 +371,11 @@ export function LoginPage() {
placeholder="••••••••"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-11 rounded-xl pr-10"
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }));
}}
className={`h-11 rounded-xl pr-10 ${fieldErrors.password ? "border-red-400 focus-visible:ring-red-400/40" : ""}`}
/>
<button
type="button"
@ -368,6 +386,9 @@ export function LoginPage() {
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{fieldErrors.password && (
<p className="mt-1.5 text-xs text-red-500">{fieldErrors.password}</p>
)}
</div>
<Button
@ -380,11 +401,15 @@ export function LoginPage() {
</form>
{/* Footer */}
<p className="mt-10 text-center text-xs text-grayScale-400">
© {new Date().getFullYear()} Yimaru Academy · All rights reserved
<div className="mt-10 text-center text-xs text-grayScale-400">
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
<p className="mt-1 font-mono text-[10px] text-grayScale-300">
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
define: {
__BUILD_HASH__: JSON.stringify(
(() => {
try {
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
return 'unknown'
}
})()
),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
})