diff --git a/drohnenfuehrer/frontend/src/components/admin/AdminPanel.js b/drohnenfuehrer/frontend/src/components/admin/AdminPanel.js index 39cec46..01ffc5e 100644 --- a/drohnenfuehrer/frontend/src/components/admin/AdminPanel.js +++ b/drohnenfuehrer/frontend/src/components/admin/AdminPanel.js @@ -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 }) => { )} -

Empfohlen: PNG, SVG oder JPG, max. 500 KB

+

Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)

)} diff --git a/drohnenfuehrer/frontend/src/components/public/PublicUserList.js b/drohnenfuehrer/frontend/src/components/public/PublicUserList.js index 20cfeac..5a74055 100644 --- a/drohnenfuehrer/frontend/src/components/public/PublicUserList.js +++ b/drohnenfuehrer/frontend/src/components/public/PublicUserList.js @@ -267,7 +267,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {

📍 {user.address}

- � {user.phone} + 📱 {user.phone} {user.landline && ( diff --git a/drohnenfuehrer/frontend/src/components/users/UserCard.css b/drohnenfuehrer/frontend/src/components/users/UserCard.css index 11e29ba..a6f6bb7 100644 --- a/drohnenfuehrer/frontend/src/components/users/UserCard.css +++ b/drohnenfuehrer/frontend/src/components/users/UserCard.css @@ -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; +} diff --git a/drohnenfuehrer/frontend/src/components/users/UserCard.js b/drohnenfuehrer/frontend/src/components/users/UserCard.js index 9af125b..82ef0e7 100644 --- a/drohnenfuehrer/frontend/src/components/users/UserCard.js +++ b/drohnenfuehrer/frontend/src/components/users/UserCard.js @@ -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 (
@@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
- +

📍 {user.address}

@@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o

GPS-Koordinaten

+ {!isEditingGPS ? (
{user.gps && user.gps.lat && user.gps.lng ? ( @@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o )}
+ {/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein + eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */} +
+

Zugang für den Drohnenführer

+ {!user.email ? ( +

+ Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich. +

+ ) : ( + <> + + {invite && ( +
+

+ Diesen Token an {invite.email} weitergeben. Gültig bis{' '} + {new Date(invite.expiresAt).toLocaleDateString('de-DE')}. +

+ {invite.inviteToken} +
+ )} + + )} +
+
diff --git a/drohnenfuehrer/frontend/src/hooks/useUsers.js b/drohnenfuehrer/frontend/src/hooks/useUsers.js index 61798ed..106ed24 100644 --- a/drohnenfuehrer/frontend/src/hooks/useUsers.js +++ b/drohnenfuehrer/frontend/src/hooks/useUsers.js @@ -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 }; }; - diff --git a/drohnenfuehrer/frontend/src/services/api.js b/drohnenfuehrer/frontend/src/services/api.js index 2555e95..b5516d7 100644 --- a/drohnenfuehrer/frontend/src/services/api.js +++ b/drohnenfuehrer/frontend/src/services/api.js @@ -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 diff --git a/drohnenfuehrer/frontend/src/services/users.js b/drohnenfuehrer/frontend/src/services/users.js index 391c3c1..fc5d22a 100644 --- a/drohnenfuehrer/frontend/src/services/users.js +++ b/drohnenfuehrer/frontend/src/services/users.js @@ -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`); diff --git a/nachsuche/frontend/src/components/admin/AdminPanel.js b/nachsuche/frontend/src/components/admin/AdminPanel.js index 05cadeb..d051a0d 100644 --- a/nachsuche/frontend/src/components/admin/AdminPanel.js +++ b/nachsuche/frontend/src/components/admin/AdminPanel.js @@ -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 }) => { )}
-

Empfohlen: PNG, SVG oder JPG, max. 500 KB

+

Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)

)}
diff --git a/nachsuche/frontend/src/components/public/PublicUserList.js b/nachsuche/frontend/src/components/public/PublicUserList.js index 0a83e83..9148e67 100644 --- a/nachsuche/frontend/src/components/public/PublicUserList.js +++ b/nachsuche/frontend/src/components/public/PublicUserList.js @@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {

📍 {user.address}

- � {user.phone} + 📱 {user.phone} {user.landline && ( diff --git a/nachsuche/frontend/src/components/users/UserCard.css b/nachsuche/frontend/src/components/users/UserCard.css index 11e29ba..a6f6bb7 100644 --- a/nachsuche/frontend/src/components/users/UserCard.css +++ b/nachsuche/frontend/src/components/users/UserCard.css @@ -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; +} diff --git a/nachsuche/frontend/src/components/users/UserCard.js b/nachsuche/frontend/src/components/users/UserCard.js index 9af125b..6aa1f22 100644 --- a/nachsuche/frontend/src/components/users/UserCard.js +++ b/nachsuche/frontend/src/components/users/UserCard.js @@ -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 (
@@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
- +

📍 {user.address}

@@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o

GPS-Koordinaten

+ {!isEditingGPS ? (
{user.gps && user.gps.lat && user.gps.lng ? ( @@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o )}
+ {/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein + eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */} +
+

Zugang für den Hundeführer

+ {!user.email ? ( +

+ Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich. +

+ ) : ( + <> + + {invite && ( +
+

+ Diesen Token an {invite.email} weitergeben. Gültig bis{' '} + {new Date(invite.expiresAt).toLocaleDateString('de-DE')}. +

+ {invite.inviteToken} +
+ )} + + )} +
+
diff --git a/nachsuche/frontend/src/hooks/useUsers.js b/nachsuche/frontend/src/hooks/useUsers.js index 61798ed..106ed24 100644 --- a/nachsuche/frontend/src/hooks/useUsers.js +++ b/nachsuche/frontend/src/hooks/useUsers.js @@ -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 }; }; - diff --git a/nachsuche/frontend/src/services/api.js b/nachsuche/frontend/src/services/api.js index 2555e95..b5516d7 100644 --- a/nachsuche/frontend/src/services/api.js +++ b/nachsuche/frontend/src/services/api.js @@ -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 diff --git a/nachsuche/frontend/src/services/users.js b/nachsuche/frontend/src/services/users.js index d40cb7b..40ba044 100644 --- a/nachsuche/frontend/src/services/users.js +++ b/nachsuche/frontend/src/services/users.js @@ -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`); diff --git a/stoeberhunde/frontend/src/components/admin/AdminPanel.js b/stoeberhunde/frontend/src/components/admin/AdminPanel.js index 365a980..4ecba04 100644 --- a/stoeberhunde/frontend/src/components/admin/AdminPanel.js +++ b/stoeberhunde/frontend/src/components/admin/AdminPanel.js @@ -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 }) => { )}
-

