This commit is contained in:
Antoine Pelletier 2026-08-25 12:29:30 +02:00
parent 1619a34992
commit 6c8715fee3
18 changed files with 777 additions and 17 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ config.yaml
/LEGACY /LEGACY
summary.ai summary.ai
.env .env
.env.example

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useRouter } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@ -138,6 +138,12 @@ function signOut() {
{{ $t('header.my-reservations') }} {{ $t('header.my-reservations') }}
</RouterLink> </RouterLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="user?.admin" as-child>
<RouterLink :to="{ name: 'admins' }">
<ShieldCheck class="size-4" />
{{ $t('header.admins') }}
</RouterLink>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem @click="signOut()"> <DropdownMenuItem @click="signOut()">
<LogOut class="size-4" /> <LogOut class="size-4" />
@ -186,6 +192,18 @@ function signOut() {
{{ $t('header.my-reservations') }} {{ $t('header.my-reservations') }}
</RouterLink> </RouterLink>
</Button> </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()"> <Button variant="outline" class="mt-1 justify-start" @click="signOut()">
<LogOut class="size-4" /> <LogOut class="size-4" />
{{ $t('header.logout') }} {{ $t('header.logout') }}

View file

@ -1002,10 +1002,192 @@ export interface paths {
patch?: never patch?: never
trace?: 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 type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { 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: { Bike: {
battery?: string | null battery?: string | null
drivetrain?: string | null drivetrain?: string | null
@ -1130,6 +1312,13 @@ export interface components {
GetAuthorizeResponse: { GetAuthorizeResponse: {
redirect_to: string redirect_to: string
} }
GrantAdminForm: {
/**
* @description The address of the person to promote, whether or not they have ever
* logged in
*/
email: string
}
IdPath: { IdPath: {
/** Format: int32 */ /** Format: int32 */
id: number id: number

View file

@ -4,6 +4,7 @@ app:
header: header:
admin: Admin admin: Admin
my-reservations: My reservations my-reservations: My reservations
admins: 'Administrators'
logout: Log out logout: Log out
logout-error: Unable to log out. logout-error: Unable to log out.
reserve: Book reserve: Book
@ -230,3 +231,25 @@ admin:
cancelled: Cancel cancelled: Cancel
ongoing: Start ongoing: Start
archived: Archive 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'

View file

@ -4,6 +4,7 @@ app:
header: header:
admin: Admin admin: Admin
my-reservations: Mes réservations my-reservations: Mes réservations
admins: 'Administrateurs'
logout: Se déconnecter logout: Se déconnecter
logout-error: Impossible de se déconnecter. logout-error: Impossible de se déconnecter.
reserve: Réserver reserve: Réserver
@ -232,3 +233,25 @@ admin:
cancelled: Annuler cancelled: Annuler
ongoing: Démarrer ongoing: Démarrer
archived: Archiver 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'

View file

@ -6,6 +6,7 @@ import ReservationView from '@/views/ReservationView.vue'
import MyReservationsView from '@/views/MyReservationsView.vue' import MyReservationsView from '@/views/MyReservationsView.vue'
import CalendarView from '@/views/CalendarView.vue' import CalendarView from '@/views/CalendarView.vue'
import AdminView from '@/views/AdminView.vue' import AdminView from '@/views/AdminView.vue'
import AdminsView from '@/views/AdminsView.vue'
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue' import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
declare module 'vue-router' { declare module 'vue-router' {
@ -40,6 +41,12 @@ const router = createRouter({
component: AdminView, component: AdminView,
meta: { requiresAuth: true, requiresAdmin: true }, 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 // Registered as the OIDC redirect uri, see `server.base_url` in config.yml
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView }, { name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
], ],

View 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 }),
})
}

View file

@ -51,6 +51,7 @@ export type ReservationBike = components['schemas']['ReservationBike']
export type NewReservationBike = components['schemas']['NewReservationBike'] export type NewReservationBike = components['schemas']['NewReservationBike']
export type Conflict = components['schemas']['Conflict'] export type Conflict = components['schemas']['Conflict']
export type Person = components['schemas']['Person'] export type Person = components['schemas']['Person']
export type Administrator = components['schemas']['Administrator']
export type FleetLive = components['schemas']['FleetLive'] export type FleetLive = components['schemas']['FleetLive']
export type BikeLive = components['schemas']['BikeLive'] export type BikeLive = components['schemas']['BikeLive']
export type UsageAlert = components['schemas']['UsageAlert'] export type UsageAlert = components['schemas']['UsageAlert']

View 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>

View file

@ -36,6 +36,7 @@ mod bikes;
mod docs; mod docs;
mod helpers; mod helpers;
mod reservations; mod reservations;
mod users;
pub fn get_router(aac: AnonAppController) -> Router { pub fn get_router(aac: AnonAppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}")); aide::generate::on_error(|err| error!("aide generated error: {err}"));
@ -63,6 +64,7 @@ pub fn get_router(aac: AnonAppController) -> Router {
.merge(auth::routes()) .merge(auth::routes())
.nest_api_service("/api/bikes", bikes::routes()) .nest_api_service("/api/bikes", bikes::routes())
.nest_api_service("/api/reservations", reservations::routes()) .nest_api_service("/api/reservations", reservations::routes())
.nest_api_service("/api/users", users::routes())
.nest_api_service("/api/docs", docs::routes()) .nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata) .finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(aac)) .layer(Extension(aac))

101
src/api/users.rs Normal file
View 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, ()>()
}

View file

@ -23,7 +23,7 @@ use thiserror::Error;
use crate::core::{ use crate::core::{
controller::{ controller::{
authn::AuthnControllerError, bikes::BikesControllerError, authn::AuthnControllerError, bikes::BikesControllerError,
reservations::ReservationsControllerError, reservations::ReservationsControllerError, users::UsersControllerError,
}, },
models::{unit::UnitId, user::User}, models::{unit::UnitId, user::User},
repositories::{DatabaseRepository, RepositoryError}, repositories::{DatabaseRepository, RepositoryError},
@ -153,6 +153,8 @@ pub enum ControllerError {
Bike(#[from] BikesControllerError), Bike(#[from] BikesControllerError),
#[error("Reservation specific error: {0}")] #[error("Reservation specific error: {0}")]
Reservation(#[from] ReservationsControllerError), Reservation(#[from] ReservationsControllerError),
#[error("User specific error: {0}")]
User(#[from] UsersControllerError),
} }
impl ControllerError { impl ControllerError {

View file

@ -6,7 +6,7 @@ use crate::{
core::{ core::{
controller::{ controller::{
AdminAppController, AnonAppController, AppController, ControllerError, AdminAppController, AnonAppController, AppController, ControllerError,
ManagerAppController, ManagerAppController, linka::Sweep,
}, },
models::{ models::{
bike::{BikeId, BikeStatus}, bike::{BikeId, BikeStatus},
@ -98,7 +98,7 @@ impl AnonAppController {
self.announce_approval(&updated).await; self.announce_approval(&updated).await;
// A booking that starts within the half hour is one somebody may be // 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 // 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) Ok(updated)
} }
@ -506,7 +506,7 @@ impl AppController {
let after = self.db.get_reservation(current.id).await?; let after = self.db.get_reservation(current.id).await?;
self.announce_edit(&current, &after).await; self.announce_edit(&current, &after).await;
// An address added to a live booking can unlock a bike straight away // 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(()) Ok(())
} }
@ -633,7 +633,7 @@ impl ManagerAppController {
_ => {} _ => {}
} }
// Approving lets its riders in; anything else may take them back out // 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(()) Ok(())
} }

View file

@ -1,9 +1,11 @@
//! Users are not created by the app: they are mirrored from the authentication //! 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`. //! provider on login. The only decision that belongs to us is `admin`.
use thiserror::Error;
use crate::core::{ use crate::core::{
controller::{AdminAppController, AnonAppController, ControllerError}, controller::{AdminAppController, AnonAppController, ControllerError},
models::user::{User, UserId}, models::user::{Administrator, User, UserId, is_valid_email},
}; };
impl AnonAppController { impl AnonAppController {
@ -27,4 +29,57 @@ impl AdminAppController {
pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> { 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) 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,
} }

View file

@ -40,15 +40,38 @@ pub struct Person {
impl Person { impl Person {
pub fn is_valid(&self) -> bool { pub fn is_valid(&self) -> bool {
let email = self.email.trim(); is_valid_email(&self.email)
// Same shape the form checks: something, an @, something with a dot && !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.len() >= 3
&& email.split('@').count() == 2 && email.split('@').count() == 2
&& !email.starts_with('@') && !email.starts_with('@')
&& !email.ends_with('@') && !email.ends_with('@')
&& !self.firstname.trim().is_empty() && !email.contains(char::is_whitespace)
&& !self.name.trim().is_empty()
} }
/// 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 /// A user as they appear inside another object (a reservation, ...): enough to
@ -60,3 +83,20 @@ pub struct UserSummary {
pub name: String, pub name: String,
pub email: 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(""));
}
}

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use crate::core::{ use crate::core::{
models::{ models::{
unit::UnitId, unit::UnitId,
user::{NewUser, User, UserId}, user::{Administrator, NewUser, User, UserId},
}, },
repositories::RepositoryError, repositories::RepositoryError,
}; };
@ -41,5 +41,8 @@ pub trait UsersRepository {
/// Replaces the whole set of units the user belongs to /// Replaces the whole set of units the user belongs to
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>; 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>; async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
} }

View file

@ -9,7 +9,10 @@ use tower_http::services::{ServeDir, ServeFile};
use tracing::info; use tracing::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; 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 api;
mod core; 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 /// decision taken in the meantime does not wait for the tick, since approving
/// reconciles straight away. /// reconciles straight away.
fn spawn_linka_ticker(controller: AnonAppController) { 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 { tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
let mut passes: u32 = 0;
loop { loop {
tick.tick().await; 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);
} }
}); });
} }

View file

@ -5,7 +5,7 @@ use crate::{
core::{ core::{
models::{ models::{
unit::{Unit, UnitId}, unit::{Unit, UnitId},
user::{NewUser, User, UserId}, user::{Administrator, NewUser, User, UserId},
}, },
repositories::{RepositoryError, users_repository::UsersRepository}, repositories::{RepositoryError, users_repository::UsersRepository},
}, },
@ -215,6 +215,22 @@ impl UsersRepository for SqlxDatabase {
Ok(()) 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> { 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) let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin)
.execute(&self.pool) .execute(&self.pool)