Add: Feedback-System, GitHub Actions Deploy, Assets-Demo User-Suche
This commit is contained in:
64
.github/workflows/README.md
vendored
Normal file
64
.github/workflows/README.md
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# GitHub Actions — Deploy Workflow
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der Workflow `deploy.yml` läuft automatisch bei jedem Push auf `main`.
|
||||
|
||||
- **Job 1 `build`**: Installiert Dependencies und baut das Frontend — rein zum Kompilierungs-Check.
|
||||
- **Job 2 `deploy`**: Kopiert den Quellcode per SCP auf den Server und startet Docker neu. **Erfordert manuellen Approval** über das GitHub Environment `production`.
|
||||
|
||||
---
|
||||
|
||||
## 1. SSH Key als Secret hinterlegen
|
||||
|
||||
### SSH Key generieren (falls noch kein dedizierter Key vorhanden)
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "github-actions-itnexus" -f ~/.ssh/github_actions_itnexus
|
||||
```
|
||||
|
||||
Den Public Key auf dem Server hinterlegen:
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/github_actions_itnexus.pub | ssh root@192.168.0.194 "cat >> ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### Secrets in GitHub eintragen
|
||||
|
||||
Gehe zu: **Repository → Settings → Secrets and variables → Actions → New repository secret**
|
||||
|
||||
| Secret Name | Wert |
|
||||
|---|---|
|
||||
| `SSH_PRIVATE_KEY` | Inhalt von `~/.ssh/github_actions_itnexus` (Private Key, beginnt mit `-----BEGIN OPENSSH PRIVATE KEY-----`) |
|
||||
| `SERVER_IP` | `192.168.0.194` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Production Environment mit Required Reviewer einrichten
|
||||
|
||||
### Environment erstellen
|
||||
|
||||
1. Gehe zu: **Repository → Settings → Environments**
|
||||
2. Klicke auf **New environment**
|
||||
3. Name: `production` (exakt so, wie im Workflow hinterlegt)
|
||||
4. Klicke auf **Configure environment**
|
||||
|
||||
### Required Reviewers setzen
|
||||
|
||||
1. Aktiviere **Required reviewers**
|
||||
2. Füge `Simon Grüssing` (GitHub-Username) als Reviewer hinzu
|
||||
3. Optional: **Prevent self-review** aktivieren wenn ein weiterer Reviewer vorhanden ist
|
||||
4. Speichern mit **Save protection rules**
|
||||
|
||||
---
|
||||
|
||||
## 3. Approval-Prozess
|
||||
|
||||
1. Push auf `main` → Job `build` startet automatisch und läuft durch.
|
||||
2. Nach erfolgreichem Build: Job `deploy` wartet auf Approval.
|
||||
3. GitHub schickt eine **E-Mail-Benachrichtigung** an alle eingetragenen Reviewer.
|
||||
4. Reviewer klickt in der E-Mail oder direkt im GitHub Actions Tab auf den Workflow-Run.
|
||||
5. Unter **"This workflow run is waiting for a required review"** → **Review deployments** → Haken bei `production` setzen → **Approve and deploy**.
|
||||
6. Erst dann startet der Deployment-Job.
|
||||
|
||||
> Tipp: Den Workflow-Status siehst du unter **Repository → Actions**.
|
||||
59
.github/workflows/deploy.yml
vendored
Normal file
59
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: IT Nexus Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy frontend/src to server
|
||||
run: |
|
||||
scp -r frontend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/frontend/src
|
||||
|
||||
- name: Deploy backend/src to server
|
||||
run: |
|
||||
scp -r backend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/backend/src
|
||||
|
||||
- name: Rebuild and restart Docker containers
|
||||
run: |
|
||||
ssh root@${{ secrets.SERVER_IP }} "cd /opt/it-nexus && docker compose build frontend backend && docker compose up -d frontend backend"
|
||||
1524
Assets-Demo.html
Normal file
1524
Assets-Demo.html
Normal file
File diff suppressed because it is too large
Load Diff
60
backend/src/routes/feedback.routes.js
Normal file
60
backend/src/routes/feedback.routes.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// GET /api/feedback — alle Issues von GitHub laden
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const { state = 'open' } = req.query;
|
||||
const response = await fetch(`https://api.github.com/repos/SimonGCereda/IT-Nexus/issues?state=${state}&labels=feedback&per_page=50`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
const issues = await response.json();
|
||||
res.json({ status: 'success', data: issues });
|
||||
}));
|
||||
|
||||
// POST /api/feedback — neues Issue erstellen
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
const { title, body, category } = req.body;
|
||||
const user = req.user;
|
||||
|
||||
const labelMap = { bug: 'bug', feature: 'enhancement', idea: 'question' };
|
||||
const labels = ['feedback', labelMap[category] || 'feedback'];
|
||||
|
||||
const issueBody = `**Gemeldet von:** ${user.first_name || ''} ${user.last_name || ''} (@${user.username})\n**Kategorie:** ${category}\n\n---\n\n${body}`;
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/SimonGCereda/IT-Nexus/issues`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, body: issueBody, labels })
|
||||
});
|
||||
const issue = await response.json();
|
||||
res.json({ status: 'success', data: issue });
|
||||
}));
|
||||
|
||||
// PATCH /api/feedback/:id — Issue schließen/öffnen (nur Admin)
|
||||
router.patch('/:id', asyncHandler(async (req, res) => {
|
||||
const { state } = req.body;
|
||||
const response = await fetch(`https://api.github.com/repos/SimonGCereda/IT-Nexus/issues/${req.params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ state })
|
||||
});
|
||||
const issue = await response.json();
|
||||
res.json({ status: 'success', data: issue });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -175,6 +175,8 @@ const securityReportRoutes = require('./routes/securityReport.routes');
|
||||
app.use('/api/security-reports', securityReportRoutes);
|
||||
const tvRoutes = require('./routes/tv.routes');
|
||||
app.use('/api/tv', tvRoutes);
|
||||
const feedbackRoutes = require('./routes/feedback.routes');
|
||||
app.use('/api/feedback', feedbackRoutes);
|
||||
|
||||
// Static file serving for uploads (PDFs)
|
||||
const path = require('path');
|
||||
|
||||
@@ -2,6 +2,126 @@ import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
/* ── FeedbackModal ───────────────────────────────────────────────── */
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'bug', label: 'Bug 🐛' },
|
||||
{ value: 'feature', label: 'Feature-Wunsch ✨' },
|
||||
{ value: 'idea', label: 'Idee 💡' },
|
||||
];
|
||||
|
||||
function FeedbackModal({ onClose }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [category, setCategory] = useState('bug');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !body.trim()) { setError('Bitte Titel und Beschreibung ausfüllen.'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const API = process.env.REACT_APP_API_URL || '';
|
||||
const res = await fetch(`${API}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ title, body, category })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.status === 'success') { setSuccess(true); }
|
||||
else { setError('Fehler beim Senden.'); }
|
||||
} catch {
|
||||
setError('Server nicht erreichbar.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--card-bg, #1e1e2e)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
|
||||
borderRadius: 12,
|
||||
padding: 28,
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: 'var(--text-primary, #f1f5f9)' }}>
|
||||
Feedback senden
|
||||
</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 20, color: 'var(--text-muted, #64748b)', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<div style={{ fontSize: 36, marginBottom: 12 }}>✅</div>
|
||||
<p style={{ color: 'var(--text-primary, #f1f5f9)', fontWeight: 600, marginBottom: 6 }}>Feedback gesendet!</p>
|
||||
<p style={{ color: 'var(--text-secondary, #94a3b8)', fontSize: 13 }}>Das Issue wurde auf GitHub erstellt.</p>
|
||||
<button onClick={onClose} style={{ marginTop: 16, padding: '8px 20px', borderRadius: 8, border: 'none', background: 'var(--accent, #6366f1)', color: '#fff', cursor: 'pointer', fontWeight: 600 }}>Schließen</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary, #94a3b8)', display: 'block', marginBottom: 6 }}>Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border-color, rgba(255,255,255,0.1))', background: 'var(--input-bg, #252535)', color: 'var(--text-primary, #f1f5f9)', fontSize: 13 }}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary, #94a3b8)', display: 'block', marginBottom: 6 }}>Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Kurze Zusammenfassung…"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border-color, rgba(255,255,255,0.1))', background: 'var(--input-bg, #252535)', color: 'var(--text-primary, #f1f5f9)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary, #94a3b8)', display: 'block', marginBottom: 6 }}>Beschreibung</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="Was ist passiert? Was hast du erwartet?"
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border-color, rgba(255,255,255,0.1))', background: 'var(--input-bg, #252535)', color: 'var(--text-primary, #f1f5f9)', fontSize: 13, resize: 'vertical', boxSizing: 'border-box', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: '#ef4444', fontSize: 12, margin: 0 }}>{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ padding: '9px 0', borderRadius: 8, border: 'none', background: 'var(--accent, #6366f1)', color: '#fff', fontWeight: 600, fontSize: 14, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? 'Senden…' : 'Feedback senden'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── SVG Icon Components ────────────────────────────────────────── */
|
||||
const I = ({ d, children, ...p }) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" {...p}>
|
||||
@@ -32,6 +152,7 @@ const icons = {
|
||||
ki: <I><path d="M12 2a3 3 0 0 0-3 3v1H7a3 3 0 0 0-3 3v2H3a2 2 0 0 0 0 4h1v2a3 3 0 0 0 3 3h2v1a3 3 0 0 0 6 0v-1h2a3 3 0 0 0 3-3v-2h1a2 2 0 0 0 0-4h-1V9a3 3 0 0 0-3-3h-2V5a3 3 0 0 0-3-3z"/></I>,
|
||||
onboarding: <I><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></I>,
|
||||
announcements: <I><path d="M11 5L6 9H2v6h4l5 4V5zM19 12c0-2.5-1.5-5-4-6"/></I>,
|
||||
feedback: <I><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></I>,
|
||||
system: <I><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></I>,
|
||||
};
|
||||
|
||||
@@ -75,6 +196,7 @@ const SubMenu = ({ label, icon, items, isActive }) => {
|
||||
const Sidebar = () => {
|
||||
const location = useLocation();
|
||||
const { user, isAdmin, isSupport, isTechniker, canViewLifecycle, canModifyFidoKeys } = useAuth();
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
|
||||
const isStaff = isSupport();
|
||||
const role = user?.role_name;
|
||||
@@ -162,9 +284,41 @@ const Sidebar = () => {
|
||||
<NavItem to="/proxmox" icon="monitoring" label="Proxmox" active={isActive('/proxmox')} />
|
||||
<NavItem to="/docker" icon="monitoring" label="Docker" active={isActive('/docker')} />
|
||||
<NavItem to="/system" icon="system" label="Systemeinstellungen" active={isActive('/system')} />
|
||||
<NavItem to="/feedback" icon="feedback" label="Feedback Issues" active={isActive('/feedback')} />
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Feedback Button */}
|
||||
<div style={{ padding: '12px 12px 16px' }}>
|
||||
<button
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary, #94a3b8)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'background .15s'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.04)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" width="16" height="16">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Feedback geben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedbackOpen && <FeedbackModal onClose={() => setFeedbackOpen(false)} />}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
293
frontend/src/pages/FeedbackPage.jsx
Normal file
293
frontend/src/pages/FeedbackPage.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const API = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function authFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(str) {
|
||||
if (!str) return '';
|
||||
return new Date(str).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
const LABEL_META = {
|
||||
bug: { label: 'Bug', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
enhancement: { label: 'Feature', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
|
||||
question: { label: 'Idee', color: '#a855f7', bg: 'rgba(168,85,247,0.12)' },
|
||||
feedback: { label: 'Feedback', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' },
|
||||
};
|
||||
|
||||
function getLabelMeta(labels = []) {
|
||||
for (const l of labels) {
|
||||
const m = LABEL_META[l.name];
|
||||
if (m && l.name !== 'feedback') return m;
|
||||
}
|
||||
return LABEL_META.feedback;
|
||||
}
|
||||
|
||||
/* ── IssueCard ───────────────────────────────────────────────────── */
|
||||
function IssueCard({ issue, isAdmin, onToggleState }) {
|
||||
const meta = getLabelMeta(issue.labels || []);
|
||||
const body = issue.body || '';
|
||||
// Zeige nur den Inhalt nach dem "---" Trennstrich
|
||||
const bodyDisplay = body.includes('---\n\n')
|
||||
? body.split('---\n\n')[1]?.slice(0, 200)
|
||||
: body.slice(0, 200);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--card-bg, #1e1e2e)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))',
|
||||
borderRadius: 10,
|
||||
padding: '16px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<span style={{
|
||||
padding: '2px 9px',
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: meta.color,
|
||||
background: meta.bg,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
marginTop: 2
|
||||
}}>{meta.label}</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary, #f1f5f9)', lineHeight: 1.4 }}>
|
||||
{issue.title}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted, #64748b)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
#{issue.number}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bodyDisplay && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary, #94a3b8)', margin: 0, lineHeight: 1.6 }}>
|
||||
{bodyDisplay}{body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted, #64748b)' }}>
|
||||
{formatDate(issue.created_at)}
|
||||
</span>
|
||||
{issue.user && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted, #64748b)' }}>
|
||||
· {issue.user.login}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
<a
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--text-secondary, #94a3b8)',
|
||||
textDecoration: 'none',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))'
|
||||
}}
|
||||
>
|
||||
GitHub ↗
|
||||
</a>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => onToggleState(issue.number, issue.state)}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: issue.state === 'open' ? '#ef4444' : '#22c55e',
|
||||
background: issue.state === 'open' ? 'rgba(239,68,68,0.1)' : 'rgba(34,197,94,0.1)',
|
||||
color: issue.state === 'open' ? '#ef4444' : '#22c55e'
|
||||
}}
|
||||
>
|
||||
{issue.state === 'open' ? 'Schließen' : 'Wiedereröffnen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
FeedbackPage
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
const TABS = [
|
||||
{ key: 'open', label: 'Offen' },
|
||||
{ key: 'closed', label: 'Geschlossen' },
|
||||
{ key: 'all', label: 'Alle' },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: '', label: 'Alle Kategorien' },
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'enhancement', label: 'Feature' },
|
||||
{ value: 'question', label: 'Idee' },
|
||||
];
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const admin = isAdmin();
|
||||
|
||||
const [tab, setTab] = useState('open');
|
||||
const [issues, setIssues] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filterLabel, setFilterLabel] = useState('');
|
||||
|
||||
const load = useCallback(async (state) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const stateParam = state === 'all' ? 'all' : state;
|
||||
const res = await authFetch(`${API}/api/feedback?state=${stateParam}`);
|
||||
const json = await res.json();
|
||||
if (json.status === 'success') setIssues(json.data || []);
|
||||
else setError('Fehler beim Laden der Issues.');
|
||||
} catch {
|
||||
setError('GitHub API nicht erreichbar.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(tab); }, [tab, load]);
|
||||
|
||||
const handleToggleState = async (issueNumber, currentState) => {
|
||||
const newState = currentState === 'open' ? 'closed' : 'open';
|
||||
try {
|
||||
await authFetch(`${API}/api/feedback/${issueNumber}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state: newState })
|
||||
});
|
||||
load(tab);
|
||||
} catch {
|
||||
alert('Fehler beim Aktualisieren des Issues.');
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = filterLabel
|
||||
? issues.filter(i => (i.labels || []).some(l => l.name === filterLabel))
|
||||
: issues;
|
||||
|
||||
// Für Tab "open" nur tatsächlich offene anzeigen (API liefert bei "all" auch geschlossene)
|
||||
const displayed = tab === 'open'
|
||||
? filtered.filter(i => i.state === 'open')
|
||||
: tab === 'closed'
|
||||
? filtered.filter(i => i.state === 'closed')
|
||||
: filtered;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '28px 32px', maxWidth: 900 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary, #f1f5f9)', margin: 0 }}>
|
||||
Feedback & Issues
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary, #94a3b8)', marginTop: 6 }}>
|
||||
GitHub Issues aus dem IT-Nexus Repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--card-bg, #1e1e2e)', border: '1px solid var(--border-color, rgba(255,255,255,0.08))', borderRadius: 8, padding: 4 }}>
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: tab === t.key ? 600 : 400,
|
||||
background: tab === t.key ? 'var(--accent, #6366f1)' : 'transparent',
|
||||
color: tab === t.key ? '#fff' : 'var(--text-secondary, #94a3b8)',
|
||||
transition: 'all .15s'
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterLabel}
|
||||
onChange={e => setFilterLabel(e.target.value)}
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))',
|
||||
background: 'var(--card-bg, #1e1e2e)',
|
||||
color: 'var(--text-primary, #f1f5f9)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{FILTER_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span style={{ marginLeft: 'auto', fontSize: 13, color: 'var(--text-muted, #64748b)' }}>
|
||||
{displayed.length} Issue{displayed.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted, #64748b)' }}>
|
||||
Laden…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: 16, borderRadius: 8, background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted, #64748b)', fontSize: 14 }}>
|
||||
Keine Issues gefunden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{displayed.map(issue => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
isAdmin={admin}
|
||||
onToggleState={handleToggleState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import SecurityReportsPage from './pages/SecurityReportsPage';
|
||||
import ScannerPage from './pages/ScannerPage';
|
||||
import TVDashboardPage from './pages/TVDashboardPage';
|
||||
import UserManagementPage from './pages/UserManagementPage';
|
||||
import FeedbackPage from './pages/FeedbackPage';
|
||||
|
||||
const L = ({ children, roles }) => (
|
||||
<ProtectedRoute allowedRoles={roles}>
|
||||
@@ -138,6 +139,7 @@ const AppRoutes = () => {
|
||||
<Route path="/security-reports" element={<L roles={['super_admin', 'admin']}><SecurityReportsPage /></L>} />
|
||||
<Route path="/network-monitor" element={<Navigate to="/monitoring" replace />} />
|
||||
<Route path="/nexus-scanner" element={<L roles={['super_admin', 'admin']}><ScannerPage /></L>} />
|
||||
<Route path="/feedback" element={<L roles={['super_admin', 'admin']}><FeedbackPage /></L>} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
Reference in New Issue
Block a user