Empfohlen: PNG, SVG oder JPG, max. 500 KB

+

Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)

)}
diff --git a/stoeberhunde/frontend/src/components/public/PublicUserList.js b/stoeberhunde/frontend/src/components/public/PublicUserList.js index 1a789c7..3773b70 100644 --- a/stoeberhunde/frontend/src/components/public/PublicUserList.js +++ b/stoeberhunde/frontend/src/components/public/PublicUserList.js @@ -268,7 +268,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {

📍 {user.address}

- � {user.phone} + 📱 {user.phone} {user.landline && ( diff --git a/stoeberhunde/frontend/src/components/users/UserCard.css b/stoeberhunde/frontend/src/components/users/UserCard.css index 11e29ba..a6f6bb7 100644 --- a/stoeberhunde/frontend/src/components/users/UserCard.css +++ b/stoeberhunde/frontend/src/components/users/UserCard.css @@ -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; +} diff --git a/stoeberhunde/frontend/src/components/users/UserCard.js b/stoeberhunde/frontend/src/components/users/UserCard.js index 9af125b..ca234f4 100644 --- a/stoeberhunde/frontend/src/components/users/UserCard.js +++ b/stoeberhunde/frontend/src/components/users/UserCard.js @@ -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 (
@@ -68,7 +80,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
- +

📍 {user.address}

@@ -120,6 +132,7 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o

GPS-Koordinaten

+ {!isEditingGPS ? (
{user.gps && user.gps.lat && user.gps.lng ? ( @@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o )}
+ {/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein + eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */} +
+

Zugang für den Stöberhundeführer

+ {!user.email ? ( +

+ Keine E-Mail hinterlegt – ohne E-Mail ist kein Login möglich. +

+ ) : ( + <> + + {invite && ( +
+

+ Diesen Token an {invite.email} weitergeben. Gültig bis{' '} + {new Date(invite.expiresAt).toLocaleDateString('de-DE')}. +

+ {invite.inviteToken} +
+ )} + + )} +
+
diff --git a/stoeberhunde/frontend/src/hooks/useUsers.js b/stoeberhunde/frontend/src/hooks/useUsers.js index 61798ed..106ed24 100644 --- a/stoeberhunde/frontend/src/hooks/useUsers.js +++ b/stoeberhunde/frontend/src/hooks/useUsers.js @@ -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 }; }; - diff --git a/stoeberhunde/frontend/src/services/api.js b/stoeberhunde/frontend/src/services/api.js index 2555e95..b5516d7 100644 --- a/stoeberhunde/frontend/src/services/api.js +++ b/stoeberhunde/frontend/src/services/api.js @@ -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 diff --git a/stoeberhunde/frontend/src/services/users.js b/stoeberhunde/frontend/src/services/users.js index 2ed318f..fcf20df 100644 --- a/stoeberhunde/frontend/src/services/users.js +++ b/stoeberhunde/frontend/src/services/users.js @@ -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`);