feat: Added AuthContext provider with JWT persistence, auth API client…
- "frontend/src/context/AuthContext.tsx" - "frontend/src/api/public-client.ts" - "frontend/src/pages/Login.tsx" - "frontend/src/pages/Login.module.css" - "frontend/src/pages/Register.tsx" - "frontend/src/pages/Register.module.css" - "frontend/src/App.tsx" GSD-Task: S02/T03
This commit is contained in:
parent
77f44b0b48
commit
b344307a89
11 changed files with 808 additions and 7 deletions
|
|
@ -100,7 +100,7 @@
|
||||||
- Estimate: 1.5h
|
- Estimate: 1.5h
|
||||||
- Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py
|
- Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py
|
||||||
- Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
|
- Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
|
||||||
- [ ] **T03: Build frontend AuthContext, API auth functions, login and register pages** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
|
- [x] **T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
30
.gsd/milestones/M019/slices/S02/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M019/slices/S02/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M019/S02/T02",
|
||||||
|
"timestamp": 1775253251505,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd backend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 7,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m pytest tests/test_auth.py -v",
|
||||||
|
"exitCode": 4,
|
||||||
|
"durationMs": 253,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m pytest tests/test_public_api.py -v",
|
||||||
|
"exitCode": 4,
|
||||||
|
"durationMs": 218,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
94
.gsd/milestones/M019/slices/S02/tasks/T03-SUMMARY.md
Normal file
94
.gsd/milestones/M019/slices/S02/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S02
|
||||||
|
milestone: M019
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/context/AuthContext.tsx", "frontend/src/api/public-client.ts", "frontend/src/pages/Login.tsx", "frontend/src/pages/Login.module.css", "frontend/src/pages/Register.tsx", "frontend/src/pages/Register.module.css", "frontend/src/App.tsx"]
|
||||||
|
key_decisions: ["Stored token key as AUTH_TOKEN_KEY constant exported from public-client.ts for shared access", "request<T> auto-injects Authorization header from localStorage — existing unauthenticated calls unaffected"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Frontend build passes with zero TypeScript errors. All 8 verification checks pass: AuthProvider wired in App.tsx, AuthContext.tsx exists, Login.tsx and Register.tsx exist, CSS modules exist, backend models/auth/schemas imports all succeed."
|
||||||
|
completed_at: 2026-04-03T21:58:04.681Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
|
||||||
|
|
||||||
|
> Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S02
|
||||||
|
milestone: M019
|
||||||
|
key_files:
|
||||||
|
- frontend/src/context/AuthContext.tsx
|
||||||
|
- frontend/src/api/public-client.ts
|
||||||
|
- frontend/src/pages/Login.tsx
|
||||||
|
- frontend/src/pages/Login.module.css
|
||||||
|
- frontend/src/pages/Register.tsx
|
||||||
|
- frontend/src/pages/Register.module.css
|
||||||
|
- frontend/src/App.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Stored token key as AUTH_TOKEN_KEY constant exported from public-client.ts for shared access
|
||||||
|
- request<T> auto-injects Authorization header from localStorage — existing unauthenticated calls unaffected
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T21:58:04.682Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
|
||||||
|
|
||||||
|
**Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Created the complete frontend auth infrastructure: AuthContext with login/register/logout/session rehydration from localStorage, auth API functions with TypeScript types in public-client.ts (with auto-inject Authorization header), Login page with email/password form and error handling, Register page with invite code + full validation, CSS modules for both pages, and routes wired into App.tsx with AuthProvider wrapper.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Frontend build passes with zero TypeScript errors. All 8 verification checks pass: AuthProvider wired in App.tsx, AuthContext.tsx exists, Login.tsx and Register.tsx exist, CSS modules exist, backend models/auth/schemas imports all succeed.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3600ms |
|
||||||
|
| 2 | `grep -c 'AuthProvider' frontend/src/App.tsx` | 0 | ✅ pass | 100ms |
|
||||||
|
| 3 | `test -f frontend/src/context/AuthContext.tsx` | 0 | ✅ pass | 100ms |
|
||||||
|
| 4 | `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` | 0 | ✅ pass | 100ms |
|
||||||
|
| 5 | `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` | 0 | ✅ pass | 100ms |
|
||||||
|
| 6 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')"` | 0 | ✅ pass | 300ms |
|
||||||
|
| 7 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')"` | 0 | ✅ pass | 300ms |
|
||||||
|
| 8 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('OK')"` | 0 | ✅ pass | 300ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Exported ApiError class from public-client.ts (was previously unexported) for use in AuthContext and pages. Added AUTH_TOKEN_KEY as exported constant.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Verification gate used wrong test file paths (tests/test_auth.py instead of backend/tests/test_auth.py) — not a code issue.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/context/AuthContext.tsx`
|
||||||
|
- `frontend/src/api/public-client.ts`
|
||||||
|
- `frontend/src/pages/Login.tsx`
|
||||||
|
- `frontend/src/pages/Login.module.css`
|
||||||
|
- `frontend/src/pages/Register.tsx`
|
||||||
|
- `frontend/src/pages/Register.module.css`
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
Exported ApiError class from public-client.ts (was previously unexported) for use in AuthContext and pages. Added AUTH_TOKEN_KEY as exported constant.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
Verification gate used wrong test file paths (tests/test_auth.py instead of backend/tests/test_auth.py) — not a code issue.
|
||||||
|
|
@ -11,9 +11,12 @@ import AdminReports from "./pages/AdminReports";
|
||||||
import AdminPipeline from "./pages/AdminPipeline";
|
import AdminPipeline from "./pages/AdminPipeline";
|
||||||
import AdminTechniquePages from "./pages/AdminTechniquePages";
|
import AdminTechniquePages from "./pages/AdminTechniquePages";
|
||||||
import About from "./pages/About";
|
import About from "./pages/About";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Register from "./pages/Register";
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
import SearchAutocomplete from "./components/SearchAutocomplete";
|
import SearchAutocomplete from "./components/SearchAutocomplete";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -58,6 +61,7 @@ export default function App() {
|
||||||
}, [menuOpen, handleOutsideClick]);
|
}, [menuOpen, handleOutsideClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AuthProvider>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<a href="#main-content" className="skip-link">Skip to content</a>
|
<a href="#main-content" className="skip-link">Skip to content</a>
|
||||||
<header className="app-header" ref={headerRef}>
|
<header className="app-header" ref={headerRef}>
|
||||||
|
|
@ -140,6 +144,10 @@ export default function App() {
|
||||||
{/* Info routes */}
|
{/* Info routes */}
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
|
|
||||||
|
{/* Auth routes */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -147,5 +155,6 @@ export default function App() {
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,11 +204,48 @@ export interface CreatorDetailResponse {
|
||||||
genre_breakdown: Record<string, number>;
|
genre_breakdown: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
display_name: string;
|
||||||
|
invite_code: string;
|
||||||
|
creator_slug?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
role: string;
|
||||||
|
creator_id: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
display_name?: string | null;
|
||||||
|
current_password?: string | null;
|
||||||
|
new_password?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BASE = "/api/v1";
|
const BASE = "/api/v1";
|
||||||
|
const AUTH_TOKEN_KEY = "chrysopedia_token";
|
||||||
|
|
||||||
class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
public detail: string,
|
public detail: string,
|
||||||
|
|
@ -218,13 +255,27 @@ class ApiError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
if (token && !headers["Authorization"]) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...init?.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -856,3 +907,39 @@ export async function fetchAdminTechniquePages(
|
||||||
`${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`,
|
`${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export { AUTH_TOKEN_KEY };
|
||||||
|
|
||||||
|
export async function authRegister(data: RegisterRequest): Promise<UserResponse> {
|
||||||
|
return request<UserResponse>(`${BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authLogin(email: string, password: string): Promise<TokenResponse> {
|
||||||
|
return request<TokenResponse>(`${BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authGetMe(token: string): Promise<UserResponse> {
|
||||||
|
return request<UserResponse>(`${BASE}/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authUpdateProfile(
|
||||||
|
token: string,
|
||||||
|
data: UpdateProfileRequest,
|
||||||
|
): Promise<UserResponse> {
|
||||||
|
return request<UserResponse>(`${BASE}/auth/me`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
109
frontend/src/context/AuthContext.tsx
Normal file
109
frontend/src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
authLogin,
|
||||||
|
authGetMe,
|
||||||
|
authRegister,
|
||||||
|
ApiError,
|
||||||
|
type UserResponse,
|
||||||
|
type RegisterRequest,
|
||||||
|
} from "../api/public-client";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: UserResponse | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (data: RegisterRequest) => Promise<UserResponse>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserResponse | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(!!token);
|
||||||
|
|
||||||
|
// Rehydrate session from stored token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
let cancelled = false;
|
||||||
|
authGetMe(token)
|
||||||
|
.then((u) => {
|
||||||
|
if (!cancelled) setUser(u);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Token expired or invalid — clear it
|
||||||
|
if (!cancelled) {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const resp = await authLogin(email, password);
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
|
||||||
|
setToken(resp.access_token);
|
||||||
|
const me = await authGetMe(resp.access_token);
|
||||||
|
setUser(me);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (data: RegisterRequest) => {
|
||||||
|
return authRegister(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
113
frontend/src/pages/Login.module.css
Normal file
113
frontend/src/pages/Login.module.css
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #4ade80;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
97
frontend/src/pages/Login.tsx
Normal file
97
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth, ApiError } from "../context/AuthContext";
|
||||||
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import styles from "./Login.module.css";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
useDocumentTitle("Login");
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check for success message from registration redirect
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const registered = params.get("registered") === "1";
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (!email.trim() || !password) {
|
||||||
|
setError("Email and password are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password);
|
||||||
|
navigate("/creator/dashboard", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.detail);
|
||||||
|
} else {
|
||||||
|
setError("An unexpected error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h1 className={styles.title}>Sign In</h1>
|
||||||
|
{registered && (
|
||||||
|
<div className={styles.success}>
|
||||||
|
Registration successful! Please sign in.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Signing in…" : "Sign In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className={styles.footer}>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link to="/register" className={styles.link}>
|
||||||
|
Register with invite code
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/src/pages/Register.module.css
Normal file
116
frontend/src/pages/Register.module.css
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
146
frontend/src/pages/Register.tsx
Normal file
146
frontend/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth, ApiError } from "../context/AuthContext";
|
||||||
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import styles from "./Register.module.css";
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
useDocumentTitle("Register");
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const validate = (): string | null => {
|
||||||
|
if (!inviteCode.trim()) return "Invite code is required.";
|
||||||
|
if (!email.trim()) return "Email is required.";
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()))
|
||||||
|
return "Please enter a valid email address.";
|
||||||
|
if (!displayName.trim()) return "Display name is required.";
|
||||||
|
if (password.length < 8)
|
||||||
|
return "Password must be at least 8 characters.";
|
||||||
|
if (password !== confirmPassword)
|
||||||
|
return "Passwords do not match.";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const validationError = validate();
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
invite_code: inviteCode.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
display_name: displayName.trim(),
|
||||||
|
});
|
||||||
|
navigate("/login?registered=1", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.detail);
|
||||||
|
} else {
|
||||||
|
setError("An unexpected error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h1 className={styles.title}>Create Account</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
An invite code is required to register.
|
||||||
|
</p>
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Invite Code
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Display Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Minimum 8 characters</span>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>
|
||||||
|
Confirm Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={styles.input}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Creating account…" : "Create Account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className={styles.footer}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link to="/login" className={styles.link}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||||
Loading…
Add table
Reference in a new issue