|
|
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
-import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
|
|
|
+import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
|
import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
@@ -14,6 +14,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
|
|
interface FormData extends UserCreate {
|
|
|
group_ids: number[];
|
|
|
confirmPassword: string;
|
|
|
+ email?: string;
|
|
|
}
|
|
|
|
|
|
export function UsersPage() {
|
|
|
@@ -29,23 +30,30 @@ export function UsersPage() {
|
|
|
const [formData, setFormData] = useState<FormData>({
|
|
|
username: '',
|
|
|
password: '',
|
|
|
+ email: '',
|
|
|
confirmPassword: '',
|
|
|
role: 'user',
|
|
|
group_ids: [],
|
|
|
});
|
|
|
|
|
|
+ // Check if advanced auth is enabled
|
|
|
+ const { data: advancedAuthStatus } = useQuery({
|
|
|
+ queryKey: ['advancedAuthStatus'],
|
|
|
+ queryFn: () => api.getAdvancedAuthStatus(),
|
|
|
+ });
|
|
|
+
|
|
|
// Close modal on Escape key
|
|
|
useEffect(() => {
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
if (e.key === 'Escape') {
|
|
|
if (showCreateModal) {
|
|
|
setShowCreateModal(false);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}
|
|
|
if (showEditModal) {
|
|
|
setShowEditModal(false);
|
|
|
setEditingUserId(null);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
@@ -71,7 +79,7 @@ export function UsersPage() {
|
|
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
|
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
|
|
setShowCreateModal(false);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
showToast(t('users.toast.created'));
|
|
|
},
|
|
|
onError: (error: Error) => {
|
|
|
@@ -86,7 +94,7 @@ export function UsersPage() {
|
|
|
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
|
|
setShowEditModal(false);
|
|
|
setEditingUserId(null);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
showToast(t('users.toast.updated'));
|
|
|
},
|
|
|
onError: (error: Error) => {
|
|
|
@@ -105,22 +113,50 @@ export function UsersPage() {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
+ const resetPasswordMutation = useMutation({
|
|
|
+ mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
|
|
|
+ onSuccess: (data) => {
|
|
|
+ showToast(data.message, 'success');
|
|
|
+ },
|
|
|
+ onError: (error: Error) => {
|
|
|
+ showToast(error.message, 'error');
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
const handleCreate = () => {
|
|
|
- if (!formData.username || !formData.password) {
|
|
|
+ const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled;
|
|
|
+
|
|
|
+ if (!formData.username) {
|
|
|
showToast(t('users.toast.fillRequired'), 'error');
|
|
|
return;
|
|
|
}
|
|
|
- if (formData.password !== formData.confirmPassword) {
|
|
|
- showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
|
|
+
|
|
|
+ // Email is required when advanced auth is enabled
|
|
|
+ if (advancedAuthEnabled && !formData.email) {
|
|
|
+ showToast('Email is required when advanced authentication is enabled', 'error');
|
|
|
return;
|
|
|
}
|
|
|
- if (formData.password.length < 6) {
|
|
|
- showToast(t('users.toast.passwordTooShort'), 'error');
|
|
|
- return;
|
|
|
+
|
|
|
+ // Password validation only when advanced auth is disabled
|
|
|
+ if (!advancedAuthEnabled) {
|
|
|
+ if (!formData.password) {
|
|
|
+ showToast(t('users.toast.fillRequired'), 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (formData.password !== formData.confirmPassword) {
|
|
|
+ showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (formData.password.length < 6) {
|
|
|
+ showToast(t('users.toast.passwordTooShort'), 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
createMutation.mutate({
|
|
|
username: formData.username,
|
|
|
- password: formData.password,
|
|
|
+ password: advancedAuthEnabled ? undefined : formData.password,
|
|
|
+ email: formData.email || undefined,
|
|
|
role: formData.role,
|
|
|
group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
|
|
|
});
|
|
|
@@ -141,6 +177,7 @@ export function UsersPage() {
|
|
|
const updateData: UserUpdate = {
|
|
|
username: formData.username || undefined,
|
|
|
password: formData.password || undefined,
|
|
|
+ email: formData.email || undefined,
|
|
|
role: formData.role,
|
|
|
group_ids: formData.group_ids,
|
|
|
};
|
|
|
@@ -148,6 +185,10 @@ export function UsersPage() {
|
|
|
if (!updateData.password) {
|
|
|
delete updateData.password;
|
|
|
}
|
|
|
+ // Remove email if empty
|
|
|
+ if (!updateData.email) {
|
|
|
+ delete updateData.email;
|
|
|
+ }
|
|
|
updateMutation.mutate({ id, data: updateData });
|
|
|
};
|
|
|
|
|
|
@@ -160,6 +201,7 @@ export function UsersPage() {
|
|
|
setFormData({
|
|
|
username: user.username,
|
|
|
password: '',
|
|
|
+ email: user.email || '',
|
|
|
confirmPassword: '',
|
|
|
role: user.role,
|
|
|
group_ids: user.groups?.map(g => g.id) || [],
|
|
|
@@ -170,7 +212,7 @@ export function UsersPage() {
|
|
|
const closeEditModal = () => {
|
|
|
setShowEditModal(false);
|
|
|
setEditingUserId(null);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
};
|
|
|
|
|
|
const toggleGroup = (groupId: number) => {
|
|
|
@@ -221,7 +263,7 @@ export function UsersPage() {
|
|
|
<Button
|
|
|
onClick={() => {
|
|
|
setShowCreateModal(true);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}}
|
|
|
>
|
|
|
<Plus className="w-4 h-4" />
|
|
|
@@ -316,6 +358,17 @@ export function UsersPage() {
|
|
|
{t('users.delete')}
|
|
|
</Button>
|
|
|
)}
|
|
|
+ {advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ variant="ghost"
|
|
|
+ onClick={() => resetPasswordMutation.mutate(user.id)}
|
|
|
+ disabled={resetPasswordMutation.isPending}
|
|
|
+ >
|
|
|
+ <RotateCcw className="w-4 h-4" />
|
|
|
+ {t('users.resetPassword') || 'Reset Password'}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</td>
|
|
|
</tr>
|
|
|
@@ -332,7 +385,7 @@ export function UsersPage() {
|
|
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
|
|
onClick={() => {
|
|
|
setShowCreateModal(false);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}}
|
|
|
>
|
|
|
<Card
|
|
|
@@ -350,7 +403,7 @@ export function UsersPage() {
|
|
|
size="sm"
|
|
|
onClick={() => {
|
|
|
setShowCreateModal(false);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}}
|
|
|
>
|
|
|
<X className="w-5 h-5" />
|
|
|
@@ -372,41 +425,67 @@ export function UsersPage() {
|
|
|
autoComplete="username"
|
|
|
/>
|
|
|
</div>
|
|
|
- <div>
|
|
|
- <label className="block text-sm font-medium text-white mb-2">
|
|
|
- {t('users.form.password')}
|
|
|
- </label>
|
|
|
- <input
|
|
|
- type="password"
|
|
|
- value={formData.password}
|
|
|
- onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
|
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
|
- placeholder={t('users.form.passwordPlaceholder')}
|
|
|
- autoComplete="new-password"
|
|
|
- minLength={6}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <label className="block text-sm font-medium text-white mb-2">
|
|
|
- {t('users.form.confirmPassword')}
|
|
|
- </label>
|
|
|
- <input
|
|
|
- type="password"
|
|
|
- value={formData.confirmPassword}
|
|
|
- onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
|
- className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
|
|
|
- formData.confirmPassword && formData.password !== formData.confirmPassword
|
|
|
- ? 'border-red-500'
|
|
|
- : 'border-bambu-dark-tertiary'
|
|
|
- }`}
|
|
|
- placeholder={t('users.form.confirmPasswordPlaceholder')}
|
|
|
- autoComplete="new-password"
|
|
|
- minLength={6}
|
|
|
- />
|
|
|
- {formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
|
|
- <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ {advancedAuthStatus?.advanced_auth_enabled && (
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-white mb-2">
|
|
|
+ {t('users.form.email') || 'Email'}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="email"
|
|
|
+ value={formData.email}
|
|
|
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
|
+ className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
|
+ placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
|
|
|
+ required={advancedAuthStatus?.advanced_auth_enabled}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!advancedAuthStatus?.advanced_auth_enabled && (
|
|
|
+ <>
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-white mb-2">
|
|
|
+ {t('users.form.password')}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="password"
|
|
|
+ value={formData.password}
|
|
|
+ onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
|
+ className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
|
+ placeholder={t('users.form.passwordPlaceholder')}
|
|
|
+ autoComplete="new-password"
|
|
|
+ minLength={6}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-white mb-2">
|
|
|
+ {t('users.form.confirmPassword')}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="password"
|
|
|
+ value={formData.confirmPassword}
|
|
|
+ onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
|
+ className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
|
|
|
+ formData.confirmPassword && formData.password !== formData.confirmPassword
|
|
|
+ ? 'border-red-500'
|
|
|
+ : 'border-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ placeholder={t('users.form.confirmPasswordPlaceholder')}
|
|
|
+ autoComplete="new-password"
|
|
|
+ minLength={6}
|
|
|
+ />
|
|
|
+ {formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
|
|
+ <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {advancedAuthStatus?.advanced_auth_enabled && (
|
|
|
+ <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3">
|
|
|
+ <p className="text-sm text-bambu-gray">
|
|
|
+ {t('users.form.autoGeneratedPassword') || 'A secure password will be automatically generated and emailed to the user.'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
<div>
|
|
|
<label className="block text-sm font-medium text-white mb-2">
|
|
|
{t('users.form.groups')}
|
|
|
@@ -440,7 +519,7 @@ export function UsersPage() {
|
|
|
variant="secondary"
|
|
|
onClick={() => {
|
|
|
setShowCreateModal(false);
|
|
|
- setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
+ setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
|
|
}}
|
|
|
>
|
|
|
{t('users.modal.cancel')}
|
|
|
@@ -507,6 +586,18 @@ export function UsersPage() {
|
|
|
autoComplete="username"
|
|
|
/>
|
|
|
</div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-white mb-2">
|
|
|
+ {t('users.form.email') || 'Email'} <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="email"
|
|
|
+ value={formData.email}
|
|
|
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
|
+ className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
|
+ placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
<div>
|
|
|
<label className="block text-sm font-medium text-white mb-2">
|
|
|
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
|