mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-07 21:50:52 +00:00
fix invite issue.
This commit is contained in:
@@ -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' };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -352,6 +352,62 @@ export async function disableMfa(userId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Profile Updates ====================
|
||||
|
||||
export async function updateProfile(
|
||||
userId: string,
|
||||
data: { firstName?: string; lastName?: string; email?: string }
|
||||
): Promise<User> {
|
||||
// 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<void> {
|
||||
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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<typeof RefreshTokenSchema>;
|
||||
export type ForgotPasswordInput = z.infer<typeof ForgotPasswordSchema>;
|
||||
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>;
|
||||
export type MfaVerifyInput = z.infer<typeof MfaVerifySchema>;
|
||||
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;
|
||||
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
export type InviteUserInput = z.infer<typeof InviteUserSchema>;
|
||||
export type CreateOrganizationInput = z.infer<typeof CreateOrganizationSchema>;
|
||||
|
||||
@@ -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() {
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<CardDescription>Update your personal information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<form onSubmit={handleProfileSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{profileMutation.isSuccess && (
|
||||
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Profile updated successfully
|
||||
</div>
|
||||
)}
|
||||
{profileMutation.error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{(profileMutation.error as any).message || 'Failed to update profile'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">First Name</label>
|
||||
<Input
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) =>
|
||||
setProfileForm({ ...profileForm, firstName: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Last Name</label>
|
||||
<Input
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) =>
|
||||
setProfileForm({ ...profileForm, lastName: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">First Name</label>
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<Input
|
||||
value={profileForm.firstName}
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) =>
|
||||
setProfileForm({ ...profileForm, firstName: e.target.value })
|
||||
setProfileForm({ ...profileForm, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Last Name</label>
|
||||
<Input
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) =>
|
||||
setProfileForm({ ...profileForm, lastName: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) =>
|
||||
setProfileForm({ ...profileForm, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardContent>
|
||||
<Button type="submit" disabled={profileMutation.isPending}>
|
||||
{profileMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -328,21 +410,63 @@ export default function SettingsPage() {
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>Update your password regularly for security</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Password</label>
|
||||
<Input type="password" placeholder="Enter current password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">New Password</label>
|
||||
<Input type="password" placeholder="Enter new password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Confirm New Password</label>
|
||||
<Input type="password" placeholder="Confirm new password" />
|
||||
</div>
|
||||
<Button>Update Password</Button>
|
||||
</CardContent>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{passwordSuccess && (
|
||||
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Password changed successfully
|
||||
</div>
|
||||
)}
|
||||
{passwordError && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Current Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, currentPassword: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">New Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, newPassword: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Confirm New Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={changePasswordMutation.isPending}>
|
||||
{changePasswordMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Update Password
|
||||
</Button>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user