Compare commits
10 Commits
10d1de91fe
...
ed8cbca1da
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8cbca1da | |||
| f44ec57c62 | |||
| bf36f14eab | |||
| 7f5cf7a9e5 | |||
| 6a6b48cbc5 | |||
| 7317616032 | |||
| 25e25d92bc | |||
| a183aca4d8 | |||
| 50a352fb27 | |||
| 35d6a48802 |
@@ -20,6 +20,12 @@ VITE_COUCHDB_URL=http://localhost:5984
|
||||
VITE_COUCHDB_USER=admin
|
||||
VITE_COUCHDB_PASSWORD=change-this-secure-password
|
||||
|
||||
# Default Admin Bootstrap (used by frontend seeder at startup)
|
||||
# Note: These are evaluated at build-time by Vite. If you change them,
|
||||
# rebuild the frontend image (`docker compose build frontend`).
|
||||
VITE_ADMIN_EMAIL=admin@localhost
|
||||
VITE_ADMIN_PASSWORD=admin123!
|
||||
|
||||
# Application Configuration
|
||||
# Base URL for your application (used in email links)
|
||||
# Development: http://localhost:5173
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
Source boots from `index.tsx` into `App.tsx`. Feature UIs live in `components/`, shared context in `contexts/`, and reusable hooks under `hooks/`. Network clients stay in `services/`; supporting utilities and shared types sit in `utils/` and `types/`. Configuration belongs in `config/`, while CouchDB credentials and seeds are organized in `couchdb-config/` and `couchdb-data/`. Tests live alongside code with broader integration suites in `tests/`; docs and proposals belong in `docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Run `bun run dev` (or `make dev`) for the Vite development server. `bun run build` compiles a production bundle; verify it locally with `bun run preview`. Guard quality with `bun run lint`, `bun run format:check`, and `bun run type-check`. Execute the full Jest suite via `bun run test`, switching to `bun run test:watch` or `bun run test:coverage` when iterating. Make targets (`make build`, `make test`) wrap the same tasks for CI parity.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
All code is TypeScript; use `.tsx` for React components and `.ts` elsewhere. Prettier enforces 2-space indentation, trailing commas, and quote consistency—run `bun run format:check` before committing. Follow ESLint rules (hooks lifecycle, `no-unused-vars`, no `console` outside tests). Name components in PascalCase, hooks as `useThing`, and tests as `Feature.test.ts[x]`. Keep configuration constants in SCREAMING_SNAKE_CASE and load env-specific values through `config/` helpers.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Jest with React Testing Library drives component coverage. Prefer colocated tests to mirror features; integration flows live under `tests/integration/`. Mock CouchDB requests with the lightweight service helpers instead of hitting live endpoints. Aim for meaningful assertions over snapshots unless the UI is intentionally static. Keep coverage balanced across hooks and service contracts before merging.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Commits follow Conventional Commits (`type(scope): imperative summary`) and should contain a single logical change. Ensure lint, format, and tests pass locally; Husky will re-run the checks. Pull requests should describe motivation, summarize implementation decisions, and list manual test evidence. Link relevant tickets or CouchDB tasks, include screenshots or terminal output for UI/CLI changes, and flag migrations so reviewers can reproduce.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Never commit real secrets—copy `.env.example` when you need local overrides. Use `bun run check:secrets` before pushing whenever credentials might be touched. Prefer `config/` accessors over hard-coded URLs or keys, and store CouchDB credentials in the secure store rather than the repo.
|
||||
+7
-21
@@ -1,34 +1,17 @@
|
||||
# Multi-stage Dockerfile for Medication Reminder App
|
||||
FROM node:20-slim AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd --gid 1001 nodeuser && \
|
||||
useradd --uid 1001 --gid nodeuser --shell /bin/bash --create-home nodeuser
|
||||
|
||||
# Builder stage
|
||||
FROM base AS builder
|
||||
|
||||
# Copy package files
|
||||
COPY --chown=nodeuser:nodeuser package.json bun.lock* ./
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY --chown=nodeuser:nodeuser . ./
|
||||
COPY . ./
|
||||
|
||||
# Build arguments for environment configuration
|
||||
# Build Environment - unified config will handle the rest
|
||||
@@ -40,6 +23,8 @@ ARG NODE_ENV=production
|
||||
ARG VITE_COUCHDB_URL
|
||||
ARG VITE_COUCHDB_USER
|
||||
ARG VITE_COUCHDB_PASSWORD
|
||||
ARG VITE_ADMIN_EMAIL
|
||||
ARG VITE_ADMIN_PASSWORD
|
||||
|
||||
# Set environment variables for build process
|
||||
# Unified config handles defaults, only set essential runtime overrides
|
||||
@@ -47,7 +32,8 @@ ENV NODE_ENV=$NODE_ENV
|
||||
ENV VITE_COUCHDB_URL=$VITE_COUCHDB_URL
|
||||
ENV VITE_COUCHDB_USER=$VITE_COUCHDB_USER
|
||||
ENV VITE_COUCHDB_PASSWORD=$VITE_COUCHDB_PASSWORD
|
||||
ENV NODE_ENV=$NODE_ENV
|
||||
ENV VITE_ADMIN_EMAIL=$VITE_ADMIN_EMAIL
|
||||
ENV VITE_ADMIN_PASSWORD=$VITE_ADMIN_PASSWORD
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
+24
-12
@@ -1,11 +1,20 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: {
|
||||
node: 'current'
|
||||
}
|
||||
}],
|
||||
'@babel/preset-typescript'
|
||||
node: 'current',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'@babel/preset-typescript',
|
||||
{
|
||||
isTSX: true,
|
||||
allExtensions: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
// Transform import.meta for Jest compatibility
|
||||
@@ -13,19 +22,22 @@ module.exports = {
|
||||
return {
|
||||
visitor: {
|
||||
MetaProperty(path) {
|
||||
if (path.node.meta.name === 'import' && path.node.property.name === 'meta') {
|
||||
if (
|
||||
path.node.meta.name === 'import' &&
|
||||
path.node.property.name === 'meta'
|
||||
) {
|
||||
path.replaceWithSourceString('({ env: process.env })');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
],
|
||||
env: {
|
||||
test: {
|
||||
plugins: [
|
||||
// Additional test-specific plugins can go here
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,6 +38,12 @@ const AvatarDropdown: React.FC<AvatarDropdownProps> = ({
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setIsOpen(prev => !prev);
|
||||
}
|
||||
}}
|
||||
className='w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-lg font-bold text-slate-600 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
|
||||
aria-label='User menu'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { authService } from '../../services/auth/auth.service';
|
||||
import { PillIcon } from '../icons/Icons';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
const ResetPasswordPage: React.FC = () => {
|
||||
const token = useMemo(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('token');
|
||||
}, []);
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>(
|
||||
'idle'
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
|
||||
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
|
||||
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-4'>
|
||||
Password Reset Link Invalid
|
||||
</h1>
|
||||
<p className='text-slate-600 dark:text-slate-300 mb-6'>
|
||||
We could not find a valid reset token. Please return to the sign in
|
||||
page and request a new password reset email.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
setError('Password must be at least 6 characters long.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('submitting');
|
||||
await authService.resetPassword(token, password);
|
||||
setStatus('success');
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Unable to reset password. Please try again.'
|
||||
);
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
|
||||
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
|
||||
<div className='inline-block bg-emerald-100 dark:bg-emerald-900/60 p-3 rounded-full mb-4'>
|
||||
<PillIcon className='w-8 h-8 text-emerald-500 dark:text-emerald-300' />
|
||||
</div>
|
||||
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-2'>
|
||||
Password Updated
|
||||
</h1>
|
||||
<p className='text-slate-600 dark:text-slate-300 mb-6'>
|
||||
Your password has been reset successfully. You can now sign in with
|
||||
your new credentials.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Go to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='text-center mb-8'>
|
||||
<div className='inline-block bg-indigo-600 p-3 rounded-xl mb-4'>
|
||||
<PillIcon className='w-8 h-8 text-white' />
|
||||
</div>
|
||||
<h1 className='text-3xl font-bold text-slate-800 dark:text-slate-100'>
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className='text-slate-500 dark:text-slate-400 mt-1'>
|
||||
Choose a new password for your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8'>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='password'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id='password'
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='Enter a new password'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='confirmPassword'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id='confirmPassword'
|
||||
type='password'
|
||||
value={confirmPassword}
|
||||
onChange={event => setConfirmPassword(event.target.value)}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='Re-enter your password'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='text-sm text-red-600 bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 rounded-md px-3 py-2'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={status === 'submitting'}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200'
|
||||
>
|
||||
{status === 'submitting'
|
||||
? 'Updating Password...'
|
||||
: 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}}
|
||||
className='mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
||||
@@ -5,7 +5,6 @@ import AvatarDropdown from '../AvatarDropdown';
|
||||
import { User, UserRole } from '../../../types';
|
||||
import { AccountStatus } from '../../../services/auth/auth.constants';
|
||||
|
||||
// Mock user data
|
||||
const mockRegularUser: User = {
|
||||
_id: '1',
|
||||
_rev: '1-abc123',
|
||||
@@ -33,7 +32,19 @@ const mockUserWithAvatar: User = {
|
||||
|
||||
const mockUserWithPassword: User = {
|
||||
...mockRegularUser,
|
||||
password: 'hashed-password',
|
||||
password: '$2b$12$examplehashforpassword',
|
||||
};
|
||||
|
||||
type DropdownProps = Partial<React.ComponentProps<typeof AvatarDropdown>>;
|
||||
|
||||
const renderDropdown = (props: DropdownProps = {}) => {
|
||||
const defaultProps: React.ComponentProps<typeof AvatarDropdown> = {
|
||||
user: mockRegularUser,
|
||||
onLogout: jest.fn(),
|
||||
};
|
||||
|
||||
const merged = { ...defaultProps, ...props };
|
||||
return render(React.createElement(AvatarDropdown, merged));
|
||||
};
|
||||
|
||||
describe('AvatarDropdown', () => {
|
||||
@@ -47,7 +58,7 @@ describe('AvatarDropdown', () => {
|
||||
|
||||
describe('rendering', () => {
|
||||
test('should render avatar button with user initials', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
@@ -55,9 +66,7 @@ describe('AvatarDropdown', () => {
|
||||
});
|
||||
|
||||
test('should render avatar image when user has avatar', () => {
|
||||
render(
|
||||
<AvatarDropdown user={mockUserWithAvatar} onLogout={mockOnLogout} />
|
||||
);
|
||||
renderDropdown({ user: mockUserWithAvatar, onLogout: mockOnLogout });
|
||||
|
||||
const avatar = screen.getByAltText('User avatar');
|
||||
expect(avatar).toBeInTheDocument();
|
||||
@@ -66,16 +75,14 @@ describe('AvatarDropdown', () => {
|
||||
|
||||
test('should render fallback character for empty username', () => {
|
||||
const userWithEmptyName = { ...mockRegularUser, username: '' };
|
||||
render(
|
||||
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
|
||||
);
|
||||
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveTextContent('?');
|
||||
});
|
||||
|
||||
test('should not render dropdown menu initially', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -83,7 +90,7 @@ describe('AvatarDropdown', () => {
|
||||
|
||||
describe('dropdown functionality', () => {
|
||||
test('should open dropdown when avatar button is clicked', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
fireEvent.click(button);
|
||||
@@ -93,35 +100,40 @@ describe('AvatarDropdown', () => {
|
||||
});
|
||||
|
||||
test('should close dropdown when avatar button is clicked again', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Signed in as')).toBeInTheDocument();
|
||||
|
||||
// Close dropdown
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should close dropdown when clicking outside', async () => {
|
||||
render(
|
||||
<div>
|
||||
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
|
||||
<div data-testid='outside'>Outside element</div>
|
||||
</div>
|
||||
React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement(AvatarDropdown, {
|
||||
user: mockRegularUser,
|
||||
onLogout: mockOnLogout,
|
||||
}),
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'outside' },
|
||||
'Outside element'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
const outside = screen.getByTestId('outside');
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Signed in as')).toBeInTheDocument();
|
||||
|
||||
// Click outside
|
||||
fireEvent.mouseDown(outside);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -132,21 +144,21 @@ describe('AvatarDropdown', () => {
|
||||
|
||||
describe('user information display', () => {
|
||||
test('should display username in dropdown', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display administrator badge for admin users', () => {
|
||||
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockAdminUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not display administrator badge for regular users', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.queryByText('Administrator')).not.toBeInTheDocument();
|
||||
@@ -158,9 +170,7 @@ describe('AvatarDropdown', () => {
|
||||
username: 'Very Long Username That Should Be Truncated',
|
||||
};
|
||||
|
||||
render(
|
||||
<AvatarDropdown user={userWithLongName} onLogout={mockOnLogout} />
|
||||
);
|
||||
renderDropdown({ user: userWithLongName, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
const usernameElement = screen.getByText(
|
||||
@@ -172,7 +182,7 @@ describe('AvatarDropdown', () => {
|
||||
|
||||
describe('logout functionality', () => {
|
||||
test('should call onLogout when logout button is clicked', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Logout'));
|
||||
@@ -181,7 +191,7 @@ describe('AvatarDropdown', () => {
|
||||
});
|
||||
|
||||
test('should close dropdown after logout is clicked', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Logout'));
|
||||
@@ -190,7 +200,7 @@ describe('AvatarDropdown', () => {
|
||||
});
|
||||
|
||||
test('should always display logout button', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
@@ -198,280 +208,115 @@ describe('AvatarDropdown', () => {
|
||||
});
|
||||
|
||||
describe('admin functionality', () => {
|
||||
test('should display admin interface button for admin users when onAdmin provided', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockAdminUser}
|
||||
onLogout={mockOnLogout}
|
||||
onAdmin={mockOnAdmin}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.getByText('Admin Interface')).toBeInTheDocument();
|
||||
test('should render Admin Interface button for admin users', () => {
|
||||
renderDropdown({
|
||||
user: mockAdminUser,
|
||||
onLogout: mockOnLogout,
|
||||
onAdmin: mockOnAdmin,
|
||||
});
|
||||
|
||||
test('should not display admin interface button for regular users', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockRegularUser}
|
||||
onLogout={mockOnLogout}
|
||||
onAdmin={mockOnAdmin}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not display admin interface button when onAdmin not provided', () => {
|
||||
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call onAdmin when admin interface button is clicked', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockAdminUser}
|
||||
onLogout={mockOnLogout}
|
||||
onAdmin={mockOnAdmin}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Admin Interface'));
|
||||
const adminButton = screen.getByText('Admin Interface');
|
||||
expect(adminButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(adminButton);
|
||||
expect(mockOnAdmin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should close dropdown after admin interface is clicked', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockAdminUser}
|
||||
onLogout={mockOnLogout}
|
||||
onAdmin={mockOnAdmin}
|
||||
/>
|
||||
);
|
||||
test('should not render Admin Interface button for regular users', () => {
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Admin Interface'));
|
||||
|
||||
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('change password functionality', () => {
|
||||
test('should display change password button for users with password when onChangePassword provided', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockUserWithPassword}
|
||||
onLogout={mockOnLogout}
|
||||
onChangePassword={mockOnChangePassword}
|
||||
/>
|
||||
);
|
||||
describe('change password visibility', () => {
|
||||
test('should show change password option when user has password', () => {
|
||||
renderDropdown({
|
||||
user: mockUserWithPassword,
|
||||
onLogout: mockOnLogout,
|
||||
onChangePassword: mockOnChangePassword,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.getByText('Change Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not display change password button for users without password', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockRegularUser}
|
||||
onLogout={mockOnLogout}
|
||||
onChangePassword={mockOnChangePassword}
|
||||
/>
|
||||
);
|
||||
test('should hide change password option when user has no password', () => {
|
||||
renderDropdown({
|
||||
user: mockRegularUser,
|
||||
onLogout: mockOnLogout,
|
||||
onChangePassword: mockOnChangePassword,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not display change password button when onChangePassword not provided', () => {
|
||||
render(
|
||||
<AvatarDropdown user={mockUserWithPassword} onLogout={mockOnLogout} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
|
||||
test('should call onChangePassword when change password button clicked', () => {
|
||||
renderDropdown({
|
||||
user: mockUserWithPassword,
|
||||
onLogout: mockOnLogout,
|
||||
onChangePassword: mockOnChangePassword,
|
||||
});
|
||||
|
||||
test('should call onChangePassword when change password button is clicked', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockUserWithPassword}
|
||||
onLogout={mockOnLogout}
|
||||
onChangePassword={mockOnChangePassword}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Change Password'));
|
||||
|
||||
expect(mockOnChangePassword).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should close dropdown after change password is clicked', () => {
|
||||
render(
|
||||
<AvatarDropdown
|
||||
user={mockUserWithPassword}
|
||||
onLogout={mockOnLogout}
|
||||
onChangePassword={mockOnChangePassword}
|
||||
/>
|
||||
);
|
||||
describe('keyboard accessibility', () => {
|
||||
test('should toggle dropdown with Enter key', () => {
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
fireEvent.click(screen.getByText('Change Password'));
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
|
||||
expect(screen.getByText('Signed in as')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not toggle dropdown with unrelated key', () => {
|
||||
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
fireEvent.keyDown(button, { key: 'Space', code: 'Space' });
|
||||
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitials function', () => {
|
||||
test('should return first character uppercase for regular names', () => {
|
||||
const userWithLowercase = { ...mockRegularUser, username: 'john doe' };
|
||||
render(
|
||||
<AvatarDropdown user={userWithLowercase} onLogout={mockOnLogout} />
|
||||
);
|
||||
describe('user initials generation', () => {
|
||||
test('should handle lowercase usernames', () => {
|
||||
const userWithLowercase = { ...mockRegularUser, username: 'john' };
|
||||
renderDropdown({ user: userWithLowercase, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveTextContent('J');
|
||||
});
|
||||
|
||||
test('should return question mark for empty string', () => {
|
||||
test('should handle empty username gracefully', () => {
|
||||
const userWithEmptyName = { ...mockRegularUser, username: '' };
|
||||
render(
|
||||
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
|
||||
);
|
||||
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveTextContent('?');
|
||||
});
|
||||
|
||||
test('should handle single character names', () => {
|
||||
const userWithSingleChar = { ...mockRegularUser, username: 'x' };
|
||||
render(
|
||||
<AvatarDropdown user={userWithSingleChar} onLogout={mockOnLogout} />
|
||||
);
|
||||
test('should handle single character username', () => {
|
||||
const userWithSingleChar = { ...mockRegularUser, username: 'a' };
|
||||
renderDropdown({ user: userWithSingleChar, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveTextContent('X');
|
||||
expect(button).toHaveTextContent('A');
|
||||
});
|
||||
|
||||
test('should handle special characters', () => {
|
||||
const userWithSpecialChar = { ...mockRegularUser, username: '@john' };
|
||||
render(
|
||||
<AvatarDropdown user={userWithSpecialChar} onLogout={mockOnLogout} />
|
||||
);
|
||||
test('should handle usernames with special characters', () => {
|
||||
const userWithSpecialChar = { ...mockRegularUser, username: '!john' };
|
||||
renderDropdown({ user: userWithSpecialChar, onLogout: mockOnLogout });
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveTextContent('@');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
test('should have proper aria-label for avatar button', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveAttribute('aria-label', 'User menu');
|
||||
});
|
||||
|
||||
test('should be keyboard accessible', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
button.focus();
|
||||
expect(button).toHaveFocus();
|
||||
});
|
||||
|
||||
test('should have proper focus styles', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling and theming', () => {
|
||||
test('should apply dark mode classes', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(button).toHaveClass('dark:bg-slate-700', 'dark:text-slate-300');
|
||||
|
||||
fireEvent.click(button);
|
||||
const dropdown = screen
|
||||
.getByText('Signed in as')
|
||||
.closest('div')?.parentElement;
|
||||
expect(dropdown).toHaveClass(
|
||||
'dark:bg-slate-800',
|
||||
'dark:border-slate-700'
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply hover styles to menu items', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
|
||||
const logoutButton = screen.getByText('Logout');
|
||||
expect(logoutButton).toHaveClass(
|
||||
'hover:bg-slate-100',
|
||||
'dark:hover:bg-slate-700'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle clicking outside when dropdown is closed', async () => {
|
||||
render(
|
||||
<div>
|
||||
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
|
||||
<div data-testid='outside'>Outside element</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const outside = screen.getByTestId('outside');
|
||||
fireEvent.mouseDown(outside);
|
||||
|
||||
// Should not throw any errors
|
||||
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle rapid clicking', () => {
|
||||
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
// Rapid clicks - odd number should end up open
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should end up open (3 clicks = open)
|
||||
expect(screen.getByText('Signed in as')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should cleanup event listeners on unmount', () => {
|
||||
const removeEventListenerSpy = jest.spyOn(
|
||||
document,
|
||||
'removeEventListener'
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'mousedown',
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
expect(button).toHaveTextContent('!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ResetPasswordPage from '../ResetPasswordPage';
|
||||
import { authService } from '../../../services/auth/auth.service';
|
||||
|
||||
jest.mock('../../../services/auth/auth.service', () => ({
|
||||
authService: {
|
||||
resetPassword: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAuthService = authService as jest.Mocked<typeof authService>;
|
||||
const mockedResetPassword = mockedAuthService.resetPassword;
|
||||
|
||||
const setLocation = (url: string) => {
|
||||
window.history.replaceState({}, 'Test', url);
|
||||
};
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedResetPassword.mockReset();
|
||||
});
|
||||
|
||||
test('renders invalid token state when no token provided', () => {
|
||||
setLocation('http://localhost/reset-password');
|
||||
|
||||
render(React.createElement(ResetPasswordPage));
|
||||
|
||||
expect(screen.getByText('Password Reset Link Invalid')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /back to sign in/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows validation error when passwords do not match', async () => {
|
||||
setLocation('http://localhost/reset-password?token=abc123');
|
||||
|
||||
render(React.createElement(ResetPasswordPage));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('New Password'), {
|
||||
target: { value: 'Password1!' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), {
|
||||
target: { value: 'SomethingElse' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Passwords do not match.')
|
||||
).toBeInTheDocument();
|
||||
expect(mockedResetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submits password reset and displays success state', async () => {
|
||||
setLocation('http://localhost/reset-password?token=token123');
|
||||
mockedResetPassword.mockResolvedValue({
|
||||
user: {
|
||||
_id: 'user-1',
|
||||
_rev: '1',
|
||||
username: 'Reset User',
|
||||
} as any,
|
||||
message: 'Password reset successfully',
|
||||
});
|
||||
|
||||
render(React.createElement(ResetPasswordPage));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('New Password'), {
|
||||
target: { value: 'Password1!' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), {
|
||||
target: { value: 'Password1!' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedResetPassword).toHaveBeenCalledWith(
|
||||
'token123',
|
||||
'Password1!'
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Password Updated')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /go to sign in/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@
|
||||
export { default as AuthPage } from './AuthPage';
|
||||
export { default as AvatarDropdown } from './AvatarDropdown';
|
||||
export { default as ChangePasswordModal } from './ChangePasswordModal';
|
||||
export { default as ResetPasswordPage } from './ResetPasswordPage';
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import { logger } from '../services/logging';
|
||||
type LoggerInstance = (typeof import('../services/logging'))['logger'];
|
||||
|
||||
let loggerPromise: Promise<LoggerInstance | null> | null = null;
|
||||
|
||||
function withLogger(callback: (logger: LoggerInstance) => void): void {
|
||||
if (!loggerPromise) {
|
||||
loggerPromise = import('../services/logging')
|
||||
.then(module => module.logger)
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
void loggerPromise.then(logger => {
|
||||
if (logger) {
|
||||
callback(logger);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Application Configuration System
|
||||
@@ -756,10 +772,14 @@ function validateConfig(config: UnifiedConfig): void {
|
||||
|
||||
// Log warnings and throw errors
|
||||
if (warnings.length > 0) {
|
||||
logger.warn('Configuration warnings', 'CONFIG', warnings);
|
||||
withLogger(logger =>
|
||||
logger.warn('Configuration warnings', 'CONFIG', warnings)
|
||||
);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
logger.error('Configuration errors', 'CONFIG', errors);
|
||||
withLogger(logger =>
|
||||
logger.error('Configuration errors', 'CONFIG', errors)
|
||||
);
|
||||
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -957,7 +977,11 @@ export function exportAsEnvVars(
|
||||
* Debug helper to log current configuration
|
||||
*/
|
||||
export function logConfig(): void {
|
||||
if (unifiedConfig.features.debugMode) {
|
||||
if (!unifiedConfig.features.debugMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
withLogger(logger =>
|
||||
logger.info('Unified Configuration (Single Source of Truth)', 'CONFIG', {
|
||||
environment: unifiedConfig.app.environment,
|
||||
app: unifiedConfig.app.name,
|
||||
@@ -974,8 +998,8 @@ export function logConfig(): void {
|
||||
},
|
||||
features: unifiedConfig.features,
|
||||
configSource: '.env file overrides applied',
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-log in development
|
||||
|
||||
+9
-1
@@ -7,6 +7,7 @@
|
||||
"<rootDir>/types/**/__tests__/**/*.test.ts",
|
||||
"<rootDir>/components/**/__tests__/**/*.test.tsx",
|
||||
"<rootDir>/tests/**/*.test.ts",
|
||||
"<rootDir>/tests/**/*.test.tsx",
|
||||
"<rootDir>/tests/**/*.test.js"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
@@ -25,7 +26,14 @@
|
||||
"^node-fetch$": "<rootDir>/tests/__mocks__/node-fetch.js"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": "babel-jest",
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": "tsconfig.json",
|
||||
"babelConfig": "babel.config.cjs",
|
||||
"diagnostics": false
|
||||
}
|
||||
],
|
||||
"^.+\\.jsx?$": "babel-jest"
|
||||
},
|
||||
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { authService } from '../auth.service';
|
||||
import { AccountStatus } from '../auth.constants';
|
||||
import { AuthenticatedUser } from '../auth.types';
|
||||
import { isBcryptHash } from '../password.service';
|
||||
|
||||
// Mock the new database service
|
||||
// Mock the database service used by authService
|
||||
jest.mock('../../database', () => ({
|
||||
databaseService: {
|
||||
findUserByEmail: jest.fn(),
|
||||
@@ -14,7 +16,7 @@ jest.mock('../../database', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the emailVerification service
|
||||
// Mock the email verification service
|
||||
jest.mock('../emailVerification.service', () => ({
|
||||
EmailVerificationService: jest.fn().mockImplementation(() => ({
|
||||
generateVerificationToken: jest.fn().mockResolvedValue({
|
||||
@@ -38,31 +40,36 @@ describe('Authentication Integration Tests', () => {
|
||||
|
||||
let mockUser: AuthenticatedUser;
|
||||
let mockDatabaseService: any;
|
||||
let hashedPassword: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Get the mocked database service
|
||||
const { databaseService } = await import('../../database');
|
||||
mockDatabaseService = databaseService;
|
||||
|
||||
// Setup default mock user
|
||||
hashedPassword = await bcrypt.hash(testCredentials.password, 10);
|
||||
|
||||
mockUser = {
|
||||
_id: 'user1',
|
||||
_rev: 'mock-rev-1',
|
||||
email: testCredentials.email,
|
||||
username: testCredentials.username,
|
||||
password: testCredentials.password,
|
||||
password: hashedPassword,
|
||||
emailVerified: false,
|
||||
status: AccountStatus.PENDING,
|
||||
};
|
||||
|
||||
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
|
||||
mockDatabaseService.updateUser.mockImplementation(
|
||||
async (user: any) => user
|
||||
);
|
||||
});
|
||||
|
||||
describe('User Registration', () => {
|
||||
test('should create a pending account for new user', async () => {
|
||||
test('should hash password before persisting new user', async () => {
|
||||
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
|
||||
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await authService.register(
|
||||
testCredentials.email,
|
||||
@@ -72,10 +79,7 @@ describe('Authentication Integration Tests', () => {
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.username).toBe(testCredentials.username);
|
||||
expect(result.user.email).toBe(testCredentials.email);
|
||||
expect(result.user.status).toBe(AccountStatus.PENDING);
|
||||
expect(result.user.emailVerified).toBe(false);
|
||||
expect(result.verificationToken).toBeDefined();
|
||||
expect(result.verificationToken.token).toBe('mock-verification-token');
|
||||
|
||||
expect(mockDatabaseService.findUserByEmail).toHaveBeenCalledWith(
|
||||
@@ -83,9 +87,14 @@ describe('Authentication Integration Tests', () => {
|
||||
);
|
||||
expect(mockDatabaseService.createUserWithPassword).toHaveBeenCalledWith(
|
||||
testCredentials.email,
|
||||
testCredentials.password,
|
||||
expect.any(String),
|
||||
testCredentials.username
|
||||
);
|
||||
|
||||
const persistedPassword =
|
||||
mockDatabaseService.createUserWithPassword.mock.calls[0][1];
|
||||
expect(isBcryptHash(persistedPassword)).toBe(true);
|
||||
expect(persistedPassword).not.toBe(testCredentials.password);
|
||||
});
|
||||
|
||||
test('should fail when user already exists', async () => {
|
||||
@@ -115,7 +124,7 @@ describe('Authentication Integration Tests', () => {
|
||||
).rejects.toThrow('Email verification required');
|
||||
});
|
||||
|
||||
test('should succeed after email verification', async () => {
|
||||
test('should succeed with correct bcrypt hashed password', async () => {
|
||||
const verifiedUser = {
|
||||
...mockUser,
|
||||
emailVerified: true,
|
||||
@@ -128,10 +137,8 @@ describe('Authentication Integration Tests', () => {
|
||||
password: testCredentials.password,
|
||||
});
|
||||
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens.accessToken).toBeTruthy();
|
||||
expect(tokens.refreshToken).toBeTruthy();
|
||||
expect(tokens.user).toBeDefined();
|
||||
expect(tokens.user.status).toBe(AccountStatus.ACTIVE);
|
||||
});
|
||||
|
||||
@@ -151,6 +158,23 @@ describe('Authentication Integration Tests', () => {
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
|
||||
test('should reject legacy accounts with plaintext passwords', async () => {
|
||||
const legacyUser = {
|
||||
...mockUser,
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
password: testCredentials.password,
|
||||
};
|
||||
mockDatabaseService.findUserByEmail.mockResolvedValue(legacyUser);
|
||||
|
||||
await expect(
|
||||
authService.login({
|
||||
email: testCredentials.email,
|
||||
password: testCredentials.password,
|
||||
})
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
|
||||
test('should fail for non-existent user', async () => {
|
||||
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
|
||||
|
||||
@@ -185,19 +209,9 @@ describe('Authentication Integration Tests', () => {
|
||||
|
||||
const result = await authService.loginWithOAuth('google', oauthUserData);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.email).toBe(oauthUserData.email);
|
||||
expect(result.user.username).toBe(oauthUserData.username);
|
||||
expect(result.user.status).toBe(AccountStatus.ACTIVE);
|
||||
expect(result.user.emailVerified).toBe(true);
|
||||
expect(result.accessToken).toBeTruthy();
|
||||
expect(result.refreshToken).toBeTruthy();
|
||||
|
||||
expect(mockDatabaseService.createUserFromOAuth).toHaveBeenCalledWith(
|
||||
oauthUserData.email,
|
||||
oauthUserData.username,
|
||||
'google'
|
||||
);
|
||||
});
|
||||
|
||||
test('should login existing OAuth user', async () => {
|
||||
@@ -215,74 +229,70 @@ describe('Authentication Integration Tests', () => {
|
||||
|
||||
const result = await authService.loginWithOAuth('google', oauthUserData);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.email).toBe(oauthUserData.email);
|
||||
expect(result.user._id).toBe('existing-user1');
|
||||
expect(result.accessToken).toBeTruthy();
|
||||
expect(result.refreshToken).toBeTruthy();
|
||||
|
||||
expect(mockDatabaseService.createUserFromOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle OAuth login errors gracefully', async () => {
|
||||
mockDatabaseService.findUserByEmail.mockRejectedValue(
|
||||
new Error('Database error')
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.loginWithOAuth('google', oauthUserData)
|
||||
).rejects.toThrow('OAuth login failed: Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Management', () => {
|
||||
test('should change password with valid current password', async () => {
|
||||
const userId = 'user1';
|
||||
const currentPassword = 'currentPassword';
|
||||
const newPassword = 'newPassword123';
|
||||
const userWithPassword = {
|
||||
const activeUser = {
|
||||
...mockUser,
|
||||
password: currentPassword,
|
||||
};
|
||||
const updatedUser = {
|
||||
...userWithPassword,
|
||||
password: newPassword,
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
};
|
||||
|
||||
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
|
||||
mockDatabaseService.updateUser.mockResolvedValue(updatedUser);
|
||||
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
|
||||
|
||||
const result = await authService.changePassword(
|
||||
userId,
|
||||
currentPassword,
|
||||
testCredentials.password,
|
||||
newPassword
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.password).toBe(newPassword);
|
||||
const updatedUser = mockDatabaseService.updateUser.mock.calls[0][0];
|
||||
expect(isBcryptHash(updatedUser.password)).toBe(true);
|
||||
expect(await bcrypt.compare(newPassword, updatedUser.password)).toBe(
|
||||
true
|
||||
);
|
||||
expect(result.message).toBe('Password changed successfully');
|
||||
expect(mockDatabaseService.updateUser).toHaveBeenCalledWith({
|
||||
...userWithPassword,
|
||||
password: newPassword,
|
||||
});
|
||||
});
|
||||
|
||||
test('should fail password change with incorrect current password', async () => {
|
||||
const userId = 'user1';
|
||||
const currentPassword = 'wrongPassword';
|
||||
const newPassword = 'newPassword123';
|
||||
const userWithPassword = {
|
||||
const hashed = await bcrypt.hash('correctPassword', 10);
|
||||
const activeUser = {
|
||||
...mockUser,
|
||||
password: 'correctPassword',
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
password: hashed,
|
||||
};
|
||||
|
||||
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
|
||||
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
|
||||
|
||||
await expect(
|
||||
authService.changePassword(userId, currentPassword, newPassword)
|
||||
authService.changePassword(userId, 'wrongPassword', 'newPassword123')
|
||||
).rejects.toThrow('Current password is incorrect');
|
||||
});
|
||||
|
||||
test('should fail password change when legacy password is detected', async () => {
|
||||
const userId = 'user1';
|
||||
const legacyUser = {
|
||||
...mockUser,
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
password: 'legacyPassword',
|
||||
};
|
||||
|
||||
mockDatabaseService.getUserById.mockResolvedValue(legacyUser);
|
||||
|
||||
await expect(
|
||||
authService.changePassword(userId, 'legacyPassword', 'newPassword123')
|
||||
).rejects.toThrow('Password needs to be reset before it can be changed');
|
||||
});
|
||||
|
||||
test('should fail password change for OAuth users', async () => {
|
||||
const userId = 'user1';
|
||||
const oauthUser = {
|
||||
@@ -302,7 +312,8 @@ describe('Authentication Integration Tests', () => {
|
||||
test('should request password reset for existing user', async () => {
|
||||
const userWithPassword = {
|
||||
...mockUser,
|
||||
password: 'hasPassword',
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
};
|
||||
mockDatabaseService.findUserByEmail.mockResolvedValue(userWithPassword);
|
||||
|
||||
@@ -310,7 +321,6 @@ describe('Authentication Integration Tests', () => {
|
||||
testCredentials.email
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toContain('password reset link has been sent');
|
||||
});
|
||||
|
||||
@@ -321,7 +331,6 @@ describe('Authentication Integration Tests', () => {
|
||||
'nonexistent@example.com'
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toContain('password reset link has been sent');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EmailVerificationService } from './emailVerification.service';
|
||||
import { databaseService } from '../database';
|
||||
import { logger } from '../logging';
|
||||
import { tokenService } from './token.service';
|
||||
import { hashPassword, verifyPassword, isBcryptHash } from './password.service';
|
||||
|
||||
const emailVerificationService = new EmailVerificationService();
|
||||
|
||||
@@ -22,9 +23,11 @@ const authService = {
|
||||
}
|
||||
|
||||
// Create user with password
|
||||
const passwordToPersist = await hashPassword(password);
|
||||
|
||||
const user = await databaseService.createUserWithPassword(
|
||||
email,
|
||||
password,
|
||||
passwordToPersist,
|
||||
username
|
||||
);
|
||||
|
||||
@@ -80,14 +83,21 @@ const authService = {
|
||||
throw new Error('Email verification required');
|
||||
}
|
||||
|
||||
// Simple password verification (in production, use bcrypt)
|
||||
logger.auth.login('Comparing passwords', {
|
||||
inputPassword: input.password,
|
||||
storedPassword: user.password,
|
||||
match: user.password === input.password,
|
||||
if (!isBcryptHash(user.password)) {
|
||||
logger.auth.error('Stored password is not hashed; rejecting login');
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const hasValidPassword = await verifyPassword(
|
||||
input.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
logger.auth.login('Password comparison result', {
|
||||
hasValidPassword,
|
||||
});
|
||||
|
||||
if (user.password !== input.password) {
|
||||
if (!hasValidPassword) {
|
||||
logger.auth.error('Password mismatch');
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
@@ -162,8 +172,16 @@ const authService = {
|
||||
throw new Error('Cannot change password for OAuth accounts');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (user.password !== currentPassword) {
|
||||
if (!isBcryptHash(user.password)) {
|
||||
throw new Error('Password needs to be reset before it can be changed');
|
||||
}
|
||||
|
||||
const currentPasswordMatches = await verifyPassword(
|
||||
currentPassword,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!currentPasswordMatches) {
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
|
||||
@@ -175,7 +193,7 @@ const authService = {
|
||||
// Update user with new password (this should be hashed before calling)
|
||||
const updatedUser = await databaseService.updateUser({
|
||||
...user,
|
||||
password: newPassword,
|
||||
password: await hashPassword(newPassword),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -249,10 +267,11 @@ const authService = {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Update user with new password (this should be hashed before calling)
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
const updatedUser = await databaseService.updateUser({
|
||||
...user,
|
||||
password: newPassword,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
// Remove used token
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getAuthConfig } from '../../config/unified.config';
|
||||
|
||||
const DEFAULT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a plaintext password using bcrypt.
|
||||
* Falls back to a sane default if auth config is unavailable.
|
||||
*/
|
||||
export async function hashPassword(plainPassword: string): Promise<string> {
|
||||
const rounds = getAuthConfig()?.bcryptRounds ?? DEFAULT_ROUNDS;
|
||||
return bcrypt.hash(plainPassword, rounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a plaintext password against a stored bcrypt hash.
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
plainPassword: string,
|
||||
hashedPassword?: string | null
|
||||
): Promise<boolean> {
|
||||
if (!hashedPassword) {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compare(plainPassword, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to decide whether a password needs hashing.
|
||||
* Useful when dealing with legacy or seeded data.
|
||||
*/
|
||||
export function isBcryptHash(value?: string | null): boolean {
|
||||
if (!value) return false;
|
||||
return (
|
||||
value.startsWith('$2a$') ||
|
||||
value.startsWith('$2b$') ||
|
||||
value.startsWith('$2y$')
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,14 @@ jest.mock('../../../config/unified.config', () => ({
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
getAuthConfig: jest.fn(() => ({ bcryptRounds: 4 })),
|
||||
}));
|
||||
|
||||
const strategyMocks: Record<string, jest.Mock> = {};
|
||||
|
||||
// Create mock strategy methods object
|
||||
const mockStrategyMethods = {
|
||||
const mockStrategyMethods = strategyMocks as Record<string, jest.Mock>;
|
||||
Object.assign(mockStrategyMethods, {
|
||||
createUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
@@ -36,17 +40,15 @@ const mockStrategyMethods = {
|
||||
updateCustomReminder: jest.fn(),
|
||||
getCustomReminders: jest.fn(),
|
||||
deleteCustomReminder: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the strategies
|
||||
jest.mock('../MockDatabaseStrategy', () => ({
|
||||
MockDatabaseStrategy: jest.fn().mockImplementation(() => mockStrategyMethods),
|
||||
MockDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
|
||||
}));
|
||||
|
||||
jest.mock('../ProductionDatabaseStrategy', () => ({
|
||||
ProductionDatabaseStrategy: jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockStrategyMethods),
|
||||
ProductionDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
@@ -390,18 +392,19 @@ describe('DatabaseService', () => {
|
||||
|
||||
test('should support changeUserPassword method', async () => {
|
||||
const user = createMockUser();
|
||||
const updatedUser = { ...user, password: 'newPassword' };
|
||||
mockStrategyMethods.getUserById.mockResolvedValue(user);
|
||||
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
|
||||
mockStrategyMethods.updateUser.mockImplementation(
|
||||
async updated => updated
|
||||
);
|
||||
|
||||
const result = await service.changeUserPassword('user1', 'newPassword');
|
||||
|
||||
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
|
||||
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
|
||||
...user,
|
||||
password: 'newPassword',
|
||||
});
|
||||
expect(result).toBe(updatedUser);
|
||||
const updateCallArg = mockStrategyMethods.updateUser.mock.calls[0][0];
|
||||
expect(updateCallArg._id).toBe(user._id);
|
||||
expect(updateCallArg.password).not.toBe('newPassword');
|
||||
expect(updateCallArg.password.startsWith('$2')).toBe(true);
|
||||
expect(result.password).toBe(updateCallArg.password);
|
||||
});
|
||||
|
||||
test('should support deleteAllUserData method', async () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
|
||||
|
||||
describe('Accessibility: ResetPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
|
||||
});
|
||||
|
||||
test('all interactive controls expose accessible names', () => {
|
||||
render(React.createElement(ResetPasswordPage));
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
for (const button of buttons) {
|
||||
const labelText =
|
||||
button.getAttribute('aria-label') ?? button.textContent?.trim();
|
||||
expect(labelText).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('form fields are associated with labels', () => {
|
||||
const { container } = render(React.createElement(ResetPasswordPage));
|
||||
|
||||
const labelElements = Array.from(container.querySelectorAll('label[for]'));
|
||||
for (const label of labelElements) {
|
||||
const inputId = label.getAttribute('for');
|
||||
const field = inputId ? container.querySelector(`#${inputId}`) : null;
|
||||
expect(field).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { generateSchedule } from '../../utils/schedule';
|
||||
import { Frequency, Medication } from '../../types';
|
||||
|
||||
describe('Performance: schedule generation', () => {
|
||||
const createMedication = (index: number): Medication => ({
|
||||
_id: `med-${index}`,
|
||||
_rev: `1-${index}`,
|
||||
name: `Medication ${index}`,
|
||||
dosage: '10mg',
|
||||
frequency: Frequency.Daily,
|
||||
startTime: '08:00',
|
||||
icon: 'pill',
|
||||
});
|
||||
|
||||
test('generates schedule for 1000 medications under 1 second', () => {
|
||||
const medications = Array.from({ length: 1000 }, (_, idx) =>
|
||||
createMedication(idx)
|
||||
);
|
||||
const start = performance.now();
|
||||
const schedule = generateSchedule(medications, new Date());
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(schedule.length).toBe(medications.length);
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
|
||||
|
||||
describe('Visual Baseline: ResetPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
|
||||
});
|
||||
|
||||
test('matches baseline layout for password reset screen', () => {
|
||||
const { container } = render(React.createElement(ResetPasswordPage));
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Visual Baseline: ResetPasswordPage matches baseline layout for password reset screen 1`] = `
|
||||
<div
|
||||
class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
<div
|
||||
class="text-center mb-8"
|
||||
>
|
||||
<div
|
||||
class="inline-block bg-indigo-600 p-3 rounded-xl mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z"
|
||||
/>
|
||||
<path
|
||||
d="m8.5 8.5 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="text-3xl font-bold text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
Reset Password
|
||||
</h1>
|
||||
<p
|
||||
class="text-slate-500 dark:text-slate-400 mt-1"
|
||||
>
|
||||
Choose a new password for your account.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8"
|
||||
>
|
||||
<form
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
for="password"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
|
||||
id="password"
|
||||
placeholder="Enter a new password"
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
for="confirmPassword"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
|
||||
id="confirmPassword"
|
||||
placeholder="Re-enter your password"
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
type="submit"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
class="mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
type="button"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
Reference in New Issue
Block a user