diff --git a/src/design-system/composites/LoginForm/LoginDialog.test.tsx b/src/design-system/composites/LoginForm/LoginDialog.test.tsx
new file mode 100644
index 0000000..6e486d0
--- /dev/null
+++ b/src/design-system/composites/LoginForm/LoginDialog.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { LoginDialog } from './LoginDialog'
+
+const defaultProps = {
+ open: true,
+ onClose: vi.fn(),
+ onSubmit: vi.fn(),
+}
+
+describe('LoginDialog', () => {
+ it('renders Modal with LoginForm when open', () => {
+ render()
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
+ })
+
+ it('does not render when closed', () => {
+ render()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('calls onClose on Esc', async () => {
+ const onClose = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.keyboard('{Escape}')
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('calls onClose on backdrop click', async () => {
+ const onClose = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByTestId('modal-backdrop'))
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('passes LoginForm props through', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('Welcome')).toBeInTheDocument()
+ expect(screen.getByText('Continue with Google')).toBeInTheDocument()
+ expect(screen.getByText('Bad credentials')).toBeInTheDocument()
+ })
+})
diff --git a/src/design-system/composites/LoginForm/LoginDialog.tsx b/src/design-system/composites/LoginForm/LoginDialog.tsx
new file mode 100644
index 0000000..cffd740
--- /dev/null
+++ b/src/design-system/composites/LoginForm/LoginDialog.tsx
@@ -0,0 +1,15 @@
+import { Modal } from '../Modal/Modal'
+import { LoginForm, type LoginFormProps } from './LoginForm'
+
+export interface LoginDialogProps extends LoginFormProps {
+ open: boolean
+ onClose: () => void
+}
+
+export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/design-system/composites/LoginForm/LoginForm.module.css b/src/design-system/composites/LoginForm/LoginForm.module.css
new file mode 100644
index 0000000..5c61959
--- /dev/null
+++ b/src/design-system/composites/LoginForm/LoginForm.module.css
@@ -0,0 +1,111 @@
+.loginForm {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-family: var(--font-body);
+ width: 100%;
+}
+
+.logo {
+ margin-bottom: 8px;
+}
+
+.title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 20px;
+}
+
+.error {
+ width: 100%;
+ margin-bottom: 16px;
+}
+
+.socialSection {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ margin-bottom: 20px;
+}
+
+.socialButton {
+ width: 100%;
+ justify-content: center;
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ margin-bottom: 20px;
+}
+
+.dividerLine {
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.dividerText {
+ color: var(--text-muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 500;
+}
+
+.fields {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ width: 100%;
+}
+
+.rememberRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.forgotLink {
+ font-size: 11px;
+ color: var(--amber);
+ font-weight: 500;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ font-family: var(--font-body);
+}
+
+.forgotLink:hover {
+ text-decoration: underline;
+}
+
+.submitButton {
+ width: 100%;
+}
+
+.signUpText {
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.signUpLink {
+ color: var(--amber);
+ font-weight: 500;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ font-family: var(--font-body);
+ font-size: 12px;
+}
+
+.signUpLink:hover {
+ text-decoration: underline;
+}
diff --git a/src/design-system/composites/LoginForm/LoginForm.test.tsx b/src/design-system/composites/LoginForm/LoginForm.test.tsx
new file mode 100644
index 0000000..a6b1e88
--- /dev/null
+++ b/src/design-system/composites/LoginForm/LoginForm.test.tsx
@@ -0,0 +1,193 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { LoginForm } from './LoginForm'
+
+const socialProviders = [
+ { label: 'Continue with Google', onClick: vi.fn() },
+ { label: 'Continue with GitHub', onClick: vi.fn() },
+]
+
+const allProps = {
+ logo:
Logo
,
+ title: 'Welcome back',
+ socialProviders,
+ onSubmit: vi.fn(),
+ onForgotPassword: vi.fn(),
+ onSignUp: vi.fn(),
+}
+
+describe('LoginForm', () => {
+ describe('rendering', () => {
+ it('renders all elements when all props provided', () => {
+ render()
+ expect(screen.getByTestId('logo')).toBeInTheDocument()
+ expect(screen.getByText('Welcome back')).toBeInTheDocument()
+ expect(screen.getByText('Continue with Google')).toBeInTheDocument()
+ expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
+ expect(screen.getByText('or')).toBeInTheDocument()
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
+ expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByText(/sign up/i)).toBeInTheDocument()
+ })
+
+ it('renders default title when title prop omitted', () => {
+ render()
+ expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
+ })
+
+ it('hides social section when socialProviders is empty', () => {
+ render()
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('hides social section when socialProviders is omitted', () => {
+ render()
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ })
+
+ it('hides forgot password link when onForgotPassword omitted', () => {
+ render()
+ expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
+ })
+
+ it('hides sign up link when onSignUp omitted', () => {
+ render()
+ expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
+ })
+
+ it('hides credentials section when onSubmit omitted (social only)', () => {
+ render()
+ expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
+ expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
+ expect(screen.queryByText('or')).not.toBeInTheDocument()
+ // Social buttons should still render
+ expect(screen.getByText('Continue with Google')).toBeInTheDocument()
+ })
+
+ it('shows server error Alert when error prop set', () => {
+ render()
+ expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
+ })
+ })
+
+ describe('validation', () => {
+ it('validates required email', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(screen.getByText('Email is required')).toBeInTheDocument()
+ })
+
+ it('validates email format', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.type(screen.getByLabelText(/email/i), 'notanemail')
+ await user.type(screen.getByLabelText(/password/i), 'password123')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument()
+ })
+
+ it('validates required password', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(screen.getByText('Password is required')).toBeInTheDocument()
+ })
+
+ it('validates password minimum length', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com')
+ await user.type(screen.getByLabelText(/password/i), 'short')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument()
+ })
+
+ it('clears field errors on typing', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(screen.getByText('Email is required')).toBeInTheDocument()
+ await user.type(screen.getByLabelText(/email/i), 't')
+ expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
+ })
+
+ it('calls onSubmit with credentials when valid', async () => {
+ const onSubmit = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com')
+ await user.type(screen.getByLabelText(/password/i), 'password123')
+ await user.click(screen.getByLabelText(/remember me/i))
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(onSubmit).toHaveBeenCalledWith({
+ email: 'test@example.com',
+ password: 'password123',
+ remember: true,
+ })
+ })
+
+ it('does not call onSubmit when validation fails', async () => {
+ const onSubmit = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('loading state', () => {
+ it('disables form inputs when loading', () => {
+ render()
+ expect(screen.getByLabelText(/email/i)).toBeDisabled()
+ expect(screen.getByLabelText(/password/i)).toBeDisabled()
+ expect(screen.getByLabelText(/remember me/i)).toBeDisabled()
+ })
+
+ it('shows spinner on submit button when loading', () => {
+ render()
+ const submitBtn = screen.getByRole('button', { name: /sign in/i })
+ expect(submitBtn).toBeDisabled()
+ // Button component renders Spinner when loading=true
+ expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
+ })
+
+ it('disables social buttons when loading', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled()
+ })
+ })
+
+ describe('callbacks', () => {
+ it('calls social provider onClick when clicked', async () => {
+ const onClick = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'Continue with Google' }))
+ expect(onClick).toHaveBeenCalledOnce()
+ })
+
+ it('calls onForgotPassword when link clicked', async () => {
+ const onForgotPassword = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByText(/forgot password/i))
+ expect(onForgotPassword).toHaveBeenCalledOnce()
+ })
+
+ it('calls onSignUp when link clicked', async () => {
+ const onSignUp = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByText(/sign up/i))
+ expect(onSignUp).toHaveBeenCalledOnce()
+ })
+ })
+})
diff --git a/src/design-system/composites/LoginForm/LoginForm.tsx b/src/design-system/composites/LoginForm/LoginForm.tsx
new file mode 100644
index 0000000..c4d9ed0
--- /dev/null
+++ b/src/design-system/composites/LoginForm/LoginForm.tsx
@@ -0,0 +1,223 @@
+import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
+import { Button } from '../../primitives/Button/Button'
+import { Input } from '../../primitives/Input/Input'
+import { Checkbox } from '../../primitives/Checkbox/Checkbox'
+import { FormField } from '../../primitives/FormField/FormField'
+import { Alert } from '../../primitives/Alert/Alert'
+import styles from './LoginForm.module.css'
+
+export interface SocialProvider {
+ label: string
+ icon?: ReactNode
+ onClick: () => void
+}
+
+export interface LoginFormProps {
+ logo?: ReactNode
+ title?: string
+ socialProviders?: SocialProvider[]
+ onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
+ onForgotPassword?: () => void
+ onSignUp?: () => void
+ error?: string
+ loading?: boolean
+ className?: string
+}
+
+interface FieldErrors {
+ email?: string
+ password?: string
+}
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+function validate(email: string, password: string): FieldErrors {
+ const errors: FieldErrors = {}
+ if (!email) {
+ errors.email = 'Email is required'
+ } else if (!EMAIL_REGEX.test(email)) {
+ errors.email = 'Please enter a valid email address'
+ }
+ if (!password) {
+ errors.password = 'Password is required'
+ } else if (password.length < 8) {
+ errors.password = 'Password must be at least 8 characters'
+ }
+ return errors
+}
+
+export function LoginForm({
+ logo,
+ title = 'Sign in',
+ socialProviders,
+ onSubmit,
+ onForgotPassword,
+ onSignUp,
+ error,
+ loading = false,
+ className,
+}: LoginFormProps) {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [remember, setRemember] = useState(false)
+ const [fieldErrors, setFieldErrors] = useState({})
+ const [submitted, setSubmitted] = useState(false)
+ const emailRef = useRef(null)
+
+ // Auto-focus first input on mount
+ useEffect(() => {
+ emailRef.current?.focus()
+ }, [])
+
+ // Reset submitted flag when error prop changes (new server error from re-attempt)
+ useEffect(() => {
+ if (error) setSubmitted(false)
+ }, [error])
+
+ // Server error is shown from prop, hidden after next submit attempt
+ const showServerError = error && !submitted
+
+ const hasSocial = socialProviders && socialProviders.length > 0
+ const hasCredentials = !!onSubmit
+ const showDivider = hasSocial && hasCredentials
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault()
+ setSubmitted(true)
+ const errors = validate(email, password)
+ setFieldErrors(errors)
+ if (Object.keys(errors).length === 0) {
+ onSubmit?.({ email, password, remember })
+ }
+ }
+
+ return (
+
+ {logo &&
{logo}
}
+
{title}
+
+ {showServerError && (
+
+ )}
+
+ {hasSocial && (
+
+ {socialProviders.map((provider) => (
+
+ ))}
+
+ )}
+
+ {showDivider && (
+
+ )}
+
+ {hasCredentials && (
+
+ )}
+
+ {!hasCredentials && onSignUp && (
+
+ Don't have an account?{' '}
+
+
+ )}
+
+ )
+}
diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts
index f2bb125..4a4ec9b 100644
--- a/src/design-system/composites/index.ts
+++ b/src/design-system/composites/index.ts
@@ -17,6 +17,10 @@ export { GroupCard } from './GroupCard/GroupCard'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
+export { LoginDialog } from './LoginForm/LoginDialog'
+export type { LoginDialogProps } from './LoginForm/LoginDialog'
+export { LoginForm } from './LoginForm/LoginForm'
+export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
export { MenuItem } from './MenuItem/MenuItem'
export { Modal } from './Modal/Modal'
export { MultiSelect } from './MultiSelect/MultiSelect'