Projektarten: Login behaelt Ziel-Link, Hinweise im Ticket-Dialog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 18:03:40 +00:00
1 parent c16049d266
commit 471c7a056d
5 files changed
+49 -7

No files matched your search

+6 -3
View File
@@ -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 { AuthProvider, useAuth } from './context/AuthContext'
import Sidebar from './components/Navbar' import Sidebar from './components/Navbar'
import Footer from './components/Footer' import Footer from './components/Footer'
@@ -14,9 +14,11 @@ import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage' import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage' import ServicesPage from './pages/ServicesPage'
import ServerpulsPage from './pages/ServerpulsPage' import ServerpulsPage from './pages/ServerpulsPage'
import { safeReturnPath } from './lib/returnPath'
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const location = useLocation()
if (loading) { if (loading) {
return ( return (
@@ -28,7 +30,7 @@ function ProtectedRoute({ children }) {
} }
if (!user) { if (!user) {
return <Navigate to="/login" replace /> return <Navigate to="/login" state={{ from: location }} replace />
} }
return children return children
@@ -36,10 +38,11 @@ function ProtectedRoute({ children }) {
function AppRoutes() { function AppRoutes() {
const { user } = useAuth() const { user } = useAuth()
const location = useLocation()
return ( return (
<Routes> <Routes>
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} /> <Route path="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} /> <Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} /> <Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} /> <Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
+8 -1
View File
@@ -115,9 +115,13 @@ export default function TicketProjects({ ticket }) {
// das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden. // das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden.
const assignWithKind = async (kind) => { const assignWithKind = async (kind) => {
setKindDialog(false) setKindDialog(false)
setBusy(true)
setError('') setError('')
setAssignResult(null) setAssignResult(null)
if (!ticket.customerId) {
setError('Dieses Ticket hat keinen Kunden. Bitte zuerst einen Kunden am Ticket eintragen.')
return
}
setBusy(true)
try { try {
const projectName = available.find((p) => p.$id === selected)?.projectName || '' const projectName = available.find((p) => p.$id === selected)?.projectName || ''
const r = await completeAssignment(selected, { const r = await completeAssignment(selected, {
@@ -217,6 +221,9 @@ export default function TicketProjects({ ticket }) {
<div className="overlay-content"> <div className="overlay-content">
<h2 className="mb-2">Was ist das?</h2> <h2 className="mb-2">Was ist das?</h2>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p> <p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
{kinds.length === 0 && (
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{kinds.map((k) => ( {kinds.map((k) => (
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button> <button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
+8
View File
@@ -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
}
+6 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import LetterGlitch from '../components/LetterGlitch' import LetterGlitch from '../components/LetterGlitch'
import { safeReturnPath } from '../lib/returnPath'
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -13,6 +14,8 @@ export default function LoginPage() {
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth() const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const target = safeReturnPath(location.state?.from)
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
@@ -23,7 +26,7 @@ export default function LoginPage() {
const result = await login(email, password) const result = await login(email, password)
if (result.success) { if (result.success) {
navigate('/tickets') navigate(target, { replace: true })
} else if (result.mfaRequired) { } else if (result.mfaRequired) {
if (result.error) setError(result.error) if (result.error) setError(result.error)
} else { } else {
@@ -52,7 +55,7 @@ export default function LoginPage() {
const result = await verifyCode(code) const result = await verifyCode(code)
setLoading(false) setLoading(false)
if (result.success) { if (result.success) {
navigate('/tickets') navigate(target, { replace: true })
} else { } else {
setError(result.error) setError(result.error)
setCode('') setCode('')
+21
View File
@@ -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')
})