feat(codev): retire Surprise + QR public + mode admin suppr fiches

- carto.vue : retire bouton Surprise (Alliance seul reste), ajoute isAdmin + deleteFiche + colonne supprimer annuaire
- middleware : /codev/qr exempté d'authentification
- auth.post.ts : détecte mdp admin → pose cookie codev_admin
- DELETE /api/codev/fiches/[id] : vérifie cookie admin avant suppression NocoDB
- GET /api/codev/me : retourne { admin, session }
- nuxt.config.ts : codevAdminPassword ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-07 00:22:44 +02:00
parent 142e5cf787
commit c8311ce1fb
6 changed files with 86 additions and 16 deletions

View File

@@ -14,9 +14,10 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
codevTableId: '', // NUXT_CODEV_TABLE_ID
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevTableId: '', // NUXT_CODEV_TABLE_ID
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

View File

@@ -61,15 +61,6 @@
Alliance
<span class="hint">besoins partages</span>
</button>
<button
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@@ -94,6 +85,7 @@
<th class="col-nom">Prénom</th>
<th class="col-besoin">Besoin</th>
<th class="col-offre">Ce que j'offre</th>
<th v-if="isAdmin" class="col-actions"></th>
</tr>
</thead>
<tbody>
@@ -101,6 +93,9 @@
<td class="col-nom">{{ f.nom }}</td>
<td class="col-besoin">{{ f.besoin }}</td>
<td class="col-offre">{{ f.offre }}</td>
<td v-if="isAdmin" class="col-actions">
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
</td>
</tr>
</tbody>
</table>
@@ -145,7 +140,7 @@ import { computeMatches } from '~/utils/codev/matching'
useHead({ title: 'Carto - Co-developpement' })
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([])
@@ -155,6 +150,15 @@ const tab = ref<'carto' | 'annuaire'>('carto')
const selectedFiche = ref<CodevFiche | null>(null)
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
const isAdmin = ref(false)
onMounted(async () => {
try {
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
isAdmin.value = r.admin
} catch { isAdmin.value = false }
})
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
@@ -177,6 +181,12 @@ function onSelectFiche(id: number) {
navigateTo(`/codev/fiche?id=${id}`)
}
}
async function deleteFiche(id: number) {
if (!confirm('Supprimer la fiche ?')) return
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
await refresh()
}
</script>
<style scoped>
@@ -503,6 +513,19 @@ thead tr .col-nom { background: #f9fafb; }
margin: 0;
}
.col-actions { width: 40px; text-align: center; }
.delete-btn {
background: transparent;
border: none;
cursor: pointer;
color: #ef4444;
font-size: 1rem;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.1s;
}
.delete-btn:hover { background: #fef2f2; }
/* ── Mobile ── */
@media (max-width: 600px) {

View File

@@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const expected = config.codevPassword || 'merci'
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
if (!isAdmin && !isUser) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
}
// Cookie session (user + admin)
setCookie(event, 'codev_session', 'ok', {
httpOnly: true,
sameSite: 'lax',
@@ -27,5 +31,16 @@ export default defineEventHandler(async (event) => {
path: '/',
})
return { status: 200, ok: true }
// Cookie admin si mot de passe admin
if (isAdmin) {
setCookie(event, 'codev_admin', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
}
return { status: 200, ok: true, admin: isAdmin }
})

View File

@@ -0,0 +1,25 @@
export default defineEventHandler(async (event) => {
// Vérif cookie admin
const adminCookie = getCookie(event, 'codev_admin')
if (adminCookie !== 'ok') {
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: Number(id) }),
}).catch(() => {
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
})
return { status: 200, ok: true }
})

View File

@@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
const admin = getCookie(event, 'codev_admin') === 'ok'
const session = getCookie(event, 'codev_session') === 'ok'
return { admin, session }
})

View File

@@ -8,8 +8,9 @@ export default defineEventHandler((event) => {
// Seulement les routes sous /codev/
if (!path.startsWith('/codev/')) return
// Routes publiques : /codev/demo (et sous-routes éventuelles)
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
// Vérification cookie
const session = getCookie(event, 'codev_session')