diff --git a/src/App.jsx b/src/App.jsx index 0c28219..eaa0187 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import Sidebar from './components/Navbar' import Footer from './components/Footer' @@ -14,9 +14,11 @@ import LegalPage from './pages/LegalPage' import AssignProjectPage from './pages/AssignProjectPage' import ServicesPage from './pages/ServicesPage' import ServerpulsPage from './pages/ServerpulsPage' +import { safeReturnPath } from './lib/returnPath' function ProtectedRoute({ children }) { const { user, loading } = useAuth() + const location = useLocation() if (loading) { return ( @@ -28,7 +30,7 @@ function ProtectedRoute({ children }) { } if (!user) { - return + return } return children @@ -36,10 +38,11 @@ function ProtectedRoute({ children }) { function AppRoutes() { const { user } = useAuth() + const location = useLocation() return ( - : } /> + : } /> } /> } /> } /> diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 7bd18cc..7d94978 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -115,9 +115,13 @@ export default function TicketProjects({ ticket }) { // das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden. const assignWithKind = async (kind) => { setKindDialog(false) - setBusy(true) setError('') setAssignResult(null) + if (!ticket.customerId) { + setError('Dieses Ticket hat keinen Kunden. Bitte zuerst einen Kunden am Ticket eintragen.') + return + } + setBusy(true) try { const projectName = available.find((p) => p.$id === selected)?.projectName || '' const r = await completeAssignment(selected, { @@ -217,6 +221,9 @@ export default function TicketProjects({ ticket }) {

Was ist das?

Bitte wähle die Art des Projekts, bevor es zugewiesen wird.

+ {kinds.length === 0 && ( +
Arten konnten nicht geladen werden – bitte neu laden.
+ )}
{kinds.map((k) => ( diff --git a/src/lib/returnPath.js b/src/lib/returnPath.js new file mode 100644 index 0000000..e2b6e51 --- /dev/null +++ b/src/lib/returnPath.js @@ -0,0 +1,8 @@ +// Rueckkehr-Ziel nach dem Login: nur relative Pfade dieser App (kein offener Redirect). +export function safeReturnPath(from, fallback = '/tickets') { + const path = typeof from === 'string' ? from : from?.pathname + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return fallback + if (path === '/login' || path.startsWith('/login/')) return fallback + const search = typeof from === 'object' && typeof from?.search === 'string' && from.search.startsWith('?') ? from.search : '' + return path + search +} diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index a945a18..0aaf3a5 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,7 +1,8 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useLocation } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import LetterGlitch from '../components/LetterGlitch' +import { safeReturnPath } from '../lib/returnPath' export default function LoginPage() { const [email, setEmail] = useState('') @@ -13,6 +14,8 @@ export default function LoginPage() { const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth() const navigate = useNavigate() + const location = useLocation() + const target = safeReturnPath(location.state?.from) const handleSubmit = async (e) => { e.preventDefault() @@ -23,7 +26,7 @@ export default function LoginPage() { const result = await login(email, password) if (result.success) { - navigate('/tickets') + navigate(target, { replace: true }) } else if (result.mfaRequired) { if (result.error) setError(result.error) } else { @@ -52,7 +55,7 @@ export default function LoginPage() { const result = await verifyCode(code) setLoading(false) if (result.success) { - navigate('/tickets') + navigate(target, { replace: true }) } else { setError(result.error) setCode('') diff --git a/tests/unit/returnPath.test.mjs b/tests/unit/returnPath.test.mjs new file mode 100644 index 0000000..b1502e6 --- /dev/null +++ b/tests/unit/returnPath.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { safeReturnPath } = await loadModule('src/lib/returnPath.js') + +test('Pfad und Suchteil bleiben erhalten', () => { + assert.equal(safeReturnPath({ pathname: '/projects/zuordnen/abc', search: '?x=1' }), '/projects/zuordnen/abc?x=1') + assert.equal(safeReturnPath({ pathname: '/projects/zuordnen/abc' }), '/projects/zuordnen/abc') + assert.equal(safeReturnPath('/customers/5'), '/customers/5') +}) + +test('Fallback bei fehlendem oder unsicherem Ziel', () => { + for (const bad of [undefined, null, {}, '', 'https://evil.example', '//evil.example', '/\\evil', 'javascript:alert(1)', { pathname: '//evil.example' }, { pathname: 'projects' }, { pathname: '/login' }, 42]) { + assert.equal(safeReturnPath(bad), '/tickets', JSON.stringify(bad)) + } +}) + +test('Suchteil ohne ? wird verworfen', () => { + assert.equal(safeReturnPath({ pathname: '/a', search: 'evil' }), '/a') +})