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
|
/LEGACY
|
||||||
summary.ai
|
summary.ai
|
||||||
.env
|
.env
|
||||||
|
.env.example
|
||||||
|
|
@ -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') }}
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
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 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']
|
||||||
|
|
|
||||||
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 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
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::{
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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(¤t, &after).await;
|
self.announce_edit(¤t, &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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/main.rs
19
src/main.rs
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue