wip
This commit is contained in:
parent
1619a34992
commit
6c8715fee3
18 changed files with 777 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ config.yaml
|
|||
/LEGACY
|
||||
summary.ai
|
||||
.env
|
||||
.env.example
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ClipboardList, LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
||||
import { ClipboardList, LogOut, Menu, Moon, ShieldCheck, Sun, User } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
|
|
@ -138,6 +138,12 @@ function signOut() {
|
|||
{{ $t('header.my-reservations') }}
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="user?.admin" as-child>
|
||||
<RouterLink :to="{ name: 'admins' }">
|
||||
<ShieldCheck class="size-4" />
|
||||
{{ $t('header.admins') }}
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
|
|
@ -186,6 +192,18 @@ function signOut() {
|
|||
{{ $t('header.my-reservations') }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="user?.admin"
|
||||
variant="ghost"
|
||||
class="justify-start"
|
||||
as-child
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
<RouterLink :to="{ name: 'admins' }">
|
||||
<ShieldCheck class="size-4" />
|
||||
{{ $t('header.admins') }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button variant="outline" class="mt-1 justify-start" @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
{{ $t('header.logout') }}
|
||||
|
|
|
|||
189
frontend/src/lib/api.d.ts
vendored
189
frontend/src/lib/api.d.ts
vendored
|
|
@ -1002,10 +1002,192 @@ export interface paths {
|
|||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/users/admins': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
/** List the administrators */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['Administrator'][]
|
||||
}
|
||||
}
|
||||
/** @description Unauthenticated - a session is required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Forbidden - the user must be an admin */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
put?: never
|
||||
/**
|
||||
* Make somebody an administrator, by email
|
||||
* @description The person need not have logged in yet: the row created is adopted at their first login. Granting to somebody who already is one changes nothing.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GrantAdminForm']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/**
|
||||
* @description One administrator, as the page that manages them lists them.
|
||||
*
|
||||
* `pending` is somebody named by their address who has never logged in: the
|
||||
* row is a placeholder waiting to be adopted at their first login, and the
|
||||
* names on it are not to be trusted.
|
||||
*/
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['Administrator']
|
||||
}
|
||||
}
|
||||
/** @description Not an email address */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Unauthenticated - a session is required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Forbidden - the user must be an admin */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/users/admins/{id}': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
put?: never
|
||||
post?: never
|
||||
/** Take the administrator rights away */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path: {
|
||||
id: number
|
||||
}
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description no content */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Unauthenticated - a session is required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Forbidden - the user must be an admin */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description no content */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description An administrator cannot demote themselves */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
}
|
||||
export type webhooks = Record<string, never>
|
||||
export interface components {
|
||||
schemas: {
|
||||
/**
|
||||
* @description One administrator, as the page that manages them lists them.
|
||||
*
|
||||
* `pending` is somebody named by their address who has never logged in: the
|
||||
* row is a placeholder waiting to be adopted at their first login, and the
|
||||
* names on it are not to be trusted.
|
||||
*/
|
||||
Administrator: {
|
||||
email: string
|
||||
firstname: string
|
||||
/** Format: int32 */
|
||||
id: number
|
||||
name: string
|
||||
pending: boolean
|
||||
}
|
||||
Bike: {
|
||||
battery?: string | null
|
||||
drivetrain?: string | null
|
||||
|
|
@ -1130,6 +1312,13 @@ export interface components {
|
|||
GetAuthorizeResponse: {
|
||||
redirect_to: string
|
||||
}
|
||||
GrantAdminForm: {
|
||||
/**
|
||||
* @description The address of the person to promote, whether or not they have ever
|
||||
* logged in
|
||||
*/
|
||||
email: string
|
||||
}
|
||||
IdPath: {
|
||||
/** Format: int32 */
|
||||
id: number
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ app:
|
|||
header:
|
||||
admin: Admin
|
||||
my-reservations: My reservations
|
||||
admins: 'Administrators'
|
||||
logout: Log out
|
||||
logout-error: Unable to log out.
|
||||
reserve: Book
|
||||
|
|
@ -230,3 +231,25 @@ admin:
|
|||
cancelled: Cancel
|
||||
ongoing: Start
|
||||
archived: Archive
|
||||
|
||||
admins:
|
||||
title: 'Administrators'
|
||||
intro: 'Add or remove the people who can administer the app.'
|
||||
email: 'Email address'
|
||||
email-placeholder: "firstname.name{'@'}epfl.ch"
|
||||
add: 'Add'
|
||||
hint: 'They need not have logged in yet: the rights wait for them at their first login.'
|
||||
remove: 'Remove'
|
||||
empty: 'No administrator.'
|
||||
load-error: 'Could not load the list of administrators.'
|
||||
you: 'You'
|
||||
pending: 'Never logged in'
|
||||
added: '{email} is now an administrator.'
|
||||
removed: '{email} is no longer an administrator.'
|
||||
error: 'That did not work. Try again.'
|
||||
error-email: 'That address is not valid.'
|
||||
error-already: 'That person is already an administrator.'
|
||||
error-self: 'You cannot take your own rights away.'
|
||||
confirm-title: 'Remove the rights?'
|
||||
confirm-intro: '{email} will lose access to the administration page. You can add them back at any time.'
|
||||
confirm-back: 'Cancel'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ app:
|
|||
header:
|
||||
admin: Admin
|
||||
my-reservations: Mes réservations
|
||||
admins: 'Administrateurs'
|
||||
logout: Se déconnecter
|
||||
logout-error: Impossible de se déconnecter.
|
||||
reserve: Réserver
|
||||
|
|
@ -232,3 +233,25 @@ admin:
|
|||
cancelled: Annuler
|
||||
ongoing: Démarrer
|
||||
archived: Archiver
|
||||
|
||||
admins:
|
||||
title: 'Administrateurs'
|
||||
intro: "Ajoutez ou retirez les personnes qui peuvent administrer l'application."
|
||||
email: 'Adresse e-mail'
|
||||
email-placeholder: "prenom.nom{'@'}epfl.ch"
|
||||
add: 'Ajouter'
|
||||
hint: "La personne n'a pas besoin de s'être déjà connectée : ses droits l'attendent à sa première connexion."
|
||||
remove: 'Retirer'
|
||||
empty: 'Aucun administrateur.'
|
||||
load-error: 'Impossible de charger la liste des administrateurs.'
|
||||
you: 'Vous'
|
||||
pending: 'Jamais connecté'
|
||||
added: '{email} est désormais administrateur.'
|
||||
removed: "{email} n'est plus administrateur."
|
||||
error: "L'opération a échoué. Réessayez."
|
||||
error-email: "Cette adresse n'est pas valide."
|
||||
error-already: 'Cette personne est déjà administratrice.'
|
||||
error-self: 'Vous ne pouvez pas retirer vos propres droits.'
|
||||
confirm-title: 'Retirer les droits ?'
|
||||
confirm-intro: "{email} n'aura plus accès à la page d'administration. Vous pourrez le rajouter à tout moment."
|
||||
confirm-back: 'Annuler'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ReservationView from '@/views/ReservationView.vue'
|
|||
import MyReservationsView from '@/views/MyReservationsView.vue'
|
||||
import CalendarView from '@/views/CalendarView.vue'
|
||||
import AdminView from '@/views/AdminView.vue'
|
||||
import AdminsView from '@/views/AdminsView.vue'
|
||||
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
||||
|
||||
declare module 'vue-router' {
|
||||
|
|
@ -40,6 +41,12 @@ const router = createRouter({
|
|||
component: AdminView,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
name: 'admins',
|
||||
path: '/admins',
|
||||
component: AdminsView,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
|
||||
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
|
||||
],
|
||||
|
|
|
|||
53
frontend/src/services/api/users.ts
Normal file
53
frontend/src/services/api/users.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Who administers the app. One file per domain area, exposing vue-query hooks:
|
||||
* views never call `fetch` themselves.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
|
||||
import type { Administrator } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
export const ADMINS_KEY = ['users', 'admins']
|
||||
|
||||
export function useAdmins() {
|
||||
return useQuery({
|
||||
queryKey: ADMINS_KEY,
|
||||
queryFn: async (): Promise<Administrator[]> => {
|
||||
const { data, response } = await getClient().GET('/api/users/admins')
|
||||
if (response.status === HttpStatus.OK && data) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes whoever holds this address an administrator. They need not have logged
|
||||
* in: the backend binds the rights to the address, and the profile that turns
|
||||
* up at the first login is the same one.
|
||||
*/
|
||||
export function useGrantAdmin() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async (email: string) => {
|
||||
const { data } = await getClient().POST('/api/users/admins', { body: { email } })
|
||||
return data
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMINS_KEY }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeAdmin() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async (id: number) => {
|
||||
await getClient().DELETE('/api/users/admins/{id}', { params: { path: { id } } })
|
||||
return id
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMINS_KEY }),
|
||||
})
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ export type ReservationBike = components['schemas']['ReservationBike']
|
|||
export type NewReservationBike = components['schemas']['NewReservationBike']
|
||||
export type Conflict = components['schemas']['Conflict']
|
||||
export type Person = components['schemas']['Person']
|
||||
export type Administrator = components['schemas']['Administrator']
|
||||
export type FleetLive = components['schemas']['FleetLive']
|
||||
export type BikeLive = components['schemas']['BikeLive']
|
||||
export type UsageAlert = components['schemas']['UsageAlert']
|
||||
|
|
|
|||
211
frontend/src/views/AdminsView.vue
Normal file
211
frontend/src/views/AdminsView.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<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>
|
||||
|
|
@ -36,6 +36,7 @@ mod bikes;
|
|||
mod docs;
|
||||
mod helpers;
|
||||
mod reservations;
|
||||
mod users;
|
||||
|
||||
pub fn get_router(aac: AnonAppController) -> Router {
|
||||
aide::generate::on_error(|err| error!("aide generated error: {err}"));
|
||||
|
|
@ -63,6 +64,7 @@ pub fn get_router(aac: AnonAppController) -> Router {
|
|||
.merge(auth::routes())
|
||||
.nest_api_service("/api/bikes", bikes::routes())
|
||||
.nest_api_service("/api/reservations", reservations::routes())
|
||||
.nest_api_service("/api/users", users::routes())
|
||||
.nest_api_service("/api/docs", docs::routes())
|
||||
.finish_api_with(&mut api, docs::api_docs_metadata)
|
||||
.layer(Extension(aac))
|
||||
|
|
|
|||
101
src/api/users.rs
Normal file
101
src/api/users.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Who administers the app.
|
||||
//!
|
||||
//! Users themselves are not managed here: they come from the authentication
|
||||
//! provider. The one decision that belongs to this app is `admin`, and this is
|
||||
//! where it is taken.
|
||||
|
||||
use aide::{
|
||||
axum::{
|
||||
ApiRouter,
|
||||
routing::{delete_with, get_with, post_with},
|
||||
},
|
||||
transform::TransformOperation,
|
||||
};
|
||||
use axum::{Json, extract::Path, http::StatusCode};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
api::helpers::{IdPath, admin, admin_desc, desc, unexpected_error},
|
||||
core::{
|
||||
controller::{AppController, ControllerError, users::UsersControllerError},
|
||||
models::user::Administrator,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn routes() -> ApiRouter {
|
||||
ApiRouter::new()
|
||||
.api_route("/admins", get_with(get_admins, get_admins_docs))
|
||||
.api_route("/admins", post_with(grant_admin, grant_admin_docs))
|
||||
.api_route("/admins/{id}", delete_with(revoke_admin, revoke_admin_docs))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrantAdminForm {
|
||||
/// The address of the person to promote, whether or not they have ever
|
||||
/// logged in
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn get_admins(ac: AppController) -> Result<Json<Vec<Administrator>>, (StatusCode, String)> {
|
||||
match admin(ac)?.get_admins().await {
|
||||
Ok(admins) => Ok(Json(admins)),
|
||||
Err(err) => unexpected_error("get_admins", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_admins_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Users")
|
||||
.summary("List the administrators")
|
||||
.response_with::<403, (), _>(admin_desc)
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn grant_admin(
|
||||
ac: AppController,
|
||||
Json(GrantAdminForm { email }): Json<GrantAdminForm>,
|
||||
) -> Result<Json<Administrator>, (StatusCode, String)> {
|
||||
match admin(ac)?.grant_admin(&email).await {
|
||||
Ok(administrator) => Ok(Json(administrator)),
|
||||
Err(ControllerError::User(err @ UsersControllerError::EmailInvalid)) => {
|
||||
Err((StatusCode::BAD_REQUEST, err.to_string()))
|
||||
}
|
||||
Err(err) => unexpected_error("grant_admin", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_admin_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Users")
|
||||
.summary("Make somebody an administrator, by email")
|
||||
.description(
|
||||
"The person need not have logged in yet: the row created is adopted \
|
||||
at their first login. Granting to somebody who already is one \
|
||||
changes nothing.",
|
||||
)
|
||||
.response_with::<403, (), _>(admin_desc)
|
||||
.response_with::<400, (), _>(desc("Not an email address"))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn revoke_admin(
|
||||
ac: AppController,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
match admin(ac)?.revoke_admin(id).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(ControllerError::User(err @ UsersControllerError::CannotDemoteSelf)) => {
|
||||
Err((StatusCode::CONFLICT, err.to_string()))
|
||||
}
|
||||
Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such user".to_owned())),
|
||||
Err(err) => unexpected_error("revoke_admin", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn revoke_admin_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Users")
|
||||
.summary("Take the administrator rights away")
|
||||
.response_with::<403, (), _>(admin_desc)
|
||||
.response_with::<409, (), _>(desc("An administrator cannot demote themselves"))
|
||||
.response::<404, ()>()
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ use thiserror::Error;
|
|||
use crate::core::{
|
||||
controller::{
|
||||
authn::AuthnControllerError, bikes::BikesControllerError,
|
||||
reservations::ReservationsControllerError,
|
||||
reservations::ReservationsControllerError, users::UsersControllerError,
|
||||
},
|
||||
models::{unit::UnitId, user::User},
|
||||
repositories::{DatabaseRepository, RepositoryError},
|
||||
|
|
@ -153,6 +153,8 @@ pub enum ControllerError {
|
|||
Bike(#[from] BikesControllerError),
|
||||
#[error("Reservation specific error: {0}")]
|
||||
Reservation(#[from] ReservationsControllerError),
|
||||
#[error("User specific error: {0}")]
|
||||
User(#[from] UsersControllerError),
|
||||
}
|
||||
|
||||
impl ControllerError {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::{
|
|||
core::{
|
||||
controller::{
|
||||
AdminAppController, AnonAppController, AppController, ControllerError,
|
||||
ManagerAppController,
|
||||
ManagerAppController, linka::Sweep,
|
||||
},
|
||||
models::{
|
||||
bike::{BikeId, BikeStatus},
|
||||
|
|
@ -98,7 +98,7 @@ impl AnonAppController {
|
|||
self.announce_approval(&updated).await;
|
||||
// A booking that starts within the half hour is one somebody may be
|
||||
// standing next to: the access list is settled now, not at the next tick
|
||||
self.sync_access_list().await;
|
||||
self.sync_access_list(Sweep::Diff).await;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
|
|
@ -506,7 +506,7 @@ impl AppController {
|
|||
let after = self.db.get_reservation(current.id).await?;
|
||||
self.announce_edit(¤t, &after).await;
|
||||
// An address added to a live booking can unlock a bike straight away
|
||||
self.sync_access_list().await;
|
||||
self.sync_access_list(Sweep::Diff).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -633,7 +633,7 @@ impl ManagerAppController {
|
|||
_ => {}
|
||||
}
|
||||
// Approving lets its riders in; anything else may take them back out
|
||||
self.sync_access_list().await;
|
||||
self.sync_access_list(Sweep::Diff).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! Users are not created by the app: they are mirrored from the authentication
|
||||
//! provider on login. The only decision that belongs to us is `admin`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
controller::{AdminAppController, AnonAppController, ControllerError},
|
||||
models::user::{User, UserId},
|
||||
models::user::{Administrator, User, UserId, is_valid_email},
|
||||
};
|
||||
|
||||
impl AnonAppController {
|
||||
|
|
@ -27,4 +29,57 @@ impl AdminAppController {
|
|||
pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> {
|
||||
self.db.set_user_admin(id, admin).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_admins(&self) -> Result<Vec<Administrator>, ControllerError> {
|
||||
self.db.get_admins().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Makes whoever holds `email` an administrator.
|
||||
///
|
||||
/// The address is the identity, so nobody has to have logged in first: a
|
||||
/// placeholder row is created and adopted the day that person does, which
|
||||
/// is the same rule as filing a reservation for somebody.
|
||||
///
|
||||
/// Granting to somebody who already is one changes nothing and is not an
|
||||
/// error: the page asks for a state, not for a transition.
|
||||
pub async fn grant_admin(&self, email: &str) -> Result<Administrator, ControllerError> {
|
||||
let email = email.trim();
|
||||
if !is_valid_email(email) {
|
||||
return Err(UsersControllerError::EmailInvalid.into());
|
||||
}
|
||||
// Nothing is known of a person named by their address alone; the local
|
||||
// part is a stand-in until their first login brings the real names
|
||||
let placeholder = email.split('@').next().unwrap_or(email);
|
||||
let user = self.db.get_or_create_user(email, placeholder, "").await?;
|
||||
if !user.admin {
|
||||
self.db.set_user_admin(user.id, true).await?;
|
||||
}
|
||||
Ok(Administrator {
|
||||
id: user.id,
|
||||
firstname: user.firstname,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
pending: user.oidc_sub.starts_with("pending:"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes the rights away from `id`.
|
||||
///
|
||||
/// Never from oneself: an admin who demotes themselves cannot undo it, and
|
||||
/// the last one doing so would leave the app with nobody able to grant them
|
||||
/// back.
|
||||
pub async fn revoke_admin(&self, id: UserId) -> Result<(), ControllerError> {
|
||||
if self.user().id == id {
|
||||
return Err(UsersControllerError::CannotDemoteSelf.into());
|
||||
}
|
||||
self.db.set_user_admin(id, false).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum UsersControllerError {
|
||||
#[error("That is not an email address")]
|
||||
EmailInvalid,
|
||||
#[error("An administrator cannot take their own rights away")]
|
||||
CannotDemoteSelf,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,15 +40,38 @@ pub struct Person {
|
|||
|
||||
impl Person {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let email = self.email.trim();
|
||||
// Same shape the form checks: something, an @, something with a dot
|
||||
is_valid_email(&self.email)
|
||||
&& !self.firstname.trim().is_empty()
|
||||
&& !self.name.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape an address must have to be worth storing.
|
||||
///
|
||||
/// Deliberately loose — something, an `@`, something — because the only real
|
||||
/// check is that mail reaches it, and refusing an unusual but valid address
|
||||
/// would be worse than accepting a wrong one.
|
||||
pub fn is_valid_email(email: &str) -> bool {
|
||||
let email = email.trim();
|
||||
email.len() >= 3
|
||||
&& email.split('@').count() == 2
|
||||
&& !email.starts_with('@')
|
||||
&& !email.ends_with('@')
|
||||
&& !self.firstname.trim().is_empty()
|
||||
&& !self.name.trim().is_empty()
|
||||
}
|
||||
&& !email.contains(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// One administrator, as the page that manages them lists them.
|
||||
///
|
||||
/// `pending` is somebody named by their address who has never logged in: the
|
||||
/// row is a placeholder waiting to be adopted at their first login, and the
|
||||
/// names on it are not to be trusted.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct Administrator {
|
||||
pub id: UserId,
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// A user as they appear inside another object (a reservation, ...): enough to
|
||||
|
|
@ -60,3 +83,20 @@ pub struct UserSummary {
|
|||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_address_needs_a_local_part_an_at_and_a_domain() {
|
||||
assert!(is_valid_email("antoine.pelletier@epfl.ch"));
|
||||
assert!(is_valid_email(" spaced@epfl.ch "));
|
||||
assert!(!is_valid_email("@epfl.ch"));
|
||||
assert!(!is_valid_email("nobody@"));
|
||||
assert!(!is_valid_email("no-at-sign"));
|
||||
assert!(!is_valid_email("two@at@signs"));
|
||||
assert!(!is_valid_email("a space@epfl.ch"));
|
||||
assert!(!is_valid_email(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use async_trait::async_trait;
|
|||
use crate::core::{
|
||||
models::{
|
||||
unit::UnitId,
|
||||
user::{NewUser, User, UserId},
|
||||
user::{Administrator, NewUser, User, UserId},
|
||||
},
|
||||
repositories::RepositoryError,
|
||||
};
|
||||
|
|
@ -41,5 +41,8 @@ pub trait UsersRepository {
|
|||
/// Replaces the whole set of units the user belongs to
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Everybody who can administer the app, by name
|
||||
async fn get_admins(&self) -> Result<Vec<Administrator>, RepositoryError>;
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -9,7 +9,10 @@ use tower_http::services::{ServeDir, ServeFile};
|
|||
use tracing::info;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::{core::controller::AnonAppController, services::database::SqlxDatabase};
|
||||
use crate::{
|
||||
core::controller::{AnonAppController, linka::Sweep},
|
||||
services::database::SqlxDatabase,
|
||||
};
|
||||
|
||||
mod api;
|
||||
mod core;
|
||||
|
|
@ -83,11 +86,23 @@ fn spawn_status_ticker(controller: AnonAppController) {
|
|||
/// decision taken in the meantime does not wait for the tick, since approving
|
||||
/// reconciles straight away.
|
||||
fn spawn_linka_ticker(controller: AnonAppController) {
|
||||
/// The platform cannot be read back, so the access list is asserted in full
|
||||
/// every so often — and always on the first pass. That is what repairs a
|
||||
/// list changed on the platform itself, or while this app was down.
|
||||
const FULL_SWEEP_EVERY: u32 = 30;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
let mut passes: u32 = 0;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
controller.sync_linka().await;
|
||||
let sweep = if passes.is_multiple_of(FULL_SWEEP_EVERY) {
|
||||
Sweep::Full
|
||||
} else {
|
||||
Sweep::Diff
|
||||
};
|
||||
controller.sync_linka(sweep).await;
|
||||
passes = passes.wrapping_add(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::{
|
|||
core::{
|
||||
models::{
|
||||
unit::{Unit, UnitId},
|
||||
user::{NewUser, User, UserId},
|
||||
user::{Administrator, NewUser, User, UserId},
|
||||
},
|
||||
repositories::{RepositoryError, users_repository::UsersRepository},
|
||||
},
|
||||
|
|
@ -215,6 +215,22 @@ impl UsersRepository for SqlxDatabase {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_admins(&self) -> Result<Vec<Administrator>, RepositoryError> {
|
||||
// The placeholder subject is what marks somebody named by an admin who
|
||||
// has never logged in — see `get_or_create_user`
|
||||
let admins = query_as!(
|
||||
Administrator,
|
||||
r#"SELECT id, firstname, "name", email,
|
||||
oidc_sub LIKE 'pending:%' AS "pending!"
|
||||
FROM users
|
||||
WHERE admin = true
|
||||
ORDER BY lower(email)"#
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(admins)
|
||||
}
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> {
|
||||
let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin)
|
||||
.execute(&self.pool)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue