diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 248498d..fd651c0 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -6,6 +6,8 @@ import { ForgotPasswordSchema, ResetPasswordSchema, MfaVerifySchema, + UpdateProfileSchema, + ChangePasswordSchema, } from '../../../../shared/src/index'; import * as authService from './auth.service'; import { authenticate } from '../../common/middleware/auth'; @@ -105,4 +107,18 @@ export const authRoutes: FastifyPluginAsync = async (fastify) => { await authService.disableMfa(request.user!.sub); return { success: true }; }); + + // PATCH /auth/profile - Update profile + fastify.patch('/profile', { preHandler: authenticate }, async (request) => { + const body = UpdateProfileSchema.parse(request.body); + const user = await authService.updateProfile(request.user!.sub, body); + return { user }; + }); + + // POST /auth/change-password - Change password + fastify.post('/change-password', { preHandler: authenticate }, async (request) => { + const body = ChangePasswordSchema.parse(request.body); + await authService.changePassword(request.user!.sub, body.currentPassword, body.newPassword); + return { message: 'Password changed successfully' }; + }); }; diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 0bb2756..449e27a 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -352,6 +352,62 @@ export async function disableMfa(userId: string): Promise { }); } +// ==================== Profile Updates ==================== + +export async function updateProfile( + userId: string, + data: { firstName?: string; lastName?: string; email?: string } +): Promise { + // If email is being changed, check for conflicts + if (data.email) { + const existingUser = await prisma.user.findFirst({ + where: { + email: data.email.toLowerCase(), + id: { not: userId }, + }, + }); + if (existingUser) { + throw new ConflictError('Email is already in use'); + } + } + + const user = await prisma.user.update({ + where: { id: userId }, + data: { + ...(data.firstName && { firstName: data.firstName }), + ...(data.lastName && { lastName: data.lastName }), + ...(data.email && { email: data.email.toLowerCase() }), + }, + }); + + return mapUser(user); +} + +export async function changePassword( + userId: string, + currentPassword: string, + newPassword: string +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundError('User'); + } + + const isValidPassword = await verifyPassword(user.passwordHash, currentPassword); + if (!isValidPassword) { + throw new ValidationError('Current password is incorrect'); + } + + const passwordHash = await hashPassword(newPassword); + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash }, + }); +} + // ==================== Helpers ==================== async function generateTokens( diff --git a/apps/api/src/modules/invites/invites.routes.ts b/apps/api/src/modules/invites/invites.routes.ts index faabc5f..7de9b01 100644 --- a/apps/api/src/modules/invites/invites.routes.ts +++ b/apps/api/src/modules/invites/invites.routes.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { randomBytes } from 'crypto'; import { prisma } from '../../lib/prisma'; import { authenticate, optionalAuthenticate } from '../../common/middleware/auth'; -import { NotFoundError, ConflictError, ForbiddenError } from '@nats-console/shared'; +import { NotFoundError, ConflictError, ForbiddenError } from '../../../../shared/src/index'; const CreateInviteSchema = z.object({ email: z.string().email(), diff --git a/apps/shared/src/schemas/index.ts b/apps/shared/src/schemas/index.ts index 6c91e8b..f0b38f9 100644 --- a/apps/shared/src/schemas/index.ts +++ b/apps/shared/src/schemas/index.ts @@ -52,6 +52,21 @@ export const MfaVerifySchema = z.object({ code: z.string().length(6, 'MFA code must be 6 digits'), }); +export const UpdateProfileSchema = z.object({ + firstName: z.string().min(1).max(100).optional(), + lastName: z.string().min(1).max(100).optional(), + email: z.string().email('Invalid email address').optional(), +}); + +export const ChangePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[a-z]/, 'Password must contain at least one lowercase letter') + .regex(/[0-9]/, 'Password must contain at least one number'), +}); + // ==================== User Schemas ==================== export const UpdateUserSchema = z.object({ @@ -343,6 +358,8 @@ export type RefreshTokenInput = z.infer; export type ForgotPasswordInput = z.infer; export type ResetPasswordInput = z.infer; export type MfaVerifyInput = z.infer; +export type UpdateProfileInput = z.infer; +export type ChangePasswordInput = z.infer; export type UpdateUserInput = z.infer; export type InviteUserInput = z.infer; export type CreateOrganizationInput = z.infer; diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 08fa2e9..1fce8d5 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Save, User, Bell, Shield, Key, Palette, Users, UserPlus, Trash2, Mail } from 'lucide-react'; +import { Save, User, Bell, Shield, Key, Palette, Users, UserPlus, Trash2, Mail, Loader2, CheckCircle2 } from 'lucide-react'; import { useAuthStore } from '@/stores/auth'; import { api } from '@/lib/api'; import { Button } from '@/components/ui/button'; @@ -11,16 +11,25 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { InviteUserDialog } from '@/components/forms/invite-user-dialog'; export default function SettingsPage() { - const { user } = useAuthStore(); + const { user, setUser } = useAuthStore(); const [activeTab, setActiveTab] = useState('profile'); const queryClient = useQueryClient(); const [profileForm, setProfileForm] = useState({ - firstName: user?.firstName || '', - lastName: user?.lastName || '', - email: user?.email || '', + firstName: '', + lastName: '', + email: '', }); + const [passwordForm, setPasswordForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + + const [passwordError, setPasswordError] = useState(''); + const [passwordSuccess, setPasswordSuccess] = useState(false); + const [notificationSettings, setNotificationSettings] = useState({ emailAlerts: true, webhookAlerts: false, @@ -30,18 +39,49 @@ export default function SettingsPage() { const [showInviteDialog, setShowInviteDialog] = useState(false); - // Fetch team members - const { data: membersData } = useQuery({ - queryKey: ['organization-members'], - queryFn: () => api.auth.me().then(() => []), // TODO: Add members endpoint - }); + // Initialize form with user data + useEffect(() => { + if (user) { + setProfileForm({ + firstName: user.firstName || '', + lastName: user.lastName || '', + email: user.email || '', + }); + } + }, [user]); // Fetch pending invites - const { data: invitesData, refetch: refetchInvites } = useQuery({ + const { data: invitesData } = useQuery({ queryKey: ['invites'], queryFn: () => api.invites.list(), }); + // Profile update mutation + const profileMutation = useMutation({ + mutationFn: (data: { firstName?: string; lastName?: string; email?: string }) => + api.auth.updateProfile(data), + onSuccess: (data) => { + setUser(data.user); + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + }); + + // Change password mutation + const changePasswordMutation = useMutation({ + mutationFn: (data: { currentPassword: string; newPassword: string }) => + api.auth.changePassword(data), + onSuccess: () => { + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); + setPasswordError(''); + setPasswordSuccess(true); + setTimeout(() => setPasswordSuccess(false), 3000); + }, + onError: (err: any) => { + setPasswordError(err.message || 'Failed to change password'); + setPasswordSuccess(false); + }, + }); + const revokeMutation = useMutation({ mutationFn: (id: string) => api.invites.revoke(id), onSuccess: () => { @@ -49,6 +89,31 @@ export default function SettingsPage() { }, }); + const handleProfileSubmit = (e: React.FormEvent) => { + e.preventDefault(); + profileMutation.mutate(profileForm); + }; + + const handlePasswordSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(''); + + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + setPasswordError('Passwords do not match'); + return; + } + + if (passwordForm.newPassword.length < 8) { + setPasswordError('Password must be at least 8 characters'); + return; + } + + changePasswordMutation.mutate({ + currentPassword: passwordForm.currentPassword, + newPassword: passwordForm.newPassword, + }); + }; + const tabs = [ { id: 'profile', label: 'Profile', icon: User }, { id: 'team', label: 'Team', icon: Users }, @@ -95,42 +160,59 @@ export default function SettingsPage() { Profile Settings Update your personal information - -
+
+ + {profileMutation.isSuccess && ( +
+ + Profile updated successfully +
+ )} + {profileMutation.error && ( +
+ {(profileMutation.error as any).message || 'Failed to update profile'} +
+ )} +
+
+ + + setProfileForm({ ...profileForm, firstName: e.target.value }) + } + /> +
+
+ + + setProfileForm({ ...profileForm, lastName: e.target.value }) + } + /> +
+
- + - setProfileForm({ ...profileForm, firstName: e.target.value }) + setProfileForm({ ...profileForm, email: e.target.value }) } />
-
- - - setProfileForm({ ...profileForm, lastName: e.target.value }) - } - /> -
-
-
- - - setProfileForm({ ...profileForm, email: e.target.value }) - } - /> -
- -
+ + + )} @@ -328,21 +410,63 @@ export default function SettingsPage() { Change Password Update your password regularly for security - -
- - -
-
- - -
-
- - -
- -
+
+ + {passwordSuccess && ( +
+ + Password changed successfully +
+ )} + {passwordError && ( +
+ {passwordError} +
+ )} +
+ + + setPasswordForm({ ...passwordForm, currentPassword: e.target.value }) + } + required + /> +
+
+ + + setPasswordForm({ ...passwordForm, newPassword: e.target.value }) + } + required + /> +
+
+ + + setPasswordForm({ ...passwordForm, confirmPassword: e.target.value }) + } + required + /> +
+ +
+
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index fac9f5a..c1c57d7 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -113,6 +113,18 @@ export const auth = { }), me: () => request<{ user: any }>('/auth/me'), + + updateProfile: (data: { firstName?: string; lastName?: string; email?: string }) => + request<{ user: any }>('/auth/profile', { + method: 'PATCH', + body: data, + }), + + changePassword: (data: { currentPassword: string; newPassword: string }) => + request<{ message: string }>('/auth/change-password', { + method: 'POST', + body: data, + }), }; // Clusters API