Compare commits
8
Commits
9308bbcd63
...
2c63942740
@@ -4,7 +4,7 @@ import type {
|
|||||||
OkResponse,
|
OkResponse,
|
||||||
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
|
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
|
||||||
RegisterCitizenResponse, RewardsResponse,
|
RegisterCitizenResponse, RewardsResponse,
|
||||||
TokenResponse, VerifyOtpRequest
|
TokenResponse, VerifyOtpRequest, UpdateProfileRequest
|
||||||
} from "~/types/api";
|
} from "~/types/api";
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
@@ -81,6 +81,9 @@ export const useAuth = () => {
|
|||||||
const changePassword = (body: ChangePasswordRequest) =>
|
const changePassword = (body: ChangePasswordRequest) =>
|
||||||
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
|
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
|
||||||
|
|
||||||
|
const updateProfile = (body: UpdateProfileRequest) =>
|
||||||
|
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body })
|
||||||
|
|
||||||
const fetchCurrentUser = () =>
|
const fetchCurrentUser = () =>
|
||||||
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
|
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ export const useAuth = () => {
|
|||||||
verifyOtp,
|
verifyOtp,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
updateProfile,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
fetchRewards,
|
fetchRewards,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
const requiredRole = to.meta.auth?.role
|
const requiredRole = to.meta.auth?.role
|
||||||
|
|
||||||
if (requiredRole && auth.user.value) {
|
if (requiredRole && auth.user.value) {
|
||||||
const userRole = auth.user.value.role
|
const userRole = auth.user.value.role.toUpperCase()
|
||||||
|
|
||||||
const roleLevels: Record<string, number> = {
|
const roleLevels: Record<string, number> = {
|
||||||
'CITIZEN': 1,
|
'CITIZEN': 1,
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, nextTick, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Gestione Aree - Admin' })
|
||||||
|
|
||||||
|
const { getAreas, createArea } = useAdmin()
|
||||||
|
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
|
||||||
|
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const formData = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
geojson: ''
|
||||||
|
})
|
||||||
|
const jsonError = ref('')
|
||||||
|
|
||||||
|
// Map variables
|
||||||
|
let map: any = null
|
||||||
|
let polygon: any = null
|
||||||
|
const mapPoints = ref<[number, number][]>([])
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
formData.name = ''
|
||||||
|
formData.description = ''
|
||||||
|
formData.geojson = ''
|
||||||
|
jsonError.value = ''
|
||||||
|
mapPoints.value = []
|
||||||
|
isCreating.value = true
|
||||||
|
|
||||||
|
nextTick(async () => {
|
||||||
|
// Import leaflet solo client-side
|
||||||
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
|
||||||
|
map = L.map('areaMap').setView([41.9028, 12.4964], 6)
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||||
|
|
||||||
|
map.on('click', (e: any) => {
|
||||||
|
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
||||||
|
polygon.setLatLngs(mapPoints.value)
|
||||||
|
updateGeojson()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMap = () => {
|
||||||
|
mapPoints.value = []
|
||||||
|
if (polygon) {
|
||||||
|
polygon.setLatLngs([])
|
||||||
|
}
|
||||||
|
formData.geojson = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateGeojson = () => {
|
||||||
|
if (mapPoints.value.length < 3) {
|
||||||
|
formData.geojson = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
|
||||||
|
if (coords[0]) {
|
||||||
|
coords.push([...coords[0]]) // Chiudi poligono
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.geojson = JSON.stringify({
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [coords]
|
||||||
|
}, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
isCreating.value = false
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (map) map.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
jsonError.value = ''
|
||||||
|
let parsedGeojson = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedGeojson = JSON.parse(formData.geojson)
|
||||||
|
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
||||||
|
throw new Error('GeoJSON non valido. Richiesto "type" e "coordinates".')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
jsonError.value = e.message || 'Formato JSON non valido.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await createArea({
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
boundary_geojson: parsedGeojson
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeCreateModal()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create area', err)
|
||||||
|
alert('Errore durante la creazione dell\'area.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Gestione Aree GeoJSON</h1>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openCreateModal">
|
||||||
|
<BIIcon name="it-plus" size="xs" class="me-1" />
|
||||||
|
Nuova Area
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!areasRes?.areas || areasRes.areas.length === 0" class="alert alert-info">
|
||||||
|
Nessuna area configurata.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Descrizione</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>GeoJSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="area in areasRes.areas" :key="area.id">
|
||||||
|
<td>{{ area.id }}</td>
|
||||||
|
<td><strong>{{ area.name }}</strong></td>
|
||||||
|
<td>{{ area.description || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="area.active" class="badge bg-success">Attiva</span>
|
||||||
|
<span v-else class="badge bg-secondary">Inattiva</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
|
||||||
|
<span v-else class="badge border border-danger text-danger">Mancante</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale Creazione -->
|
||||||
|
<div v-if="isCreating" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||||
|
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Nuova Area</h5>
|
||||||
|
<button type="button" class="btn-close" @click="closeCreateModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nome Area</label>
|
||||||
|
<input type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Descrizione (opzionale)</label>
|
||||||
|
<textarea class="form-control" v-model="formData.description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">GeoJSON Generato</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control font-monospace text-sm"
|
||||||
|
rows="6"
|
||||||
|
v-model="formData.geojson"
|
||||||
|
required
|
||||||
|
readonly
|
||||||
|
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
||||||
|
></textarea>
|
||||||
|
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" @click="clearMap">
|
||||||
|
Pulisci Mappa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-light border small mb-2 p-2">
|
||||||
|
Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti.
|
||||||
|
</div>
|
||||||
|
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer d-flex justify-content-end gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @click="handleCreate">
|
||||||
|
Crea Area
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#areaMap {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
margin-block: 0!important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Moderazione Abusi - Admin' })
|
||||||
|
|
||||||
|
const { getModerationFlags, updateModerationFlag } = useAdmin()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
|
||||||
|
const { data: flagsRes, pending, refresh } = useAsyncData(
|
||||||
|
'admin-moderation-flags',
|
||||||
|
() => getModerationFlags({ page: page.value, per_page: perPage.value, status: statusFilter.value || undefined }),
|
||||||
|
{ watch: [page, statusFilter] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleResolve = async (id: number, rejectReport: boolean) => {
|
||||||
|
const note = prompt('Inserisci una nota di risoluzione (opzionale):')
|
||||||
|
if (note === null) return // cancelled
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateModerationFlag(id, {
|
||||||
|
status: 'RESOLVED',
|
||||||
|
reject_report: rejectReport,
|
||||||
|
resolution_note: note
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to resolve flag', err)
|
||||||
|
alert('Errore durante la risoluzione della segnalazione.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = async (id: number) => {
|
||||||
|
if (!confirm('Sei sicuro di voler ignorare (dismiss) questo flag?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateModerationFlag(id, { status: 'DISMISSED' })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to dismiss flag', err)
|
||||||
|
alert('Errore durante l\'aggiornamento.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('it-IT', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Moderazione Abusi</h1>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna alla Dashboard</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Filtra per Stato</label>
|
||||||
|
<select class="form-select" v-model="statusFilter">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="OPEN">Aperti</option>
|
||||||
|
<option value="RESOLVED">Risolti</option>
|
||||||
|
<option value="DISMISSED">Ignorati</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-danger" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!flagsRes?.flags || flagsRes.flags.length === 0" class="alert alert-success">
|
||||||
|
Nessun elemento da moderare al momento.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="card mb-3 shadow-sm border-0" v-for="flag in flagsRes.flags" :key="flag.id">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Flag #{{ flag.id }}</strong> -
|
||||||
|
<span class="badge" :class="{
|
||||||
|
'bg-warning': flag.status === 'OPEN',
|
||||||
|
'bg-success': flag.status === 'RESOLVED',
|
||||||
|
'bg-secondary': flag.status === 'DISMISSED'
|
||||||
|
}">{{ flag.status }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ formatDate(flag.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h5 class="card-title">Motivo: {{ flag.reason }}</h5>
|
||||||
|
<p class="card-text text-muted mb-2">
|
||||||
|
{{ flag.details || 'Nessun dettaglio aggiuntivo fornito.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="badge bg-info me-2">{{ flag.content_type }}</span>
|
||||||
|
<span v-if="flag.report_id">
|
||||||
|
Rif:
|
||||||
|
<NuxtLink :to="`/operator/reports/${flag.report_id}`" class="text-decoration-none">
|
||||||
|
Segnalazione #{{ flag.report_id }}
|
||||||
|
</NuxtLink>
|
||||||
|
</span>
|
||||||
|
<span v-if="flag.message_id" class="ms-2">
|
||||||
|
| Messaggio #{{ flag.message_id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 small">
|
||||||
|
Segnalato da: {{ flag.created_by_email }} (ID: {{ flag.created_by_user_id }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="flag.status !== 'OPEN'" class="mt-2 small text-muted border-top pt-2">
|
||||||
|
<strong>Risolto/Ignorato da:</strong> {{ flag.resolved_by_email }} in data {{ flag.resolved_at ? formatDate(flag.resolved_at) : 'N/D' }}
|
||||||
|
<div v-if="flag.resolution_note"><strong>Nota:</strong> {{ flag.resolution_note }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 text-end" v-if="flag.status === 'OPEN'">
|
||||||
|
<div class="d-flex flex-column gap-2 mt-3 mt-md-0">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" @click="handleResolve(flag.id, true)">
|
||||||
|
<BIIcon name="it-ban" size="xs" /> Risolvi & Rigetta Segn.
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" @click="handleResolve(flag.id, false)">
|
||||||
|
<BIIcon name="it-check" size="xs" /> Risolvi (Solo Avviso)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @click="handleDismiss(flag.id)">
|
||||||
|
<BIIcon name="it-close" size="xs" /> Ignora Flag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="(flagsRes.total || flagsRes.flags.length) > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="flagsRes.total || (page * perPage + 1)"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Gestione Utenti - Admin' })
|
||||||
|
|
||||||
|
const { getUsers, updateUserBan, updateUserRoles } = useAdmin()
|
||||||
|
|
||||||
|
const { data: usersRes, pending, refresh } = useAsyncData('admin-users', () => getUsers())
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
if (!usersRes.value?.users) return []
|
||||||
|
if (!search.value) return usersRes.value.users
|
||||||
|
const s = search.value.toLowerCase()
|
||||||
|
return usersRes.value.users.filter(u =>
|
||||||
|
u.first_name.toLowerCase().includes(s) ||
|
||||||
|
u.last_name.toLowerCase().includes(s) ||
|
||||||
|
u.email.toLowerCase().includes(s) ||
|
||||||
|
u.fiscal_code?.toLowerCase().includes(s)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggleBan = async (id: number, currentBanStatus: boolean) => {
|
||||||
|
if (!confirm(`Sei sicuro di voler ${currentBanStatus ? 'sbloccare' : 'bannare'} questo utente?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateUserBan(id, { banned: !currentBanStatus })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update user ban status', err)
|
||||||
|
alert('Errore durante l\'aggiornamento dello stato di ban.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal per i ruoli
|
||||||
|
const editingUserId = ref<number | null>(null)
|
||||||
|
const selectedRoles = ref<string[]>([])
|
||||||
|
const availableRoles = ['CITIZEN', 'OPERATOR', 'ADMIN']
|
||||||
|
|
||||||
|
const openRoleModal = (user: any) => {
|
||||||
|
editingUserId.value = user.id
|
||||||
|
selectedRoles.value = user.roles ? user.roles.split(',').map((r: string) => r.trim()) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRoleModal = () => {
|
||||||
|
editingUserId.value = null
|
||||||
|
selectedRoles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRoles = async () => {
|
||||||
|
if (!editingUserId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateUserRoles(editingUserId.value, { roles: selectedRoles.value })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeRoleModal()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update roles', err)
|
||||||
|
alert('Errore durante l\'aggiornamento dei ruoli.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Gestione Utenti</h1>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna alla Dashboard</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Cerca per nome, email o CF..." v-model="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!usersRes?.users || usersRes.users.length === 0" class="alert alert-info">
|
||||||
|
Nessun utente trovato.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Email / CF</th>
|
||||||
|
<th>Ruoli</th>
|
||||||
|
<th>Punti</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in filteredUsers" :key="user.id">
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ user.first_name }} {{ user.last_name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ user.email }}</div>
|
||||||
|
<small class="text-muted">{{ user.fiscal_code }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-for="r in (user.roles ? user.roles.split(',') : [])" :key="r" class="badge bg-primary me-1">
|
||||||
|
{{ r.trim() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.points || 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="user.banned" class="badge bg-danger">Bannato</span>
|
||||||
|
<span v-else class="badge bg-success">Attivo</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary me-2"
|
||||||
|
@click="openRoleModal(user)"
|
||||||
|
>
|
||||||
|
Ruoli
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="user.banned ? 'btn-outline-success' : 'btn-outline-danger'"
|
||||||
|
@click="handleToggleBan(user.id, !!user.banned)"
|
||||||
|
>
|
||||||
|
{{ user.banned ? 'Sblocca' : 'Banna' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editingUserId !== null" class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Modifica Ruoli Utente</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeRoleModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-check mb-2" v-for="role in availableRoles" :key="role">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
:value="role"
|
||||||
|
:id="'role-'+role"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
>
|
||||||
|
<label class="form-check-label" :for="'role-'+role">
|
||||||
|
{{ role }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="closeRoleModal">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveRoles">Salva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -132,7 +132,7 @@ const formatDate = (dateString: string) =>
|
|||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<NuxtLink to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink>
|
<NuxtLink to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink>
|
||||||
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
|
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
|
||||||
<NuxtLink to="/settings/password" class="btn btn-outline-secondary">Cambia password</NuxtLink>
|
<NuxtLink to="/auth/change-password" class="btn btn-outline-secondary">Cambia password</NuxtLink>
|
||||||
<button type="button" class="btn btn-outline-danger" @click="logout">
|
<button type="button" class="btn btn-outline-danger" @click="logout">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Modifica Profilo - Visione' })
|
||||||
|
|
||||||
|
const { user, updateProfile, syncUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
first_name: user.value?.first_name || '',
|
||||||
|
last_name: user.value?.last_name || '',
|
||||||
|
phone: user.value?.phone || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return formData.first_name.trim().length > 0 && formData.last_name.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateProfile({
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
phone: formData.phone || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok || res.user) {
|
||||||
|
await syncUser()
|
||||||
|
successMessage.value = 'Profilo aggiornato con successo.'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/profile')
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore durante l\'aggiornamento del profilo.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8 col-xl-6">
|
||||||
|
<div class="card card-bg border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4 p-lg-5">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<NuxtLink to="/profile" class="btn btn-sm btn-link text-decoration-none ps-0 me-3">
|
||||||
|
<BIIcon name="it-arrow-left" size="sm" />
|
||||||
|
</NuxtLink>
|
||||||
|
<h1 class="h3 mb-0">Modifica Profilo</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Aggiorna i tuoi dati personali. Email e Codice Fiscale non sono modificabili.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="first_name" class="form-label">Nome</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="first_name"
|
||||||
|
v-model="formData.first_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="last_name" class="form-label">Cognome</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="last_name"
|
||||||
|
v-model="formData.last_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="phone" class="form-label">Telefono (opzionale)</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-telephone" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
v-model="formData.phone"
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field opacity-50">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-mail" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control disabled"
|
||||||
|
:value="user?.email"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field opacity-50">
|
||||||
|
<label class="form-label">Codice Fiscale</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-card" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control disabled"
|
||||||
|
:value="user?.fiscal_code"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="successMessage" class="alert alert-success mb-0" role="alert">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100 py-2 mt-2"
|
||||||
|
:disabled="!canSubmit || isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ isLoading ? 'Aggiornamento in corso...' : 'Salva modifiche' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #c5d0db;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control:focus-within {
|
||||||
|
border-color: #06c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-icon {
|
||||||
|
color: #5c6f82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control .form-control {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+8
@@ -208,6 +208,12 @@ export interface ChangePasswordRequest {
|
|||||||
new_password: string;
|
new_password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmailRequest {
|
export interface EmailRequest {
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
@@ -315,6 +321,7 @@ export interface PagedReportListResponse {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
|
total?: number;
|
||||||
reports: Report[];
|
reports: Report[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +400,7 @@ export interface ModerationListResponse {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
|
total?: number;
|
||||||
flags: ModerationFlag[];
|
flags: ModerationFlag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ export default defineNuxtConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/api/v1': {
|
||||||
|
target: 'https://bk.visione.studentindipendenti.it/api/v1',
|
||||||
|
changeOrigin: true,
|
||||||
|
headers: {
|
||||||
|
host: 'bk.visione.studentindipendenti.it',
|
||||||
|
origin: 'https://visione.studentindipendenti.it',
|
||||||
|
referer: 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'https://bk.visione.studentindipendenti.it/uploads',
|
||||||
|
changeOrigin: true,
|
||||||
|
headers: {
|
||||||
|
host: 'bk.visione.studentindipendenti.it',
|
||||||
|
origin: 'https://visione.studentindipendenti.it',
|
||||||
|
referer: 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
link: [
|
link: [
|
||||||
|
|||||||
@@ -2588,6 +2588,11 @@ [email protected]:
|
|||||||
resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz"
|
||||||
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
|
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
|
||||||
|
|
||||||
|
codice-fiscale-js@^2.3.23:
|
||||||
|
version "2.3.23"
|
||||||
|
resolved "https://registry.npmjs.org/codice-fiscale-js/-/codice-fiscale-js-2.3.23.tgz"
|
||||||
|
integrity sha512-pkbvPqRF4c+wMiHj+YnoMiktN7tYYBfBhRKRQhE74TLeBPQn9uD8y2Nl6b2tld5MenAbxjSyfuwR4TFr3fYeRg==
|
||||||
|
|
||||||
color-convert@^2.0.1:
|
color-convert@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
|
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user