211 lines
7.1 KiB
Vue
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>
|