cargagep-v2/frontend/src/views/AdminsView.vue
Antoine Pelletier 6c8715fee3 wip
2026-08-25 12:29:30 +02:00

211 lines
7.1 KiB
Vue

<script setup lang="ts">
/**
* Who administers the app.
*
* Rights are held by an **address**, not by an account: somebody can be made an
* administrator before they have ever logged in, and the profile that turns up
* at their first login is the one that was named here. Until then the row is
* shown as pending, and the name on it is a stand-in.
*/
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ShieldCheck, Trash2, UserPlus } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/services/api/auth'
import { useAdmins, useGrantAdmin, useRevokeAdmin } from '@/services/api/users'
import { ApiError, type Administrator } from '@/utils/types'
const { t } = useI18n()
const { user } = useSession()
const { data: admins, isPending, isError } = useAdmins()
const grant = useGrantAdmin()
const revoke = useRevokeAdmin()
const email = ref('')
const error = ref('')
/** The same shape the backend accepts: something, an @, something */
const EMAIL_RE = /^[^\s@]+@[^\s@]+$/
const list = computed(() => admins.value ?? [])
function label(administrator: Administrator) {
const name = `${administrator.firstname} ${administrator.name}`.trim()
return name || administrator.email
}
function add() {
const address = email.value.trim()
error.value = ''
if (!EMAIL_RE.test(address)) {
error.value = t('admins.error-email')
return
}
if (
list.value.some((administrator) => administrator.email.toLowerCase() === address.toLowerCase())
) {
error.value = t('admins.error-already')
return
}
grant.mutate(address, {
onSuccess: () => {
email.value = ''
toast.success(t('admins.added', { email: address }))
},
onError: () => toast.error(t('admins.error')),
})
}
/**
* Nobody is removed on a stray click: the dialog names who is losing what.
*
* Who is being removed is held apart from whether the dialog is open — closing
* it is what runs the action, so a single flag would clear the target before
* the click is handled.
*/
const removing = ref<Administrator | null>(null)
const confirming = ref(false)
function askToRemove(administrator: Administrator) {
removing.value = administrator
confirming.value = true
}
function confirmRemove() {
confirming.value = false
const administrator = removing.value
if (!administrator) return
revoke.mutate(administrator.id, {
onSuccess: () => toast.success(t('admins.removed', { email: administrator.email })),
onError: (err) =>
toast.error(
// The backend refuses to let anybody demote themselves
err instanceof ApiError && err.status === HttpStatus.CONFLICT
? t('admins.error-self')
: t('admins.error'),
),
})
}
</script>
<template>
<div class="mx-auto w-full max-w-3xl">
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ $t('admins.title') }}</CardTitle>
<CardDescription>{{ $t('admins.intro') }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-6">
<!-- Adding one -->
<!-- `novalidate`: the address is checked below, so the message is the
translated one rather than the browser's own bubble -->
<form class="grid gap-2" novalidate @submit.prevent="add()">
<Label for="admin-email">{{ $t('admins.email') }}</Label>
<div class="flex flex-wrap items-start gap-2">
<Input
id="admin-email"
v-model="email"
type="email"
class="min-w-56 flex-1"
:placeholder="$t('admins.email-placeholder')"
:aria-invalid="!!error || undefined"
@input="error = ''"
/>
<Button type="submit" :disabled="grant.isPending.value">
<UserPlus class="size-4" />
{{ $t('admins.add') }}
</Button>
</div>
<p v-if="error" class="text-destructive text-xs">{{ error }}</p>
<p v-else class="text-muted-foreground text-xs">{{ $t('admins.hint') }}</p>
</form>
<!-- The current ones -->
<div class="grid gap-3">
<div v-if="isPending" class="grid gap-2">
<Skeleton v-for="i in 3" :key="i" class="h-14 w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('admins.load-error') }}
</p>
<p v-else-if="!list.length" class="text-muted-foreground text-sm">
{{ $t('admins.empty') }}
</p>
<div
v-for="administrator in list"
v-else
:key="administrator.id"
class="bg-card flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3"
>
<div class="grid min-w-0 gap-0.5">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
<ShieldCheck class="text-primary size-4 shrink-0" />
{{ label(administrator) }}
<Badge v-if="administrator.id === user?.id" variant="secondary">
{{ $t('admins.you') }}
</Badge>
<Badge v-else-if="administrator.pending" variant="secondary">
{{ $t('admins.pending') }}
</Badge>
</span>
<span class="text-muted-foreground truncate text-sm">{{ administrator.email }}</span>
</div>
<Button
variant="outline"
size="sm"
:disabled="administrator.id === user?.id || revoke.isPending.value"
:title="administrator.id === user?.id ? $t('admins.error-self') : undefined"
@click="askToRemove(administrator)"
>
<Trash2 class="size-4" />
{{ $t('admins.remove') }}
</Button>
</div>
</div>
</CardContent>
</Card>
<AlertDialog v-model:open="confirming">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('admins.confirm-title') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admins.confirm-intro', { email: removing?.email }) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('admins.confirm-back') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive hover:bg-destructive/90 text-white"
@click="confirmRemove()"
>
{{ $t('admins.remove') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>