Merge branch 'password-reset'

This commit is contained in:
dimitar 2025-02-27 00:37:49 +01:00
commit 8a4ecd913c
13 changed files with 769 additions and 46 deletions

View File

@ -0,0 +1,27 @@
/*
Warnings:
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateTable
CREATE TABLE "PasswordReset" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token");
-- AddForeignKey
ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -29,6 +29,9 @@ model User {
uploadedDocuments Document[] @relation("UploadedDocuments")
Notification Notification[]
sharedDocuments Document[] @relation("SharedDocuments")
passwordResets PasswordReset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Notification {
@ -41,3 +44,13 @@ model Notification {
document Document @relation(fields: [documentId], references: [id])
user User @relation(fields: [userId], references: [id])
}
model PasswordReset {
id Int @id @default(autoincrement())
token String @unique
userId Int
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}

View File

@ -1,4 +1,3 @@
import {
Controller,
Get,
@ -103,6 +102,16 @@ async uploadDocument(
return this.adminService.updateDocumentStatus(+id, status);
}
@Post('users/:id/reset-password')
async resetUserPassword(
@Param('id', ParseIntPipe) id: number,
@Body() { password }: { password: string },
) {
if (!password || password.length < 6) {
throw new BadRequestException('Password must be at least 6 characters long');
}
return this.adminService.resetUserPassword(id, password);
}
// @Get('test-s3-connection')
// async testS3Connection() {

View File

@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { S3Service } from '../s3/s3.service';
import { UpdateDocumentDto } from '../dto/update-document.dto';
@ -339,4 +339,65 @@ export class AdminService {
throw error;
}
}
async resetUserPassword(userId: number, newPassword: string) {
this.logger.log('=== Starting password reset process ===');
this.logger.debug('Reset request for user:', { userId });
try {
// Find the user
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
},
});
if (!user) {
this.logger.error('User not found:', { userId });
throw new NotFoundException('User not found');
}
// Hash the new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update the user's password
await this.prisma.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
this.logger.log('Password reset successful for user:', {
userId: user.id,
email: user.email,
});
// Send email notification
try {
await this.emailService.sendPasswordResetNotification(
user.email,
user.name
);
this.logger.debug('Password reset notification sent');
} catch (emailError) {
this.logger.error('Failed to send password reset notification:', {
error: emailError.message,
code: emailError.code,
stack: emailError.stack,
});
// Don't throw the error, just log it
}
return { message: 'Password reset successful' };
} catch (error) {
this.logger.error('Error in resetUserPassword:', {
error: error.message,
code: error.code,
stack: error.stack,
});
throw error;
}
}
}

View File

@ -7,6 +7,7 @@ import {
Get,
Request,
Logger,
BadRequestException,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from '../dto/login.dto';
@ -154,4 +155,25 @@ export class AuthController {
async getUserInfo(@Request() req) {
return this.authService.getUserInfo(req.user.userId);
}
@Post('forgot-password')
async forgotPassword(@Body() { email }: { email: string }) {
if (!email) {
throw new BadRequestException('Email is required');
}
return this.authService.sendPasswordResetToken(email);
}
@Post('reset-password')
async resetPassword(
@Body() { token, newPassword }: { token: string; newPassword: string }
) {
if (!token || !newPassword) {
throw new BadRequestException('Token and new password are required');
}
if (newPassword.length < 6) {
throw new BadRequestException('Password must be at least 6 characters long');
}
return this.authService.resetPasswordWithToken(token, newPassword);
}
}

View File

@ -1,10 +1,11 @@
import { Injectable, ConflictException, Logger } from '@nestjs/common';
import { Injectable, ConflictException, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { CreateUserDto } from '../dto/create-user.dto';
import { ConfigService } from '@nestjs/config';
import { EmailService } from '../email/email.service';
import { randomBytes } from 'crypto';
@Injectable()
export class AuthService {
@ -226,4 +227,135 @@ export class AuthService {
}
});
}
async sendPasswordResetToken(email: string) {
this.logger.log('=== Starting password reset token process ===');
this.logger.debug('Reset token requested for:', email);
try {
// Find the user
const user = await this.prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
},
});
if (!user) {
this.logger.warn('User not found for password reset:', email);
// Return success anyway to prevent email enumeration
return { message: 'If an account exists, a password reset link has been sent' };
}
// Generate reset token
const token = randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour
// Save reset token
await this.prisma.passwordReset.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
// Send email with reset link
try {
await this.emailService.sendPasswordResetEmail(
user.email,
user.name,
token
);
this.logger.debug('Password reset email sent successfully');
} catch (emailError) {
this.logger.error('Failed to send password reset email:', {
error: emailError.message,
code: emailError.code,
stack: emailError.stack,
});
throw emailError;
}
return { message: 'If an account exists, a password reset link has been sent' };
} catch (error) {
this.logger.error('Error in sendPasswordResetToken:', {
error: error.message,
code: error.code,
stack: error.stack,
});
throw error;
}
}
async resetPasswordWithToken(token: string, newPassword: string) {
this.logger.log('=== Starting password reset with token process ===');
this.logger.debug('Password reset attempted with token');
try {
// Find valid reset token
const resetRecord = await this.prisma.passwordReset.findFirst({
where: {
token,
expiresAt: {
gt: new Date(), // Token must not be expired
},
used: false, // Token must not be used
},
include: {
user: true,
},
});
if (!resetRecord) {
this.logger.warn('Invalid or expired reset token used');
throw new UnauthorizedException('Invalid or expired reset token');
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password and mark token as used
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: resetRecord.userId },
data: { password: hashedPassword },
}),
this.prisma.passwordReset.update({
where: { id: resetRecord.id },
data: { used: true },
}),
]);
this.logger.log('Password reset successful for user:', resetRecord.userId);
// Send confirmation email
try {
await this.emailService.sendPasswordChangeConfirmation(
resetRecord.user.email,
resetRecord.user.name
);
this.logger.debug('Password change confirmation email sent');
} catch (emailError) {
this.logger.error('Failed to send password change confirmation:', {
error: emailError.message,
code: emailError.code,
stack: emailError.stack,
});
// Don't throw error here as password is already changed
}
return { message: 'Password reset successful' };
} catch (error) {
this.logger.error('Error in resetPasswordWithToken:', {
error: error.message,
code: error.code,
stack: error.stack,
});
throw error;
}
}
}

View File

@ -186,4 +186,118 @@ export class EmailService {
throw error;
}
}
async sendPasswordResetNotification(email: string, name: string) {
this.logger.log('Sending password reset notification email to:', email);
const mailOptions = {
from: this.from,
to: email,
subject: 'Your Password Has Been Reset',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Password Reset Notification</h2>
<p>Hello ${name},</p>
<p>Your password has been reset by an administrator.</p>
<p>If you did not expect this change, please contact your administrator immediately.</p>
<p>Best regards,<br>IMK Team</p>
</div>
`,
};
try {
this.logger.debug('Attempting to send password reset notification...');
const info = await this.transporter.sendMail(mailOptions);
this.logger.log('Password reset notification sent successfully:', info.response);
return info;
} catch (error) {
this.logger.error('Failed to send password reset notification:', {
error: error.message,
code: error.code,
command: error.command,
stack: error.stack,
});
throw error;
}
}
async sendPasswordResetEmail(email: string, name: string, token: string) {
this.logger.log('Sending password reset email to:', email);
const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
const mailOptions = {
from: this.from,
to: email,
subject: 'Reset Your Password - IMK Platform',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Password Reset Request</h2>
<p>Hello ${name},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p>
<a href="${resetLink}" style="
display: inline-block;
padding: 10px 20px;
background-color: #4F46E5;
color: white;
text-decoration: none;
border-radius: 5px;
">Reset Password</a>
</p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, please ignore this email or contact support if you have concerns.</p>
<p>Best regards,<br>IMK Team</p>
</div>
`,
};
try {
this.logger.debug('Attempting to send password reset email...');
const info = await this.transporter.sendMail(mailOptions);
this.logger.log('Password reset email sent successfully:', info.response);
return info;
} catch (error) {
this.logger.error('Failed to send password reset email:', {
error: error.message,
code: error.code,
command: error.command,
stack: error.stack,
});
throw error;
}
}
async sendPasswordChangeConfirmation(email: string, name: string) {
this.logger.log('Sending password change confirmation to:', email);
const mailOptions = {
from: this.from,
to: email,
subject: 'Password Changed Successfully - IMK Platform',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Password Changed Successfully</h2>
<p>Hello ${name},</p>
<p>Your password has been successfully changed.</p>
<p>If you did not make this change, please contact our support team immediately.</p>
<p>Best regards,<br>IMK Team</p>
</div>
`,
};
try {
this.logger.debug('Attempting to send password change confirmation...');
const info = await this.transporter.sendMail(mailOptions);
this.logger.log('Password change confirmation sent successfully:', info.response);
return info;
} catch (error) {
this.logger.error('Failed to send password change confirmation:', {
error: error.message,
code: error.code,
command: error.command,
stack: error.stack,
});
throw error;
}
}
}

View File

@ -17,6 +17,8 @@ import Login from './components/login/Login';
import Navbar from './components/navbar/Navbar';
import ProtectedRoute from './components/protectedRoute/ProtectedRoute';
import { ThemeProvider } from './theme/ThemeProvider';
import ForgotPassword from './components/auth/ForgotPassword';
import ResetPassword from './components/auth/ResetPassword';
// import { Navbar } from './components/navbar/Navbar';
function App() {
@ -52,6 +54,8 @@ function App() {
<Dashboard />
</ProtectedRoute>
} />
<Route path='/forgot-password' element={<ForgotPassword />} />
<Route path='/reset-password' element={<ResetPassword />} />
</Routes>
<Footer />
</BrowserRouter>

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { getAllUsers, getAllDocuments, getUserInfo, createUser } from '../../services/api';
import { getAllUsers, getAllDocuments, getUserInfo, createUser, resetUserPassword } from '../../services/api';
import DocumentUpload from '../documentUpload/DocumentUpload';
import { useNavigate } from 'react-router-dom';
import { FiUsers, FiFile, FiUpload, FiUserPlus, FiLoader } from 'react-icons/fi';
import { FiUsers, FiFile, FiUpload, FiUserPlus, FiLoader, FiKey } from 'react-icons/fi';
function AdminPanel() {
const navigate = useNavigate();
@ -12,6 +12,12 @@ function AdminPanel() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [resetPasswordModal, setResetPasswordModal] = useState({
isOpen: false,
userId: null,
userName: '',
newPassword: '',
});
const [newUser, setNewUser] = useState({
name: '',
email: '',
@ -80,6 +86,24 @@ function AdminPanel() {
}
};
const handleResetPassword = async (e) => {
e.preventDefault();
try {
await resetUserPassword(resetPasswordModal.userId, resetPasswordModal.newPassword);
setResetPasswordModal({
isOpen: false,
userId: null,
userName: '',
newPassword: '',
});
// Show success message
setError('Password reset successful');
setTimeout(() => setError(''), 3000);
} catch (err) {
setError('Failed to reset password');
}
};
if (!isAdmin) return null;
const tabs = [
@ -132,6 +156,55 @@ function AdminPanel() {
</div>
)}
{resetPasswordModal.isOpen && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-primary-800 rounded-xl p-6 w-full max-w-md">
<h3 className="text-xl font-bold text-white mb-4">
Reset Password for {resetPasswordModal.userName}
</h3>
<form onSubmit={handleResetPassword} className="space-y-4">
<input
type="password"
placeholder="New Password"
value={resetPasswordModal.newPassword}
onChange={(e) => setResetPasswordModal({
...resetPasswordModal,
newPassword: e.target.value
})}
className="w-full bg-primary-700/30 border border-primary-600 rounded-lg px-4 py-2
text-white placeholder-neutral-400 focus:outline-none focus:border-primary-500
focus:ring-1 focus:ring-primary-500"
required
minLength={6}
/>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => setResetPasswordModal({
isOpen: false,
userId: null,
userName: '',
newPassword: '',
})}
className="px-4 py-2 text-neutral-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex items-center justify-center space-x-2 px-4 py-2
bg-primary-600 hover:bg-primary-700 text-white rounded-lg
transition-colors shadow-lg"
>
<FiKey className="w-4 h-4" />
<span>Reset Password</span>
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid gap-6">
{activeTab === 'documents' && (
<div className="bg-primary-800/50 backdrop-blur-lg rounded-xl overflow-hidden shadow-xl">
@ -248,6 +321,7 @@ function AdminPanel() {
<th className="px-6 py-4 text-left text-sm text-neutral-400">Name</th>
<th className="px-6 py-4 text-left text-sm text-neutral-400">Email</th>
<th className="px-6 py-4 text-left text-sm text-neutral-400">Role</th>
<th className="px-6 py-4 text-left text-sm text-neutral-400">Actions</th>
</tr>
</thead>
<tbody>
@ -261,6 +335,20 @@ function AdminPanel() {
{user.isAdmin ? 'Admin' : 'User'}
</span>
</td>
<td className="px-6 py-4">
<button
onClick={() => setResetPasswordModal({
isOpen: true,
userId: user.id,
userName: user.name,
newPassword: '',
})}
className="flex items-center space-x-1 text-neutral-400 hover:text-white transition-colors"
>
<FiKey className="w-4 h-4" />
<span>Reset Password</span>
</button>
</td>
</tr>
))}
</tbody>

View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { forgotPassword } from '../../services/api';
import { Link } from 'react-router-dom';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState({ type: '', message: '' });
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setStatus({ type: '', message: '' });
try {
await forgotPassword(email);
setStatus({
type: 'success',
message: 'If an account exists with this email, you will receive password reset instructions.'
});
setEmail('');
} catch (error) {
setStatus({
type: 'error',
message: 'Failed to process request. Please try again.'
});
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-900 to-primary-800 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-primary-800/50 backdrop-blur-lg p-8 rounded-xl shadow-xl">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
Forgot your password?
</h2>
<p className="mt-2 text-center text-sm text-neutral-400">
Enter your email address and we'll send you instructions to reset your password.
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{status.message && (
<div
className={`p-4 rounded-lg ${
status.type === 'success'
? 'bg-green-500/10 border border-green-500/20 text-green-200'
: 'bg-red-500/10 border border-red-500/20 text-red-200'
}`}
>
{status.message}
</div>
)}
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none relative block w-full px-3 py-2 border
border-primary-600 bg-primary-700/30 placeholder-neutral-400
text-white rounded-lg focus:outline-none focus:ring-primary-500
focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border
border-transparent text-sm font-medium rounded-lg text-white
bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50
disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending...' : 'Send Reset Instructions'}
</button>
</div>
<div className="text-center">
<Link
to="/login"
className="font-medium text-primary-400 hover:text-primary-300 transition-colors"
>
Back to login
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { resetPassword } from '../../services/api';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const [status, setStatus] = useState({ type: '', message: '' });
const [loading, setLoading] = useState(false);
const token = searchParams.get('token');
useEffect(() => {
if (!token) {
navigate('/forgot-password');
}
}, [token, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
setStatus({
type: 'error',
message: 'Passwords do not match'
});
return;
}
if (formData.password.length < 6) {
setStatus({
type: 'error',
message: 'Password must be at least 6 characters long'
});
return;
}
setLoading(true);
setStatus({ type: '', message: '' });
try {
await resetPassword(token, formData.password);
setStatus({
type: 'success',
message: 'Password reset successful. You can now login with your new password.'
});
setFormData({ password: '', confirmPassword: '' });
// Redirect to login after 3 seconds
setTimeout(() => navigate('/login'), 3000);
} catch (error) {
setStatus({
type: 'error',
message: error.response?.data?.message || 'Failed to reset password. Please try again.'
});
} finally {
setLoading(false);
}
};
if (!token) return null;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-900 to-primary-800 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-primary-800/50 backdrop-blur-lg p-8 rounded-xl shadow-xl">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-neutral-400">
Enter your new password below
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{status.message && (
<div
className={`p-4 rounded-lg ${
status.type === 'success'
? 'bg-green-500/10 border border-green-500/20 text-green-200'
: 'bg-red-500/10 border border-red-500/20 text-red-200'
}`}
>
{status.message}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="password" className="sr-only">
New Password
</label>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="appearance-none relative block w-full px-3 py-2 border
border-primary-600 bg-primary-700/30 placeholder-neutral-400
text-white rounded-lg focus:outline-none focus:ring-primary-500
focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="New password"
minLength={6}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="sr-only">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="appearance-none relative block w-full px-3 py-2 border
border-primary-600 bg-primary-700/30 placeholder-neutral-400
text-white rounded-lg focus:outline-none focus:ring-primary-500
focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Confirm new password"
minLength={6}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border
border-transparent text-sm font-medium rounded-lg text-white
bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50
disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</div>
<div className="text-center">
<Link
to="/login"
className="font-medium text-primary-400 hover:text-primary-300 transition-colors"
>
Back to login
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Input, FormGroup, Label, ErrorMessage } from '../UI';
import { Link } from 'react-router-dom';
export default function Login() {
const { login } = useAuth();
@ -77,14 +78,14 @@ export default function Login() {
<Label htmlFor="password" className="text-white">
Password
</Label>
<Button
{/* <Button
variant="ghost"
size="sm"
onClick={() => navigate('/forgot-password')}
className="text-neutral-300 hover:text-white"
>
Forgot password?
</Button>
</Button> */}
</div>
<Input
id="password"
@ -99,46 +100,33 @@ export default function Login() {
/>
</FormGroup>
<Button
type="submit"
variant="primary"
fullWidth
disabled={isLoading}
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500
disabled:bg-primary-800 disabled:cursor-not-allowed
transition-all duration-200 ease-in-out"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Signing in...
</span>
) : (
'Sign in'
)}
</Button>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border
border-transparent text-sm font-medium rounded-lg text-white
bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50
disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<Link
to="/forgot-password"
className="font-medium text-primary-400 hover:text-primary-300 transition-colors"
>
Forgot your password?
</Link>
</div>
</div>
</form>
<div className="mt-6 text-center">
{/* <div className="mt-6 text-center">
<p className="text-sm text-neutral-300">
Don't have an account?{' '}
<Button
@ -150,7 +138,7 @@ export default function Login() {
Sign up
</Button>
</p>
</div>
</div> */}
</Card>
</div>
);

View File

@ -98,5 +98,10 @@ export const getUserInfo = () => api.get('/auth/user-info');
export const getAllDocuments = () => api.get('/admin/documents');
// export const getSharedDocuments = (userId) => api.get(`/documents/shared/${userId}`);
export const getAllUsers = () => api.get('/admin/users');
export const resetUserPassword = (userId, newPassword) => api.post(`/admin/users/${userId}/reset-password`, { password: newPassword });
// Password reset endpoints
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
export const resetPassword = (token, newPassword) => api.post('/auth/reset-password', { token, newPassword });
export default api;