Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ NODE_ENV=production

# ─── Database ──────────────────────────────────────────
DATABASE_URL=/app/data/cornerstone.db

# ─── Session ───────────────────────────────────────────
SESSION_DURATION=604800
SECURE_COOKIES=true
23 changes: 22 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { lazy } from 'react';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppShell } from './components/AppShell/AppShell';

const SetupPage = lazy(() => import('./pages/SetupPage/SetupPage'));
const LoginPage = lazy(() => import('./pages/LoginPage/LoginPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage/DashboardPage'));
const WorkItemsPage = lazy(() => import('./pages/WorkItemsPage/WorkItemsPage'));
const BudgetPage = lazy(() => import('./pages/BudgetPage/BudgetPage'));
Expand All @@ -14,6 +16,25 @@ export function App() {
return (
<BrowserRouter>
<Routes>
{/* Auth routes (no AppShell wrapper) */}
<Route
path="setup"
element={
<Suspense fallback={<div>Loading...</div>}>
<SetupPage />
</Suspense>
}
/>
<Route
path="login"
element={
<Suspense fallback={<div>Loading...</div>}>
<LoginPage />
</Suspense>
}
/>

{/* App routes (with AppShell wrapper) */}
<Route element={<AppShell />}>
<Route index element={<DashboardPage />} />
<Route path="work-items" element={<WorkItemsPage />} />
Expand Down
39 changes: 39 additions & 0 deletions client/src/lib/authApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { get, post } from './apiClient.js';
import type { UserResponse } from '@cornerstone/shared';

export interface AuthMeResponse {
user: UserResponse | null;
setupRequired: boolean;
oidcEnabled: boolean;
}

export interface SetupResponse {
user: UserResponse;
}

export interface LoginResponse {
user: UserResponse;
}

export interface SetupPayload {
email: string;
displayName: string;
password: string;
}

export interface LoginPayload {
email: string;
password: string;
}

export function getAuthMe(): Promise<AuthMeResponse> {
return get<AuthMeResponse>('/auth/me');
}

export function setup(payload: SetupPayload): Promise<SetupResponse> {
return post<SetupResponse>('/auth/setup', payload);
}

export function login(payload: LoginPayload): Promise<LoginResponse> {
return post<LoginResponse>('/auth/login', payload);
}
142 changes: 142 additions & 0 deletions client/src/pages/LoginPage/LoginPage.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
.container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f9fafb;
padding: 1rem;
}

.card {
background: white;
border-radius: 0.5rem;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 2px rgba(0, 0, 0, 0.06);
padding: 2rem;
width: 100%;
max-width: 28rem;
}

.title {
font-size: 1.875rem;
font-weight: 700;
color: #111827;
margin-bottom: 0.5rem;
text-align: center;
}

.description {
font-size: 0.875rem;
color: #4b5563;
text-align: center;
margin-bottom: 1.5rem;
}

.errorBanner {
background-color: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
color: #991b1b;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}

.successBanner {
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.375rem;
color: #166534;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}

.form {
display: flex;
flex-direction: column;
gap: 1rem;
}

.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.label {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
}

.input {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #111827;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}

.input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.input:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}

.input[aria-invalid='true'] {
border-color: #ef4444;
}

.error {
font-size: 0.75rem;
color: #dc2626;
}

.button {
margin-top: 0.5rem;
padding: 0.625rem 1rem;
background-color: #3b82f6;
color: white;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition:
background-color 0.15s ease,
box-shadow 0.15s ease;
}

.button:hover:not(:disabled) {
background-color: #2563eb;
}

.button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}

.button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}

@media (max-width: 767px) {
.card {
padding: 1.5rem;
}

.title {
font-size: 1.5rem;
}
}
137 changes: 137 additions & 0 deletions client/src/pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useState, type FormEvent } from 'react';
import { login } from '../../lib/authApi.js';
import { ApiClientError } from '../../lib/apiClient.js';
import styles from './LoginPage.module.css';

interface FormErrors {
email?: string;
password?: string;
}

export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [apiError, setApiError] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [successMessage, setSuccessMessage] = useState<string>('');

const validateForm = (): boolean => {
const newErrors: FormErrors = {};

if (!email) {
newErrors.email = 'Email is required';
}

if (!password) {
newErrors.password = 'Password is required';
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setApiError('');
setSuccessMessage('');

if (!validateForm()) {
return;
}

setIsSubmitting(true);

try {
const response = await login({ email, password });
setSuccessMessage(
`Login successful! Welcome back, ${response.user.displayName}. Session management will be implemented in Story #32.`,
);
// Clear form
setEmail('');
setPassword('');
setErrors({});
} catch (error) {
if (error instanceof ApiClientError) {
setApiError(error.error.message);
} else {
setApiError('An unexpected error occurred. Please try again.');
}
} finally {
setIsSubmitting(false);
}
};

return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Sign In</h1>
<p className={styles.description}>Sign in to your Cornerstone account.</p>

{apiError && (
<div className={styles.errorBanner} role="alert">
{apiError}
</div>
)}

{successMessage && (
<div className={styles.successBanner} role="alert">
{successMessage}
</div>
)}

<form onSubmit={handleSubmit} className={styles.form} noValidate>
<div className={styles.field}>
<label htmlFor="email" className={styles.label}>
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={styles.input}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
disabled={isSubmitting}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" className={styles.error} role="alert">
{errors.email}
</span>
)}
</div>

<div className={styles.field}>
<label htmlFor="password" className={styles.label}>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={styles.input}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
disabled={isSubmitting}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" className={styles.error} role="alert">
{errors.password}
</span>
)}
</div>

<button type="submit" className={styles.button} disabled={isSubmitting}>
{isSubmitting ? 'Signing In...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}

export default LoginPage;
Loading