feat: Added ProtectedRoute component, CreatorDashboard with sidebar nav…

- "frontend/src/components/ProtectedRoute.tsx"
- "frontend/src/pages/CreatorDashboard.tsx"
- "frontend/src/pages/CreatorDashboard.module.css"
- "frontend/src/pages/CreatorSettings.tsx"
- "frontend/src/pages/CreatorSettings.module.css"
- "frontend/src/App.tsx"
- "frontend/src/App.css"
- "frontend/src/pages/Login.tsx"

GSD-Task: S02/T04
This commit is contained in:
jlightner 2026-04-03 22:02:04 +00:00
parent b344307a89
commit c60fc8c3b3
12 changed files with 749 additions and 6 deletions

View file

@ -148,7 +148,7 @@
- Estimate: 1.5h
- 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
- Verify: cd frontend && npm run build && test -f src/context/AuthContext.tsx && test -f src/pages/Login.tsx && test -f src/pages/Register.tsx
- [ ] **T04: Build dashboard shell, profile settings, ProtectedRoute, and nav auth state** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
- [x] **T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav** — Create the creator dashboard shell page with sidebar navigation, profile settings page, ProtectedRoute wrapper component, and update the main nav to show authenticated user state.
## Steps

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M019/S02/T03",
"timestamp": 1775253488528,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 90,
"verdict": "fail"
},
{
"command": "test -f src/context/AuthContext.tsx",
"exitCode": 1,
"durationMs": 5,
"verdict": "fail"
},
{
"command": "test -f src/pages/Login.tsx",
"exitCode": 1,
"durationMs": 5,
"verdict": "fail"
},
{
"command": "test -f src/pages/Register.tsx",
"exitCode": 1,
"durationMs": 5,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,97 @@
---
id: T04
parent: S02
milestone: M019
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/ProtectedRoute.tsx", "frontend/src/pages/CreatorDashboard.tsx", "frontend/src/pages/CreatorDashboard.module.css", "frontend/src/pages/CreatorSettings.tsx", "frontend/src/pages/CreatorSettings.module.css", "frontend/src/App.tsx", "frontend/src/App.css", "frontend/src/pages/Login.tsx"]
key_decisions: ["Extracted AppShell from App to allow useAuth() inside AuthProvider", "Login page honors returnTo query param from ProtectedRoute redirect", "SidebarNav exported from CreatorDashboard for reuse in CreatorSettings"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "All task and slice verification checks pass: npm run build exits 0 with zero TypeScript errors, all required files exist, ProtectedRoute and useAuth grep checks pass in App.tsx, backend model/auth/schema imports succeed."
completed_at: 2026-04-03T22:02:01.042Z
blocker_discovered: false
---
# T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
> Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
## What Happened
---
id: T04
parent: S02
milestone: M019
key_files:
- frontend/src/components/ProtectedRoute.tsx
- frontend/src/pages/CreatorDashboard.tsx
- frontend/src/pages/CreatorDashboard.module.css
- frontend/src/pages/CreatorSettings.tsx
- frontend/src/pages/CreatorSettings.module.css
- frontend/src/App.tsx
- frontend/src/App.css
- frontend/src/pages/Login.tsx
key_decisions:
- Extracted AppShell from App to allow useAuth() inside AuthProvider
- Login page honors returnTo query param from ProtectedRoute redirect
- SidebarNav exported from CreatorDashboard for reuse in CreatorSettings
duration: ""
verification_result: passed
completed_at: 2026-04-03T22:02:01.043Z
blocker_discovered: false
---
# T04: Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav
**Added ProtectedRoute component, CreatorDashboard with sidebar nav, CreatorSettings with profile/password forms, protected routes in App.tsx, and auth-aware header nav**
## What Happened
Created ProtectedRoute that checks isAuthenticated and redirects to /login?returnTo=... when unauthenticated. Built CreatorDashboard with two-column layout (sidebar + content) using NavLink for active state, with placeholder cards for future analytics. Built CreatorSettings reusing the same SidebarNav, with profile (display_name editable, email read-only) and password change sections calling authUpdateProfile(). Restructured App.tsx by extracting AppShell so useAuth() works inside AuthProvider, added AuthNav component showing user name + dashboard link + logout when authenticated or login link when not. Updated Login.tsx to honor returnTo param. All CSS uses modules with existing dark theme tokens.
## Verification
All task and slice verification checks pass: npm run build exits 0 with zero TypeScript errors, all required files exist, ProtectedRoute and useAuth grep checks pass in App.tsx, backend model/auth/schema imports succeed.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3300ms |
| 2 | `test -f src/components/ProtectedRoute.tsx` | 0 | ✅ pass | 50ms |
| 3 | `test -f src/pages/CreatorDashboard.tsx && test -f src/pages/CreatorSettings.tsx` | 0 | ✅ pass | 50ms |
| 4 | `grep -q 'ProtectedRoute' src/App.tsx` | 0 | ✅ pass | 50ms |
| 5 | `grep -q 'useAuth' src/App.tsx` | 0 | ✅ pass | 50ms |
| 6 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')"` | 0 | ✅ pass | 500ms |
| 7 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')"` | 0 | ✅ pass | 500ms |
| 8 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('OK')"` | 0 | ✅ pass | 500ms |
## Deviations
Extracted AppShell from App to allow useAuth() inside AuthProvider. AuthNav added as separate component rather than inline.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/ProtectedRoute.tsx`
- `frontend/src/pages/CreatorDashboard.tsx`
- `frontend/src/pages/CreatorDashboard.module.css`
- `frontend/src/pages/CreatorSettings.tsx`
- `frontend/src/pages/CreatorSettings.module.css`
- `frontend/src/App.tsx`
- `frontend/src/App.css`
- `frontend/src/pages/Login.tsx`
## Deviations
Extracted AppShell from App to allow useAuth() inside AuthProvider. AuthNav added as separate component rather than inline.
## Known Issues
None.

View file

@ -778,6 +778,48 @@ a.app-footer__repo:hover {
color: var(--color-text-on-header-hover);
}
/* ── Auth nav ─────────────────────────────────────────────────────────────── */
.auth-nav__user {
color: var(--color-accent);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: color 0.15s;
}
.auth-nav__user:hover {
color: var(--color-accent-hover);
}
.auth-nav__logout {
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.25rem 0.625rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.auth-nav__logout:hover {
color: var(--color-text-primary);
border-color: var(--color-text-secondary);
}
.auth-nav__login {
color: var(--color-text-on-header);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.15s;
}
.auth-nav__login:hover {
color: var(--color-accent);
}
/* ── Admin dropdown ───────────────────────────────────────────────────────── */
.admin-dropdown {

View file

@ -13,12 +13,38 @@ import AdminTechniquePages from "./pages/AdminTechniquePages";
import About from "./pages/About";
import Login from "./pages/Login";
import Register from "./pages/Register";
import CreatorDashboard from "./pages/CreatorDashboard";
import CreatorSettings from "./pages/CreatorSettings";
import AdminDropdown from "./components/AdminDropdown";
import AppFooter from "./components/AppFooter";
import SearchAutocomplete from "./components/SearchAutocomplete";
import { AuthProvider } from "./context/AuthContext";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider, useAuth } from "./context/AuthContext";
export default function App() {
function AuthNav() {
const { isAuthenticated, user, logout } = useAuth();
const navigate = useNavigate();
if (isAuthenticated) {
return (
<>
<Link to="/creator/dashboard" className="auth-nav__user">
{user?.display_name ?? "Dashboard"}
</Link>
<button
className="auth-nav__logout"
onClick={() => { logout(); navigate("/"); }}
>
Logout
</button>
</>
);
}
return <Link to="/login" className="auth-nav__login">Login</Link>;
}
function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const showNavSearch = location.pathname !== "/";
@ -61,7 +87,6 @@ export default function App() {
}, [menuOpen, handleOutsideClick]);
return (
<AuthProvider>
<div className="app">
<a href="#main-content" className="skip-link">Skip to content</a>
<header className="app-header" ref={headerRef}>
@ -108,6 +133,7 @@ export default function App() {
<Link to="/">Home</Link>
<Link to="/topics">Topics</Link>
<Link to="/creators">Creators</Link>
<AuthNav />
<AdminDropdown />
{/* Mobile-only: search bar inside menu when not shown in header */}
{menuOpen && showNavSearch && (
@ -148,6 +174,10 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Creator routes (protected) */}
<Route path="/creator/dashboard" element={<ProtectedRoute><CreatorDashboard /></ProtectedRoute>} />
<Route path="/creator/settings" element={<ProtectedRoute><CreatorSettings /></ProtectedRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@ -155,6 +185,13 @@ export default function App() {
<AppFooter />
</div>
);
}
export default function App() {
return (
<AuthProvider>
<AppShell />
</AuthProvider>
);
}

View file

@ -0,0 +1,24 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import type { ReactNode } from "react";
/**
* Wraps routes that require authentication.
* Redirects to /login with a returnTo param if not authenticated.
* Shows nothing while the auth state is still loading (token rehydration).
*/
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return null; // Wait for token rehydration before redirecting
}
if (!isAuthenticated) {
const returnTo = location.pathname + location.search;
return <Navigate to={`/login?returnTo=${encodeURIComponent(returnTo)}`} replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,132 @@
.layout {
display: flex;
gap: 0;
min-height: 60vh;
}
/* ── Sidebar ───────────────────────────────────────────────────────────────── */
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--color-bg-surface);
border-right: 1px solid var(--color-border);
border-radius: 12px 0 0 12px;
padding: 1.5rem 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sidebarLink {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1.25rem;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: color 0.15s, background 0.15s;
border: none;
background: none;
cursor: pointer;
width: 100%;
text-align: left;
}
.sidebarLink:hover {
color: var(--color-text-primary);
background: var(--color-bg-surface-hover);
}
.sidebarLinkActive {
color: var(--color-accent) !important;
background: var(--color-accent-subtle);
}
.sidebarLinkDisabled {
opacity: 0.4;
cursor: not-allowed;
}
.sidebarLinkDisabled:hover {
background: none;
color: var(--color-text-secondary);
}
.sidebarIcon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ── Content area ──────────────────────────────────────────────────────────── */
.content {
flex: 1;
min-width: 0;
padding: 2rem;
}
.welcome {
margin: 0 0 1.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.25rem;
}
.cardTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
}
.cardBody {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* ── Mobile ────────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--color-border);
border-radius: 12px 12px 0 0;
padding: 0.75rem 0.5rem;
gap: 0;
}
.sidebarLink {
padding: 0.5rem 1rem;
white-space: nowrap;
font-size: 0.8125rem;
}
.content {
padding: 1.25rem;
}
}

View file

@ -0,0 +1,79 @@
import { NavLink } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./CreatorDashboard.module.css";
function SidebarNav() {
const linkClass = ({ isActive }: { isActive: boolean }) =>
`${styles.sidebarLink}${isActive ? ` ${styles.sidebarLinkActive}` : ""}`;
return (
<nav className={styles.sidebar}>
<NavLink to="/creator/dashboard" className={linkClass} end>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
Dashboard
</NavLink>
<span
className={`${styles.sidebarLink} ${styles.sidebarLinkDisabled}`}
title="Coming soon"
aria-disabled="true"
>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Content
</span>
<NavLink to="/creator/settings" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Settings
</NavLink>
</nav>
);
}
export { SidebarNav };
export default function CreatorDashboard() {
useDocumentTitle("Creator Dashboard");
const { user } = useAuth();
return (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<h1 className={styles.welcome}>
Welcome back{user?.display_name ? `, ${user.display_name}` : ""}
</h1>
<div className={styles.cards}>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Content Stats</h2>
<p className={styles.cardBody}>
Content analytics coming in M020. You'll see views, engagement, and technique performance here.
</p>
</div>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Recent Activity</h2>
<p className={styles.cardBody}>
Activity feed coming soon. Track updates to your technique pages.
</p>
</div>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Quick Actions</h2>
<p className={styles.cardBody}>
Content management tools will appear here once the creator content module is live.
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,115 @@
.heading {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 1.5rem;
}
/* ── Sections ──────────────────────────────────────────────────────────────── */
.section {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.sectionTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 1rem;
}
/* ── Form elements ─────────────────────────────────────────────────────────── */
.fieldGroup {
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);
}
.inputReadonly {
composes: input;
opacity: 0.6;
cursor: not-allowed;
}
/* ── Button ────────────────────────────────────────────────────────────────── */
.button {
margin-top: 0.75rem;
padding: 0.625rem 1.25rem;
background: var(--color-accent);
color: #0f0f14;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
align-self: flex-start;
}
.button:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Feedback ──────────────────────────────────────────────────────────────── */
.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;
}
.divider {
border: none;
border-top: 1px solid var(--color-border);
margin: 1.25rem 0;
}

View file

@ -0,0 +1,174 @@
import { useState, type FormEvent } from "react";
import { useAuth, ApiError } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import { authUpdateProfile } from "../api/public-client";
import { SidebarNav } from "./CreatorDashboard";
import dashStyles from "./CreatorDashboard.module.css";
import styles from "./CreatorSettings.module.css";
export default function CreatorSettings() {
useDocumentTitle("Settings");
const { user, token } = useAuth();
// Profile fields
const [displayName, setDisplayName] = useState(user?.display_name ?? "");
const [profileMsg, setProfileMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [profileSubmitting, setProfileSubmitting] = useState(false);
// Password fields
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordMsg, setPasswordMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const handleProfileSubmit = async (e: FormEvent) => {
e.preventDefault();
setProfileMsg(null);
if (!displayName.trim()) {
setProfileMsg({ type: "error", text: "Display name cannot be empty." });
return;
}
if (!token) return;
setProfileSubmitting(true);
try {
await authUpdateProfile(token, { display_name: displayName.trim() });
setProfileMsg({ type: "success", text: "Profile updated." });
} catch (err) {
setProfileMsg({
type: "error",
text: err instanceof ApiError ? err.detail : "Failed to update profile.",
});
} finally {
setProfileSubmitting(false);
}
};
const handlePasswordSubmit = async (e: FormEvent) => {
e.preventDefault();
setPasswordMsg(null);
if (!currentPassword || !newPassword) {
setPasswordMsg({ type: "error", text: "Current and new password are required." });
return;
}
if (newPassword.length < 8) {
setPasswordMsg({ type: "error", text: "New password must be at least 8 characters." });
return;
}
if (newPassword !== confirmPassword) {
setPasswordMsg({ type: "error", text: "New passwords don't match." });
return;
}
if (!token) return;
setPasswordSubmitting(true);
try {
await authUpdateProfile(token, {
current_password: currentPassword,
new_password: newPassword,
});
setPasswordMsg({ type: "success", text: "Password changed successfully." });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setPasswordMsg({
type: "error",
text: err instanceof ApiError ? err.detail : "Failed to change password.",
});
} finally {
setPasswordSubmitting(false);
}
};
return (
<div className={dashStyles.layout}>
<SidebarNav />
<div className={dashStyles.content}>
<h1 className={styles.heading}>Settings</h1>
{/* Profile section */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Profile</h2>
{profileMsg && (
<div className={profileMsg.type === "error" ? styles.error : styles.success}>
{profileMsg.text}
</div>
)}
<form onSubmit={handleProfileSubmit} className={styles.fieldGroup}>
<label className={styles.label}>
Display Name
<input
type="text"
className={styles.input}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</label>
<label className={styles.label}>
Email
<input
type="email"
className={styles.inputReadonly}
value={user?.email ?? ""}
readOnly
tabIndex={-1}
/>
</label>
<button type="submit" className={styles.button} disabled={profileSubmitting}>
{profileSubmitting ? "Saving…" : "Save Profile"}
</button>
</form>
</div>
{/* Password section */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Change Password</h2>
{passwordMsg && (
<div className={passwordMsg.type === "error" ? styles.error : styles.success}>
{passwordMsg.text}
</div>
)}
<form onSubmit={handlePasswordSubmit} className={styles.fieldGroup}>
<label className={styles.label}>
Current Password
<input
type="password"
className={styles.input}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
<label className={styles.label}>
New Password
<input
type="password"
className={styles.input}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label className={styles.label}>
Confirm New Password
<input
type="password"
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<button type="submit" className={styles.button} disabled={passwordSubmitting}>
{passwordSubmitting ? "Changing…" : "Change Password"}
</button>
</form>
</div>
</div>
</div>
);
}

View file

@ -18,6 +18,7 @@ export default function Login() {
// Check for success message from registration redirect
const params = new URLSearchParams(location.search);
const registered = params.get("registered") === "1";
const returnTo = params.get("returnTo");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -31,7 +32,7 @@ export default function Login() {
setSubmitting(true);
try {
await login(email.trim(), password);
navigate("/creator/dashboard", { replace: true });
navigate(returnTo || "/creator/dashboard", { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);

View file

@ -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/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"}
{"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/ProtectedRoute.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/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.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"}