fix(frontend): kaputtes UserCard-JSX reparieren, Einladungs-Token erzeugbar machen
- UserCard.js ließ sich nicht übersetzen: die öffnende Hälfte des Ternary
um den GPS-Bereich ({isEditingGPS ? () fehlte, während das ") : (" und
das ")}" stehen geblieben waren. isEditingGPS wurde dadurch nur noch
gesetzt, nie gelesen. Da die Datei in allen drei Apps identisch ist,
scheiterte überall der Build.
- Der Einladungs-Flow aus 8384ad9 war nur zur Hälfte da: Backend und
Login-Formular verlangen einen Invite-Token, aber kein Frontend hat
/invite-token je aufgerufen – der Admin konnte gar keinen erzeugen und
ein Führer ohne Passwort kam nicht ins System. Die Benutzerkarte hat
jetzt einen Button dafür (nur bei hinterlegter E-Mail), verdrahtet über
UserList und AdminPanel.
- api.js: bei einem Fehler ohne Request-Config lief der Retry-Zweig in
"config.retry = 2" auf undefined und verdeckte den echten Fehler.
- useUsers: "mehr laden" hat die aktiven Filter verworfen und die
ungefilterte zweite Seite angehängt. Mit dem serverseitigen Typfilter
aus d9fecb6 fällt das jetzt deutlich stärker auf.
- Die öffentliche Liste zeigte statt des Telefon-Emojis ein kaputtes
Ersatzzeichen.
- Der Logo-Upload nannte SVG als erlaubtes Format, das Backend lehnt es
bewusst ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
63f6d97318
commit
81b21da499
|
|
@ -3,7 +3,7 @@ import UserList from '../users/UserList';
|
||||||
import ExportButton from './ExportButton';
|
import ExportButton from './ExportButton';
|
||||||
import Trash from './Trash';
|
import Trash from './Trash';
|
||||||
import AuditLogs from './AuditLogs';
|
import AuditLogs from './AuditLogs';
|
||||||
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users';
|
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
|
||||||
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './AdminPanel.css';
|
import './AdminPanel.css';
|
||||||
|
|
@ -127,6 +127,16 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async (id) => {
|
||||||
|
const result = await generateInviteToken(id);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
|
||||||
|
} else {
|
||||||
|
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfigUpdate = async (updatedConfig) => {
|
const handleConfigUpdate = async (updatedConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateConfig(updatedConfig);
|
const response = await updateConfig(updatedConfig);
|
||||||
|
|
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
|
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 500 * 1024) {
|
if (file.size > 500 * 1024) {
|
||||||
|
|
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
onUserDelete={handleUserDelete}
|
onUserDelete={handleUserDelete}
|
||||||
onPhotoUpload={handleUserPhotoUpload}
|
onPhotoUpload={handleUserPhotoUpload}
|
||||||
onPhotoDelete={handleUserPhotoDelete}
|
onPhotoDelete={handleUserPhotoDelete}
|
||||||
|
onGenerateInvite={handleGenerateInvite}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p>
|
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<a href={`tel:${user.phone}`} className="user-phone">
|
<a href={`tel:${user.phone}`} className="user-phone">
|
||||||
<EFBFBD> {user.phone}
|
📱 {user.phone}
|
||||||
</a>
|
</a>
|
||||||
{user.landline && (
|
{user.landline && (
|
||||||
<a href={`tel:${user.landline}`} className="user-phone">
|
<a href={`tel:${user.landline}`} className="user-phone">
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,30 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.invite-section h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.invite-result {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.invite-token {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: var(--color-bg-muted, #f2f2ec);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import './UserCard.css';
|
import './UserCard.css';
|
||||||
|
|
||||||
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const { userTypeLabels } = useConfigContext();
|
const { userTypeLabels } = useConfigContext();
|
||||||
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
||||||
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
||||||
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
||||||
const [photoUploading, setPhotoUploading] = useState(false);
|
const [photoUploading, setPhotoUploading] = useState(false);
|
||||||
|
const [invite, setInvite] = useState(null);
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
const photoInputRef = useRef(null);
|
const photoInputRef = useRef(null);
|
||||||
|
|
||||||
const handleGPSUpdate = () => {
|
const handleGPSUpdate = () => {
|
||||||
|
|
@ -50,6 +52,16 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async () => {
|
||||||
|
if (!onGenerateInvite) return;
|
||||||
|
setInviteLoading(true);
|
||||||
|
const result = await onGenerateInvite(user._id);
|
||||||
|
if (result?.success) {
|
||||||
|
setInvite(result.data);
|
||||||
|
}
|
||||||
|
setInviteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
||||||
<div className="user-header">
|
<div className="user-header">
|
||||||
|
|
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<p className="user-type">
|
<p className="user-type">
|
||||||
|
|
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
|
|
||||||
<div className="gps-section">
|
<div className="gps-section">
|
||||||
<h4>GPS-Koordinaten</h4>
|
<h4>GPS-Koordinaten</h4>
|
||||||
|
{!isEditingGPS ? (
|
||||||
<div className="gps-display">
|
<div className="gps-display">
|
||||||
{user.gps && user.gps.lat && user.gps.lng ? (
|
{user.gps && user.gps.lat && user.gps.lng ? (
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein
|
||||||
|
eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */}
|
||||||
|
<div className="invite-section">
|
||||||
|
<h4>Zugang für den Drohnenführer</h4>
|
||||||
|
{!user.email ? (
|
||||||
|
<p className="settings-hint">
|
||||||
|
Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleGenerateInvite}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
>
|
||||||
|
{inviteLoading ? 'Erzeuge…' : '🔑 Einladungs-Token erzeugen'}
|
||||||
|
</button>
|
||||||
|
{invite && (
|
||||||
|
<div className="invite-result">
|
||||||
|
<p className="settings-hint">
|
||||||
|
Diesen Token an <strong>{invite.email}</strong> weitergeben. Gültig bis{' '}
|
||||||
|
{new Date(invite.expiresAt).toLocaleDateString('de-DE')}.
|
||||||
|
</p>
|
||||||
|
<code className="invite-token">{invite.inviteToken}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<button
|
||||||
className="edit-button"
|
className="edit-button"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './UserList.css';
|
import './UserList.css';
|
||||||
|
|
||||||
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onPhotoUpload={onPhotoUpload}
|
onPhotoUpload={onPhotoUpload}
|
||||||
onPhotoDelete={onPhotoDelete}
|
onPhotoDelete={onPhotoDelete}
|
||||||
|
onGenerateInvite={onGenerateInvite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
|
||||||
|
|
||||||
// Keep a ref so callbacks always call the latest fetchUsers
|
// Keep a ref so callbacks always call the latest fetchUsers
|
||||||
const fetchUsersRef = useRef(null);
|
const fetchUsersRef = useRef(null);
|
||||||
|
// Zuletzt verwendete Filter, damit „mehr laden" nicht auf die ungefilterte
|
||||||
|
// Liste zurückfällt und Fremdeinträge an das Ergebnis anhängt.
|
||||||
|
const activeFiltersRef = useRef({});
|
||||||
|
|
||||||
const fetchUsers = useCallback(async (page = 1, reset = false, filters = {}) => {
|
const fetchUsers = useCallback(async (page = 1, reset = false, filters = null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (filters !== null) {
|
||||||
|
activeFiltersRef.current = filters;
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
page,
|
page,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
...filters
|
...activeFiltersRef.current
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
||||||
|
|
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
|
||||||
fetchUsersRef.current = fetchUsers;
|
fetchUsersRef.current = fetchUsers;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(1, true); // Initial load, reset users
|
fetchUsers(1, true, {}); // Initial load, reset users and filters
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
|
||||||
refetch
|
refetch
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,18 @@ api.interceptors.response.use(
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne Request-Config (z. B. Fehler schon beim Aufbau der Anfrage) gibt es
|
||||||
|
// nichts zu wiederholen – vorher lief das in einen TypeError auf undefined.
|
||||||
|
if (!config) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Retry logic for network errors or 5xx errors
|
// Retry logic for network errors or 5xx errors
|
||||||
if (!config || !config.retry) {
|
if (config.retry === undefined) {
|
||||||
config.retry = 2; // Default: 2 retries
|
config.retry = 2; // Default: 2 retries
|
||||||
config.retryCount = 0;
|
config.retryCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRetry = (
|
const shouldRetry = (
|
||||||
error.code === 'ECONNABORTED' || // Timeout
|
error.code === 'ECONNABORTED' || // Timeout
|
||||||
error.code === 'ERR_NETWORK' || // Network error
|
error.code === 'ERR_NETWORK' || // Network error
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,20 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
|
export const generateInviteToken = async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/drohnenfuehrer/${id}/invite-token`);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || 'Fehler beim Erzeugen des Einladungs-Tokens'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteUserPhoto = async (id) => {
|
export const deleteUserPhoto = async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/users/${id}/photo`);
|
await api.delete(`/users/${id}/photo`);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import UserList from '../users/UserList';
|
||||||
import ExportButton from './ExportButton';
|
import ExportButton from './ExportButton';
|
||||||
import Trash from './Trash';
|
import Trash from './Trash';
|
||||||
import AuditLogs from './AuditLogs';
|
import AuditLogs from './AuditLogs';
|
||||||
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users';
|
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
|
||||||
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './AdminPanel.css';
|
import './AdminPanel.css';
|
||||||
|
|
@ -127,6 +127,16 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async (id) => {
|
||||||
|
const result = await generateInviteToken(id);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
|
||||||
|
} else {
|
||||||
|
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfigUpdate = async (updatedConfig) => {
|
const handleConfigUpdate = async (updatedConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateConfig(updatedConfig);
|
const response = await updateConfig(updatedConfig);
|
||||||
|
|
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
|
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 500 * 1024) {
|
if (file.size > 500 * 1024) {
|
||||||
|
|
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
onUserDelete={handleUserDelete}
|
onUserDelete={handleUserDelete}
|
||||||
onPhotoUpload={handleUserPhotoUpload}
|
onPhotoUpload={handleUserPhotoUpload}
|
||||||
onPhotoDelete={handleUserPhotoDelete}
|
onPhotoDelete={handleUserPhotoDelete}
|
||||||
|
onGenerateInvite={handleGenerateInvite}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p>
|
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<a href={`tel:${user.phone}`} className="user-phone">
|
<a href={`tel:${user.phone}`} className="user-phone">
|
||||||
<EFBFBD> {user.phone}
|
📱 {user.phone}
|
||||||
</a>
|
</a>
|
||||||
{user.landline && (
|
{user.landline && (
|
||||||
<a href={`tel:${user.landline}`} className="user-phone">
|
<a href={`tel:${user.landline}`} className="user-phone">
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,30 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.invite-section h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.invite-result {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.invite-token {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: var(--color-bg-muted, #f2f2ec);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import './UserCard.css';
|
import './UserCard.css';
|
||||||
|
|
||||||
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const { userTypeLabels } = useConfigContext();
|
const { userTypeLabels } = useConfigContext();
|
||||||
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
||||||
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
||||||
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
||||||
const [photoUploading, setPhotoUploading] = useState(false);
|
const [photoUploading, setPhotoUploading] = useState(false);
|
||||||
|
const [invite, setInvite] = useState(null);
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
const photoInputRef = useRef(null);
|
const photoInputRef = useRef(null);
|
||||||
|
|
||||||
const handleGPSUpdate = () => {
|
const handleGPSUpdate = () => {
|
||||||
|
|
@ -50,6 +52,16 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async () => {
|
||||||
|
if (!onGenerateInvite) return;
|
||||||
|
setInviteLoading(true);
|
||||||
|
const result = await onGenerateInvite(user._id);
|
||||||
|
if (result?.success) {
|
||||||
|
setInvite(result.data);
|
||||||
|
}
|
||||||
|
setInviteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
||||||
<div className="user-header">
|
<div className="user-header">
|
||||||
|
|
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<p className="user-type">
|
<p className="user-type">
|
||||||
|
|
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
|
|
||||||
<div className="gps-section">
|
<div className="gps-section">
|
||||||
<h4>GPS-Koordinaten</h4>
|
<h4>GPS-Koordinaten</h4>
|
||||||
|
{!isEditingGPS ? (
|
||||||
<div className="gps-display">
|
<div className="gps-display">
|
||||||
{user.gps && user.gps.lat && user.gps.lng ? (
|
{user.gps && user.gps.lat && user.gps.lng ? (
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein
|
||||||
|
eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */}
|
||||||
|
<div className="invite-section">
|
||||||
|
<h4>Zugang für den Hundeführer</h4>
|
||||||
|
{!user.email ? (
|
||||||
|
<p className="settings-hint">
|
||||||
|
Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleGenerateInvite}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
>
|
||||||
|
{inviteLoading ? 'Erzeuge…' : '🔑 Einladungs-Token erzeugen'}
|
||||||
|
</button>
|
||||||
|
{invite && (
|
||||||
|
<div className="invite-result">
|
||||||
|
<p className="settings-hint">
|
||||||
|
Diesen Token an <strong>{invite.email}</strong> weitergeben. Gültig bis{' '}
|
||||||
|
{new Date(invite.expiresAt).toLocaleDateString('de-DE')}.
|
||||||
|
</p>
|
||||||
|
<code className="invite-token">{invite.inviteToken}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<button
|
||||||
className="edit-button"
|
className="edit-button"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './UserList.css';
|
import './UserList.css';
|
||||||
|
|
||||||
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onPhotoUpload={onPhotoUpload}
|
onPhotoUpload={onPhotoUpload}
|
||||||
onPhotoDelete={onPhotoDelete}
|
onPhotoDelete={onPhotoDelete}
|
||||||
|
onGenerateInvite={onGenerateInvite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
|
||||||
|
|
||||||
// Keep a ref so callbacks always call the latest fetchUsers
|
// Keep a ref so callbacks always call the latest fetchUsers
|
||||||
const fetchUsersRef = useRef(null);
|
const fetchUsersRef = useRef(null);
|
||||||
|
// Zuletzt verwendete Filter, damit „mehr laden" nicht auf die ungefilterte
|
||||||
|
// Liste zurückfällt und Fremdeinträge an das Ergebnis anhängt.
|
||||||
|
const activeFiltersRef = useRef({});
|
||||||
|
|
||||||
const fetchUsers = useCallback(async (page = 1, reset = false, filters = {}) => {
|
const fetchUsers = useCallback(async (page = 1, reset = false, filters = null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (filters !== null) {
|
||||||
|
activeFiltersRef.current = filters;
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
page,
|
page,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
...filters
|
...activeFiltersRef.current
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
||||||
|
|
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
|
||||||
fetchUsersRef.current = fetchUsers;
|
fetchUsersRef.current = fetchUsers;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(1, true); // Initial load, reset users
|
fetchUsers(1, true, {}); // Initial load, reset users and filters
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
|
||||||
refetch
|
refetch
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,18 @@ api.interceptors.response.use(
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne Request-Config (z. B. Fehler schon beim Aufbau der Anfrage) gibt es
|
||||||
|
// nichts zu wiederholen – vorher lief das in einen TypeError auf undefined.
|
||||||
|
if (!config) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Retry logic for network errors or 5xx errors
|
// Retry logic for network errors or 5xx errors
|
||||||
if (!config || !config.retry) {
|
if (config.retry === undefined) {
|
||||||
config.retry = 2; // Default: 2 retries
|
config.retry = 2; // Default: 2 retries
|
||||||
config.retryCount = 0;
|
config.retryCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRetry = (
|
const shouldRetry = (
|
||||||
error.code === 'ECONNABORTED' || // Timeout
|
error.code === 'ECONNABORTED' || // Timeout
|
||||||
error.code === 'ERR_NETWORK' || // Network error
|
error.code === 'ERR_NETWORK' || // Network error
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,20 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
|
export const generateInviteToken = async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/handler/${id}/invite-token`);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || 'Fehler beim Erzeugen des Einladungs-Tokens'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteUserPhoto = async (id) => {
|
export const deleteUserPhoto = async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/users/${id}/photo`);
|
await api.delete(`/users/${id}/photo`);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import UserList from '../users/UserList';
|
||||||
import ExportButton from './ExportButton';
|
import ExportButton from './ExportButton';
|
||||||
import Trash from './Trash';
|
import Trash from './Trash';
|
||||||
import AuditLogs from './AuditLogs';
|
import AuditLogs from './AuditLogs';
|
||||||
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users';
|
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
|
||||||
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './AdminPanel.css';
|
import './AdminPanel.css';
|
||||||
|
|
@ -127,6 +127,16 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async (id) => {
|
||||||
|
const result = await generateInviteToken(id);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
|
||||||
|
} else {
|
||||||
|
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfigUpdate = async (updatedConfig) => {
|
const handleConfigUpdate = async (updatedConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateConfig(updatedConfig);
|
const response = await updateConfig(updatedConfig);
|
||||||
|
|
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
|
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 500 * 1024) {
|
if (file.size > 500 * 1024) {
|
||||||
|
|
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
onUserDelete={handleUserDelete}
|
onUserDelete={handleUserDelete}
|
||||||
onPhotoUpload={handleUserPhotoUpload}
|
onPhotoUpload={handleUserPhotoUpload}
|
||||||
onPhotoDelete={handleUserPhotoDelete}
|
onPhotoDelete={handleUserPhotoDelete}
|
||||||
|
onGenerateInvite={handleGenerateInvite}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p>
|
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<a href={`tel:${user.phone}`} className="user-phone">
|
<a href={`tel:${user.phone}`} className="user-phone">
|
||||||
<EFBFBD> {user.phone}
|
📱 {user.phone}
|
||||||
</a>
|
</a>
|
||||||
{user.landline && (
|
{user.landline && (
|
||||||
<a href={`tel:${user.landline}`} className="user-phone">
|
<a href={`tel:${user.landline}`} className="user-phone">
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,30 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.invite-section h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.invite-result {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.invite-token {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: var(--color-bg-muted, #f2f2ec);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import './UserCard.css';
|
import './UserCard.css';
|
||||||
|
|
||||||
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const { userTypeLabels } = useConfigContext();
|
const { userTypeLabels } = useConfigContext();
|
||||||
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
|
||||||
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
|
||||||
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
const [isEditingGPS, setIsEditingGPS] = useState(false);
|
||||||
const [photoUploading, setPhotoUploading] = useState(false);
|
const [photoUploading, setPhotoUploading] = useState(false);
|
||||||
|
const [invite, setInvite] = useState(null);
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
const photoInputRef = useRef(null);
|
const photoInputRef = useRef(null);
|
||||||
|
|
||||||
const handleGPSUpdate = () => {
|
const handleGPSUpdate = () => {
|
||||||
|
|
@ -50,6 +52,16 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateInvite = async () => {
|
||||||
|
if (!onGenerateInvite) return;
|
||||||
|
setInviteLoading(true);
|
||||||
|
const result = await onGenerateInvite(user._id);
|
||||||
|
if (result?.success) {
|
||||||
|
setInvite(result.data);
|
||||||
|
}
|
||||||
|
setInviteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
|
||||||
<div className="user-header">
|
<div className="user-header">
|
||||||
|
|
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<p className="user-address">📍 {user.address}</p>
|
<p className="user-address">📍 {user.address}</p>
|
||||||
<p className="user-type">
|
<p className="user-type">
|
||||||
|
|
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
|
|
||||||
<div className="gps-section">
|
<div className="gps-section">
|
||||||
<h4>GPS-Koordinaten</h4>
|
<h4>GPS-Koordinaten</h4>
|
||||||
|
{!isEditingGPS ? (
|
||||||
<div className="gps-display">
|
<div className="gps-display">
|
||||||
{user.gps && user.gps.lat && user.gps.lng ? (
|
{user.gps && user.gps.lat && user.gps.lng ? (
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein
|
||||||
|
eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */}
|
||||||
|
<div className="invite-section">
|
||||||
|
<h4>Zugang für den Stöberhundeführer</h4>
|
||||||
|
{!user.email ? (
|
||||||
|
<p className="settings-hint">
|
||||||
|
Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleGenerateInvite}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
>
|
||||||
|
{inviteLoading ? 'Erzeuge…' : '🔑 Einladungs-Token erzeugen'}
|
||||||
|
</button>
|
||||||
|
{invite && (
|
||||||
|
<div className="invite-result">
|
||||||
|
<p className="settings-hint">
|
||||||
|
Diesen Token an <strong>{invite.email}</strong> weitergeben. Gültig bis{' '}
|
||||||
|
{new Date(invite.expiresAt).toLocaleDateString('de-DE')}.
|
||||||
|
</p>
|
||||||
|
<code className="invite-token">{invite.inviteToken}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<button
|
||||||
className="edit-button"
|
className="edit-button"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
import './UserList.css';
|
import './UserList.css';
|
||||||
|
|
||||||
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete }) => {
|
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onPhotoUpload={onPhotoUpload}
|
onPhotoUpload={onPhotoUpload}
|
||||||
onPhotoDelete={onPhotoDelete}
|
onPhotoDelete={onPhotoDelete}
|
||||||
|
onGenerateInvite={onGenerateInvite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
|
||||||
|
|
||||||
// Keep a ref so callbacks always call the latest fetchUsers
|
// Keep a ref so callbacks always call the latest fetchUsers
|
||||||
const fetchUsersRef = useRef(null);
|
const fetchUsersRef = useRef(null);
|
||||||
|
// Zuletzt verwendete Filter, damit „mehr laden" nicht auf die ungefilterte
|
||||||
|
// Liste zurückfällt und Fremdeinträge an das Ergebnis anhängt.
|
||||||
|
const activeFiltersRef = useRef({});
|
||||||
|
|
||||||
const fetchUsers = useCallback(async (page = 1, reset = false, filters = {}) => {
|
const fetchUsers = useCallback(async (page = 1, reset = false, filters = null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (filters !== null) {
|
||||||
|
activeFiltersRef.current = filters;
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
page,
|
page,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
...filters
|
...activeFiltersRef.current
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
|
||||||
|
|
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
|
||||||
fetchUsersRef.current = fetchUsers;
|
fetchUsersRef.current = fetchUsers;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(1, true); // Initial load, reset users
|
fetchUsers(1, true, {}); // Initial load, reset users and filters
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
|
||||||
refetch
|
refetch
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,18 @@ api.interceptors.response.use(
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne Request-Config (z. B. Fehler schon beim Aufbau der Anfrage) gibt es
|
||||||
|
// nichts zu wiederholen – vorher lief das in einen TypeError auf undefined.
|
||||||
|
if (!config) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Retry logic for network errors or 5xx errors
|
// Retry logic for network errors or 5xx errors
|
||||||
if (!config || !config.retry) {
|
if (config.retry === undefined) {
|
||||||
config.retry = 2; // Default: 2 retries
|
config.retry = 2; // Default: 2 retries
|
||||||
config.retryCount = 0;
|
config.retryCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRetry = (
|
const shouldRetry = (
|
||||||
error.code === 'ECONNABORTED' || // Timeout
|
error.code === 'ECONNABORTED' || // Timeout
|
||||||
error.code === 'ERR_NETWORK' || // Network error
|
error.code === 'ERR_NETWORK' || // Network error
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,20 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
|
export const generateInviteToken = async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/stoeberhundefuehrer/${id}/invite-token`);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || 'Fehler beim Erzeugen des Einladungs-Tokens'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteUserPhoto = async (id) => {
|
export const deleteUserPhoto = async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/users/${id}/photo`);
|
await api.delete(`/users/${id}/photo`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue