fix permissions
This commit is contained in:
@@ -10,7 +10,7 @@ class IssueController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
const groups: string[] = (req.user as any).groups || [];
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
const canViewAll = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
|
|
||||||
// Parse filter query params
|
// Parse filter query params
|
||||||
const filters: {
|
const filters: {
|
||||||
@@ -60,7 +60,7 @@ class IssueController {
|
|||||||
}
|
}
|
||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
const groups: string[] = (req.user as any).groups || [];
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
const canViewAll = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
||||||
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||||
return;
|
return;
|
||||||
@@ -227,7 +227,7 @@ class IssueController {
|
|||||||
}
|
}
|
||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
const groups: string[] = (req.user as any).groups || [];
|
const groups: string[] = (req.user as any).groups || [];
|
||||||
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
|
const canViewAll = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'issues:view_all');
|
||||||
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
|
||||||
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
res.status(403).json({ success: false, message: 'Kein Zugriff' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration 056: Add issues:widget permission
|
||||||
|
-- Gated permission for the Issue Quick Add dashboard widget.
|
||||||
|
-- Granted to all groups that currently have issues:create.
|
||||||
|
|
||||||
|
-- 1. Insert the new permission
|
||||||
|
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
|
||||||
|
VALUES ('issues:widget', 'issues', 'Widget', 'Issue-Schnelleingabe auf dem Dashboard', 8)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Grant to every group that already has issues:create
|
||||||
|
INSERT INTO group_permissions (authentik_group, permission_id)
|
||||||
|
SELECT authentik_group, 'issues:widget'
|
||||||
|
FROM group_permissions
|
||||||
|
WHERE permission_id = 'issues:create'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 3. Add dependency: issues:widget requires issues:create
|
||||||
|
UPDATE app_settings
|
||||||
|
SET value = value || '{"issues:widget": ["issues:create"]}'::jsonb
|
||||||
|
WHERE key = 'permission_deps';
|
||||||
148
frontend/src/components/dashboard/IssueQuickAddWidget.tsx
Normal file
148
frontend/src/components/dashboard/IssueQuickAddWidget.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Skeleton,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { BugReport } from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { issuesApi } from '../../services/issues';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
|
||||||
|
const PRIO_OPTIONS = [
|
||||||
|
{ value: 'niedrig', label: 'Niedrig' },
|
||||||
|
{ value: 'mittel', label: 'Mittel' },
|
||||||
|
{ value: 'hoch', label: 'Hoch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const IssueQuickAddWidget: React.FC = () => {
|
||||||
|
const [titel, setTitel] = useState('');
|
||||||
|
const [typId, setTypId] = useState<number | ''>('');
|
||||||
|
const [prioritaet, setPrioritaet] = useState<string>('mittel');
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: types = [], isLoading: typesLoading } = useQuery({
|
||||||
|
queryKey: ['issue-types'],
|
||||||
|
queryFn: issuesApi.getTypes,
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTypes = types.filter((t) => t.aktiv);
|
||||||
|
const defaultTypId = activeTypes[0]?.id;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
issuesApi.createIssue({
|
||||||
|
titel: titel.trim(),
|
||||||
|
typ_id: typId !== '' ? (typId as number) : defaultTypId,
|
||||||
|
prioritaet,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Issue erstellt');
|
||||||
|
setTitel('');
|
||||||
|
setTypId('');
|
||||||
|
setPrioritaet('mittel');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issues'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Issue konnte nicht erstellt werden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!titel.trim()) return;
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<BugReport color="primary" />
|
||||||
|
<Typography variant="h6">Issue melden</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{typesLoading ? (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Titel"
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
inputProps={{ maxLength: 255 }}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={typId}
|
||||||
|
label="Typ"
|
||||||
|
onChange={(e: SelectChangeEvent<number | ''>) =>
|
||||||
|
setTypId(e.target.value as number | '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeTypes.map((t) => (
|
||||||
|
<MenuItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Priorität</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={prioritaet}
|
||||||
|
label="Priorität"
|
||||||
|
onChange={(e: SelectChangeEvent<string>) => setPrioritaet(e.target.value)}
|
||||||
|
>
|
||||||
|
{PRIO_OPTIONS.map((p) => (
|
||||||
|
<MenuItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!titel.trim() || mutation.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Wird erstellt…' : 'Melden'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IssueQuickAddWidget;
|
||||||
@@ -20,3 +20,4 @@ export { default as LinksWidget } from './LinksWidget';
|
|||||||
export { default as WidgetGroup } from './WidgetGroup';
|
export { default as WidgetGroup } from './WidgetGroup';
|
||||||
export { default as BestellungenWidget } from './BestellungenWidget';
|
export { default as BestellungenWidget } from './BestellungenWidget';
|
||||||
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
export { default as AusruestungsanfrageWidget } from './AusruestungsanfrageWidget';
|
||||||
|
export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const WIDGETS = [
|
|||||||
{ key: 'links', label: 'Links', defaultVisible: true },
|
{ key: 'links', label: 'Links', defaultVisible: true },
|
||||||
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
{ key: 'bestellungen', label: 'Bestellungen', defaultVisible: true },
|
||||||
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
|
{ key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true },
|
||||||
|
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import BannerWidget from '../components/dashboard/BannerWidget';
|
|||||||
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||||
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
import BestellungenWidget from '../components/dashboard/BestellungenWidget';
|
||||||
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
import AusruestungsanfrageWidget from '../components/dashboard/AusruestungsanfrageWidget';
|
||||||
|
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { configApi } from '../services/config';
|
import { configApi } from '../services/config';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
import { WidgetKey } from '../constants/widgets';
|
||||||
@@ -218,6 +219,14 @@ function Dashboard() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPermission('issues:widget') && widgetVisible('issueQuickAdd') && (
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
|
||||||
|
<Box>
|
||||||
|
<IssueQuickAddWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
)}
|
||||||
</WidgetGroup>
|
</WidgetGroup>
|
||||||
|
|
||||||
{/* Information Group */}
|
{/* Information Group */}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import type { Issue, IssueComment, IssueTyp, CreateIssuePayload, UpdateIssuePayl
|
|||||||
const formatDate = (iso?: string) =>
|
const formatDate = (iso?: string) =>
|
||||||
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||||
|
|
||||||
|
const formatIssueId = (issue: Issue) =>
|
||||||
|
`${new Date(issue.created_at).getFullYear()}/${issue.id}`;
|
||||||
|
|
||||||
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
|
const STATUS_COLORS: Record<Issue['status'], 'info' | 'warning' | 'success' | 'error'> = {
|
||||||
offen: 'info',
|
offen: 'info',
|
||||||
in_bearbeitung: 'warning',
|
in_bearbeitung: 'warning',
|
||||||
@@ -217,7 +220,7 @@ function IssueRow({
|
|||||||
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
|
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'unset' : undefined } }}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
<TableCell sx={{ width: 50 }}>#{issue.id}</TableCell>
|
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
|
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
|
||||||
@@ -341,7 +344,7 @@ function IssueRow({
|
|||||||
{/* Reopen Dialog */}
|
{/* Reopen Dialog */}
|
||||||
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Issue wiedereröffnen</DialogTitle>
|
<DialogTitle>Issue wiedereröffnen</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Kommentar (Pflicht)"
|
label="Kommentar (Pflicht)"
|
||||||
required
|
required
|
||||||
@@ -685,7 +688,7 @@ function IssueTypeAdmin() {
|
|||||||
{/* Create Type Dialog */}
|
{/* Create Type Dialog */}
|
||||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Neue Kategorie erstellen</DialogTitle>
|
<DialogTitle>Neue Kategorie erstellen</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
<TextField label="Name" required fullWidth value={createData.name || ''} onChange={(e) => setCreateData({ ...createData, name: e.target.value })} autoFocus />
|
<TextField label="Name" required fullWidth value={createData.name || ''} onChange={(e) => setCreateData({ ...createData, name: e.target.value })} autoFocus />
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Übergeordnete Kategorie</InputLabel>
|
<InputLabel>Übergeordnete Kategorie</InputLabel>
|
||||||
@@ -876,7 +879,7 @@ export default function Issues() {
|
|||||||
{/* Create Issue Dialog */}
|
{/* Create Issue Dialog */}
|
||||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Neues Issue erstellen</DialogTitle>
|
<DialogTitle>Neues Issue erstellen</DialogTitle>
|
||||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Titel"
|
label="Titel"
|
||||||
required
|
required
|
||||||
@@ -931,7 +934,7 @@ export default function Issues() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
{canCreate && (
|
{canCreate && activeTab === 'mine' && (
|
||||||
<ChatAwareFab
|
<ChatAwareFab
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label="Neues Issue"
|
aria-label="Neues Issue"
|
||||||
|
|||||||
Reference in New Issue
Block a user