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:
thomas 2026-08-25 08:11:55 +02:00
parent 63f6d97318
commit 81b21da499
24 changed files with 366 additions and 39 deletions

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton';
import Trash from './Trash';
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 ErrorMessage from '../common/ErrorMessage';
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) => {
try {
const response = await updateConfig(updatedConfig);
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
return;
}
if (file.size > 500 * 1024) {
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/>
</>
)}
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button>
)}
</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>

View File

@ -267,7 +267,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">
<EFBFBD> {user.phone}
📱 {user.phone}
</a>
{user.landline && (
<a href={`tel:${user.landline}`} className="user-phone">

View File

@ -303,3 +303,30 @@
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;
}

View File

@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
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 [lat, setLat] = useState(user.gps?.lat?.toString() || '');
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
const [isEditingGPS, setIsEditingGPS] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);
const [invite, setInvite] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const photoInputRef = useRef(null);
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 (
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
<div className="user-header">
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
</span>
</label>
</div>
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<p className="user-type">
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
<div className="gps-section">
<h4>GPS-Koordinaten</h4>
{!isEditingGPS ? (
<div className="gps-display">
{user.gps && user.gps.lat && user.gps.lng ? (
<span>
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)}
</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">
<button
className="edit-button"

View File

@ -7,7 +7,7 @@ import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
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 [showCreateForm, setShowCreateForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
onDelete={handleDelete}
onPhotoUpload={onPhotoUpload}
onPhotoDelete={onPhotoDelete}
onGenerateInvite={onGenerateInvite}
/>
))}
</div>

View File

@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
// Keep a ref so callbacks always call the latest fetchUsers
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);
setError(null);
if (filters !== null) {
activeFiltersRef.current = filters;
}
const params = {
page,
limit: 50,
...filters
...activeFiltersRef.current
};
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
fetchUsersRef.current = fetchUsers;
useEffect(() => {
fetchUsers(1, true); // Initial load, reset users
fetchUsers(1, true, {}); // Initial load, reset users and filters
}, [fetchUsers]);
const loadMore = useCallback(() => {
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
refetch
};
};

View File

@ -38,12 +38,18 @@ api.interceptors.response.use(
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
if (!config || !config.retry) {
if (config.retry === undefined) {
config.retry = 2; // Default: 2 retries
config.retryCount = 0;
}
const shouldRetry = (
error.code === 'ECONNABORTED' || // Timeout
error.code === 'ERR_NETWORK' || // Network error

View File

@ -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) => {
try {
await api.delete(`/users/${id}/photo`);

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton';
import Trash from './Trash';
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 ErrorMessage from '../common/ErrorMessage';
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) => {
try {
const response = await updateConfig(updatedConfig);
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
return;
}
if (file.size > 500 * 1024) {
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/>
</>
)}
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button>
)}
</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>

View File

@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">
<EFBFBD> {user.phone}
📱 {user.phone}
</a>
{user.landline && (
<a href={`tel:${user.landline}`} className="user-phone">

View File

@ -303,3 +303,30 @@
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;
}

View File

@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
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 [lat, setLat] = useState(user.gps?.lat?.toString() || '');
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
const [isEditingGPS, setIsEditingGPS] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);
const [invite, setInvite] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const photoInputRef = useRef(null);
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 (
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
<div className="user-header">
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
</span>
</label>
</div>
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<p className="user-type">
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
<div className="gps-section">
<h4>GPS-Koordinaten</h4>
{!isEditingGPS ? (
<div className="gps-display">
{user.gps && user.gps.lat && user.gps.lng ? (
<span>
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)}
</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">
<button
className="edit-button"

View File

@ -7,7 +7,7 @@ import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
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 [showCreateForm, setShowCreateForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
onDelete={handleDelete}
onPhotoUpload={onPhotoUpload}
onPhotoDelete={onPhotoDelete}
onGenerateInvite={onGenerateInvite}
/>
))}
</div>

View File

@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
// Keep a ref so callbacks always call the latest fetchUsers
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);
setError(null);
if (filters !== null) {
activeFiltersRef.current = filters;
}
const params = {
page,
limit: 50,
...filters
...activeFiltersRef.current
};
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
fetchUsersRef.current = fetchUsers;
useEffect(() => {
fetchUsers(1, true); // Initial load, reset users
fetchUsers(1, true, {}); // Initial load, reset users and filters
}, [fetchUsers]);
const loadMore = useCallback(() => {
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
refetch
};
};

View File

@ -38,12 +38,18 @@ api.interceptors.response.use(
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
if (!config || !config.retry) {
if (config.retry === undefined) {
config.retry = 2; // Default: 2 retries
config.retryCount = 0;
}
const shouldRetry = (
error.code === 'ECONNABORTED' || // Timeout
error.code === 'ERR_NETWORK' || // Network error

View File

@ -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) => {
try {
await api.delete(`/users/${id}/photo`);

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton';
import Trash from './Trash';
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 ErrorMessage from '../common/ErrorMessage';
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) => {
try {
const response = await updateConfig(updatedConfig);
@ -187,7 +197,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
return;
}
if (file.size > 500 * 1024) {
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/>
</>
)}
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button>
)}
</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>

View File

@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">
<EFBFBD> {user.phone}
📱 {user.phone}
</a>
{user.landline && (
<a href={`tel:${user.landline}`} className="user-phone">

View File

@ -303,3 +303,30 @@
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;
}

View File

@ -2,12 +2,14 @@ import React, { useState, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
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 [lat, setLat] = useState(user.gps?.lat?.toString() || '');
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
const [isEditingGPS, setIsEditingGPS] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);
const [invite, setInvite] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const photoInputRef = useRef(null);
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 (
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
<div className="user-header">
@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
</span>
</label>
</div>
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<p className="user-type">
@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
<div className="gps-section">
<h4>GPS-Koordinaten</h4>
{!isEditingGPS ? (
<div className="gps-display">
{user.gps && user.gps.lat && user.gps.lng ? (
<span>
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)}
</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">
<button
className="edit-button"

View File

@ -7,7 +7,7 @@ import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
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 [showCreateForm, setShowCreateForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@ -145,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
onDelete={handleDelete}
onPhotoUpload={onPhotoUpload}
onPhotoDelete={onPhotoDelete}
onGenerateInvite={onGenerateInvite}
/>
))}
</div>

View File

@ -15,15 +15,22 @@ export const useUsers = (isAdmin = false) => {
// Keep a ref so callbacks always call the latest fetchUsers
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);
setError(null);
if (filters !== null) {
activeFiltersRef.current = filters;
}
const params = {
page,
limit: 50,
...filters
...activeFiltersRef.current
};
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
@ -40,7 +47,7 @@ export const useUsers = (isAdmin = false) => {
fetchUsersRef.current = fetchUsers;
useEffect(() => {
fetchUsers(1, true); // Initial load, reset users
fetchUsers(1, true, {}); // Initial load, reset users and filters
}, [fetchUsers]);
const loadMore = useCallback(() => {
@ -62,4 +69,3 @@ export const useUsers = (isAdmin = false) => {
refetch
};
};

View File

@ -38,12 +38,18 @@ api.interceptors.response.use(
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
if (!config || !config.retry) {
if (config.retry === undefined) {
config.retry = 2; // Default: 2 retries
config.retryCount = 0;
}
const shouldRetry = (
error.code === 'ECONNABORTED' || // Timeout
error.code === 'ERR_NETWORK' || // Network error

View File

@ -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) => {
try {
await api.delete(`/users/${id}/photo`);