Compare commits

...

2 commits

Author SHA1 Message Date
Antoine Pelletier
1ae4ba3a58 feat: add cargobike structure 2026-08-23 22:20:40 +02:00
Antoine Pelletier
7e958b89c6 feat: add oidc 2026-08-23 22:20:27 +02:00
99 changed files with 11579 additions and 572 deletions

View file

@ -1,5 +1,6 @@
{
"i18n-ally.localesPaths": [
"frontend/src/locales"
]
],
"i18n-ally.keystyle": "nested"
}

1189
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,11 @@ aide = { version = "0.15.1", features = [
] }
async-trait = "0.1.89"
axum = { version = "0.8.9", features = ["macros"] }
axum-login = "0.18.0"
chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] }
config = "0.15.23"
indexmap = "2.14.0"
openidconnect = "4.0.1"
schemars = { version = "0.9", features = ["chrono04"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
@ -27,5 +30,6 @@ sqlx = { version = "0.8.6", features = [
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "signal"] }
tower-http = { version = "0.6.10", features = ["fs"] }
tower-sessions = { version = "0.14", default-features = false, features = ["memory-store"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }

View file

@ -17,11 +17,17 @@ routes and the frontend views are not written yet.
- Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui`
- A single binary in production: the backend serves the built frontend
Authentication is **not wired yet**. Users are mirrored from AGEPoly's OIDC provider
(Whiskey): `users.oidc_sub` is the identity, and `AppController::login` upserts the row from
the claims of the token. Because the units also come from Whiskey, they are stored as plain
text (`units_users.unit_name`, `reservations.unit`) rather than as an enum or a reference
table: a unit unknown to us must never break a login.
Users are mirrored from AGEPoly's OIDC provider (Whiskey): `users.oidc_sub` is the
identity, and a login upserts the row from the claims of the token. Whiskey calls the units
**groups** and sends them in the `groups` claim; each one is a row in `units`, shared by
everybody who belongs to it, and the memberships are rewritten at every login — so a user
removed from a group there loses it here too. A group we have never seen is created on the
spot rather than rejected: an unknown unit must never break a login.
`admin` is ours, and is never touched by a login.
In a debug build, `dev_users` from the configuration can be logged in through
`POST /api/login` without going through the provider — see the login page.
## Getting started
@ -90,6 +96,27 @@ another implementation (a mock in tests, another storage) without touching the l
Handlers stay thin — extract the controller, call it, map the error to a status code — and
their OpenAPI documentation sits right next to them (`fn *_docs`).
### Authorization is a type, not a check
There are four controllers, each dereferencing into the one above it:
```
AnonAppController anybody: reading the fleet, the calendar, the login routes
└─ AppController a logged in user
├─ ManagerAppController a member of one unit, acting for that unit
└─ AdminAppController an admin
```
A handler declares what it needs in its signature. `AnonAppController` always extracts;
`AppController` resolves the session cookie and answers **401** on its own, so a protected
route cannot be left open by forgetting a check. Going further down is explicit and
fallible — `try_into_manager(unit)` and `try_into_admin()`, wrapped by `api::helpers` so a
refusal becomes a 403.
Sessions are handled by `axum-login` on top of `tower-sessions`, wired in `api/mod.rs`.
The store is in memory: **everybody is logged out when the backend restarts**. Swap it for a
persistent store if that becomes a problem.
### Adding an entity
1. `dbmate n create_things` and write the migration, then `dbmate up`
@ -157,10 +184,13 @@ vue router keeps working on a page reload. Only one process to deploy.
## Data model
```
users ──< units_users a user belongs to several units (from Whiskey)
│
├──< reservations_users >── reservations ──< reservations_bikes >── bikes
└──── reservations.requester_id
units ──< units_users >── users a unit (a Whiskey group) has many members,
│ │ and a user belongs to many units
│ ├──< reservations_users >── reservations
│ └──── reservations.requester_id
└──── reservations.unit_id exactly one unit borrows the bikes
reservations ──< reservations_bikes >── bikes
```
A reservation moves through a state machine, enforced in

View file

@ -1,15 +0,0 @@
# Copy to config.yml (gitignored) and adapt.
# Every value can also be given as an environment variable, e.g. APP__SERVER__PORT=3000
server:
address: 0.0.0.0
port: 3000
postgres:
host: localhost
port: 5432
user: postgres
password: postgres
name: cargagep
# Directory containing the built frontend (frontend/dist after `npm run build`)
frontend_dir: frontend/dist

View file

@ -0,0 +1,16 @@
-- migrate:up
-- Short lived state of an in-flight OIDC authorization: the csrf token is the
-- key the provider hands back, `data` holds the pkce verifier and the nonce.
-- A row is consumed by the callback (deleted on read), so a state can never be
-- replayed, and `created_at` lets the stale ones be swept.
CREATE TABLE oidc_states (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX oidc_states_created_at_idx ON oidc_states (created_at);
-- migrate:down
DROP TABLE IF EXISTS oidc_states;

View file

@ -0,0 +1,56 @@
-- migrate:up
-- A unit (an AGEPoly committee or commission) is a row of its own rather than a
-- string repeated on every membership: it can be renamed, and per-unit data can
-- be hung off it later without touching anything else. `name` is the Whiskey
-- group name and stays the key we match the provider on.
CREATE TABLE units (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- Everything that already named a unit becomes a row
INSERT INTO units ("name")
SELECT DISTINCT unit_name FROM units_users
UNION
SELECT DISTINCT unit FROM reservations
ON CONFLICT ("name") DO NOTHING;
-- units_users: unit_name -> unit_id
ALTER TABLE units_users
ADD COLUMN unit_id INTEGER REFERENCES units (id) ON DELETE CASCADE;
UPDATE units_users uu SET unit_id = u.id FROM units u WHERE u."name" = uu.unit_name;
ALTER TABLE units_users ALTER COLUMN unit_id SET NOT NULL;
ALTER TABLE units_users DROP CONSTRAINT units_users_pkey;
ALTER TABLE units_users DROP COLUMN unit_name;
ALTER TABLE units_users ADD PRIMARY KEY (unit_id, user_id);
CREATE INDEX units_users_user_id_idx ON units_users (user_id);
-- reservations: unit -> unit_id
ALTER TABLE reservations ADD COLUMN unit_id INTEGER REFERENCES units (id);
UPDATE reservations r SET unit_id = u.id FROM units u WHERE u."name" = r.unit;
ALTER TABLE reservations ALTER COLUMN unit_id SET NOT NULL;
DROP INDEX reservations_unit_idx;
ALTER TABLE reservations DROP COLUMN unit;
CREATE INDEX reservations_unit_id_idx ON reservations (unit_id);
-- migrate:down
ALTER TABLE reservations ADD COLUMN unit TEXT;
UPDATE reservations r SET unit = u."name" FROM units u WHERE u.id = r.unit_id;
ALTER TABLE reservations ALTER COLUMN unit SET NOT NULL;
DROP INDEX reservations_unit_id_idx;
ALTER TABLE reservations DROP COLUMN unit_id;
CREATE INDEX reservations_unit_idx ON reservations (unit);
ALTER TABLE units_users ADD COLUMN unit_name TEXT;
UPDATE units_users uu SET unit_name = u."name" FROM units u WHERE u.id = uu.unit_id;
ALTER TABLE units_users ALTER COLUMN unit_name SET NOT NULL;
ALTER TABLE units_users DROP CONSTRAINT units_users_pkey;
DROP INDEX units_users_user_id_idx;
ALTER TABLE units_users DROP COLUMN unit_id;
ALTER TABLE units_users ADD PRIMARY KEY (user_id, unit_name);
CREATE INDEX units_users_unit_name_idx ON units_users (unit_name);
DROP TABLE units;

View file

@ -1,6 +1,6 @@
\restrict dbmate
-- Dumped from database version 18.6
-- Dumped from database version 18.3
-- Dumped by pg_dump version 18.6
SET statement_timeout = 0;
@ -79,19 +79,30 @@ CREATE SEQUENCE public.bikes_id_seq
ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id;
--
-- Name: oidc_states; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.oidc_states (
key text NOT NULL,
data text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: reservations; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.reservations (
id integer NOT NULL,
unit text NOT NULL,
start_time timestamp with time zone NOT NULL,
end_time timestamp with time zone NOT NULL,
requester_id integer NOT NULL,
telegram text NOT NULL,
description text DEFAULT ''::text NOT NULL,
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
unit_id integer NOT NULL,
CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
CONSTRAINT reservations_time_order CHECK ((end_time > start_time))
);
@ -146,13 +157,43 @@ CREATE TABLE public.schema_migrations (
);
--
-- Name: units; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.units (
id integer NOT NULL,
name text NOT NULL
);
--
-- Name: units_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.units_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: units_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.units_id_seq OWNED BY public.units.id;
--
-- Name: units_users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.units_users (
user_id integer NOT NULL,
unit_name text NOT NULL
unit_id integer NOT NULL
);
@ -205,6 +246,13 @@ ALTER TABLE ONLY public.bikes ALTER COLUMN id SET DEFAULT nextval('public.bikes_
ALTER TABLE ONLY public.reservations ALTER COLUMN id SET DEFAULT nextval('public.reservations_id_seq'::regclass);
--
-- Name: units id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units ALTER COLUMN id SET DEFAULT nextval('public.units_id_seq'::regclass);
--
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
--
@ -220,6 +268,14 @@ ALTER TABLE ONLY public.bikes
ADD CONSTRAINT bikes_pkey PRIMARY KEY (id);
--
-- Name: oidc_states oidc_states_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.oidc_states
ADD CONSTRAINT oidc_states_pkey PRIMARY KEY (key);
--
-- Name: reservations_bikes reservations_bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -252,12 +308,28 @@ ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: units units_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units
ADD CONSTRAINT units_name_key UNIQUE (name);
--
-- Name: units units_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units
ADD CONSTRAINT units_pkey PRIMARY KEY (id);
--
-- Name: units_users units_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units_users
ADD CONSTRAINT units_users_pkey PRIMARY KEY (user_id, unit_name);
ADD CONSTRAINT units_users_pkey PRIMARY KEY (unit_id, user_id);
--
@ -292,6 +364,13 @@ ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: oidc_states_created_at_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX oidc_states_created_at_idx ON public.oidc_states USING btree (created_at);
--
-- Name: reservations_bikes_bike_id_idx; Type: INDEX; Schema: public; Owner: -
--
@ -314,17 +393,17 @@ CREATE INDEX reservations_status_idx ON public.reservations USING btree (status)
--
-- Name: reservations_unit_idx; Type: INDEX; Schema: public; Owner: -
-- Name: reservations_unit_id_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX reservations_unit_idx ON public.reservations USING btree (unit);
CREATE INDEX reservations_unit_id_idx ON public.reservations USING btree (unit_id);
--
-- Name: units_users_unit_name_idx; Type: INDEX; Schema: public; Owner: -
-- Name: units_users_user_id_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX units_users_unit_name_idx ON public.units_users USING btree (unit_name);
CREATE INDEX units_users_user_id_idx ON public.units_users USING btree (user_id);
--
@ -351,6 +430,14 @@ ALTER TABLE ONLY public.reservations
ADD CONSTRAINT reservations_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id);
--
-- Name: reservations reservations_unit_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.reservations
ADD CONSTRAINT reservations_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES public.units(id);
--
-- Name: reservations_users reservations_users_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -367,6 +454,14 @@ ALTER TABLE ONLY public.reservations_users
ADD CONSTRAINT reservations_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: units_users units_users_unit_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.units_users
ADD CONSTRAINT units_users_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES public.units(id) ON DELETE CASCADE;
--
-- Name: units_users units_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -389,4 +484,6 @@ ALTER TABLE ONLY public.units_users
INSERT INTO public.schema_migrations (version) VALUES
('20260823153300'),
('20260823153310'),
('20260823153320');
('20260823153320'),
('20260823170000'),
('20260823210000');

View file

@ -8,26 +8,47 @@ INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, a
(3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE)
ON CONFLICT DO NOTHING;
-- Unit names come from Whiskey; these are placeholders for development
INSERT INTO public.units_users (user_id, unit_name) VALUES
-- Units are Whiskey's "groups". In real life the rows are created on the fly at
-- login; these are placeholders for development.
INSERT INTO public.units ("name") VALUES
('agepoly'),
('clic'),
('S4S'),
('Balélec')
ON CONFLICT ("name") DO NOTHING;
-- Resolved by name so the seed never depends on generated ids
INSERT INTO public.units_users (user_id, unit_id)
SELECT membership.user_id, u.id
FROM (VALUES
(1, 'agepoly'),
(2, 'agepoly'),
(2, 'clic'),
(1, 'S4S'),
(1, 'Balélec'),
(3, 'clic')
) AS membership (user_id, unit_name)
JOIN public.units u ON u."name" = membership.unit_name
ON CONFLICT DO NOTHING;
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
(1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
(2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
(3, 'Cargo 3', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service')
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'),
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service')
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations
(id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES
(1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin',
'Transport du matériel pour la rentrée', 'approved'),
(2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre',
'Déménagement du stock de la commission', 'requested')
(id, unit_id, start_time, end_time, requester_id, telegram, "description", status)
SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status
FROM (VALUES
(1, 'agepoly', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz,
1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status),
(2, 'clic', '2026-09-05 09:00:00+02'::timestamptz, '2026-09-06 17:00:00+02'::timestamptz,
3, '@chloe_favre', 'Déménagement du stock de la commission', 'requested'::reservation_status)
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status)
JOIN public.units u ON u."name" = r.unit_name
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
@ -39,6 +60,7 @@ INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
ON CONFLICT DO NOTHING;
-- Keep the sequences in sync with the explicit ids inserted above
SELECT setval('public.units_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.units));
SELECT setval('public.users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.users));
SELECT setval('public.bikes_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.bikes));
SELECT setval('public.reservations_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.reservations));

View file

@ -9,7 +9,7 @@
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CarGAGEP</title>
<title>Cargobikes</title>
</head>
<body>
<div id="app"></div>

6787
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -17,17 +17,18 @@
"openapi": "openapi-typescript http://localhost:3000/api/docs/private/api.json -o ./src/lib/api.d.ts"
},
"dependencies": {
"@lucide/vue": "^1.26.0",
"@internationalized/date": "^3.12.3",
"@lucide/vue": "^1.33.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^6.1.29",
"@vueuse/core": "^14.3.0",
"@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"http-status-ts": "^2.0.1",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.14",
"reka-ui": "^2.10.1",
"reka-ui": "^2.10.3",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",

View file

@ -1,16 +1,16 @@
<script setup lang="ts">
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
import { SidebarProvider } from '@/components/ui/sidebar'
import { Toaster } from '@/components/ui/sonner'
import AppSidebar from './components/AppSidebar.vue'
import AppContent from './components/AppContent.vue'
import AppHeader from './components/AppHeader.vue'
</script>
<template>
<SidebarProvider>
<AppSidebar />
<AppContent />
</SidebarProvider>
<div class="bg-background text-foreground flex min-h-svh flex-col">
<AppHeader />
<main class="w-full flex-1 px-4 py-6 sm:px-6">
<RouterView />
</main>
</div>
<VueQueryDevtools />
<Toaster position="top-center" />
</template>

View file

@ -1,11 +0,0 @@
<script setup lang="ts">
import { useSidebar } from './ui/sidebar'
const { setOpen } = useSidebar()
</script>
<template>
<main @click="setOpen(false)" class="py-5 px-7 min-h-full w-full">
<RouterView />
</main>
</template>

View file

@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { getWhiskeyAuthorizationUrl, useLogoutMutation, useSession } from '@/services/api/auth'
import { locales, setLocale, type Locale } from '@/services/i18n'
import { isDark, toggleTheme } from '@/services/theme'
const { t, locale: currentLocale } = useI18n()
const router = useRouter()
const flags: Record<Locale, string> = { fr: '🇫🇷', en: '🇬🇧' }
const nav = [
{ name: 'reservations', label: 'header.reserve' },
{ name: 'calendar', label: 'header.calendar' },
]
const menuOpen = ref(false)
const { user, isLoggedIn } = useSession()
const logoutMutation = useLogoutMutation()
function login() {
menuOpen.value = false
getWhiskeyAuthorizationUrl()
.then((url) => {
window.location.href = url
})
.catch(() => toast.error(t('login.error')))
}
function signOut() {
menuOpen.value = false
logoutMutation.mutate(undefined, {
onSuccess: () => router.push({ name: 'login' }),
onError: () => toast.error(t('header.logout-error')),
})
}
</script>
<template>
<header class="bg-background sticky top-0 z-40 w-full border-b">
<div class="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:gap-3">
<!-- Identity -->
<RouterLink :to="{ name: 'reservations' }" class="flex min-w-0 items-center gap-2">
<img src="/logo-agep.png" alt="AGEPoly" class="h-6 w-auto shrink-0 sm:h-7" />
<span class="text-primary truncate text-sm font-semibold sm:text-base">{{
$t('app.title')
}}</span>
<img
src="/cargobike-icon.png"
alt=""
aria-hidden="true"
class="hidden h-6 w-auto shrink-0 sm:block dark:invert"
/>
</RouterLink>
<div class="flex-1" />
<!-- Navigation, from md upwards -->
<nav class="hidden items-center gap-1 md:flex">
<Button
v-for="entry in nav"
:key="entry.name"
variant="ghost"
size="sm"
class="text-primary"
as-child
>
<RouterLink :to="{ name: entry.name }">{{ $t(entry.label) }}</RouterLink>
</Button>
</nav>
<!-- Theme -->
<Button
variant="outline"
size="icon-sm"
:aria-label="$t(isDark ? 'header.theme-light' : 'header.theme-dark')"
:title="$t(isDark ? 'header.theme-light' : 'header.theme-dark')"
@click="toggleTheme()"
>
<Sun v-if="isDark" class="size-4" />
<Moon v-else class="size-4" />
</Button>
<!-- Language -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" :aria-label="$t('header.language')">
<span aria-hidden="true">{{ flags[currentLocale as Locale] }}</span>
<span class="hidden uppercase sm:inline">{{ currentLocale }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="locale in locales"
:key="locale.lang"
@click="setLocale(locale.lang)"
>
<span aria-hidden="true">{{ flags[locale.lang] }}</span>
<span class="uppercase">{{ locale.lang }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Session, from md upwards -->
<DropdownMenu v-if="isLoggedIn">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="hidden md:inline-flex">
<User class="size-4" />
<span class="max-w-32 truncate">{{ user?.firstname }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel class="font-normal">
<span class="block truncate">{{ user?.firstname }} {{ user?.name }}</span>
<span class="text-muted-foreground block truncate text-xs">{{ user?.email }}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="signOut()">
<LogOut class="size-4" />
{{ $t('header.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button v-else size="sm" class="hidden md:inline-flex" @click="login()">
{{ $t('header.login') }}
</Button>
<!-- Everything else, below md -->
<Sheet v-model:open="menuOpen">
<SheetTrigger as-child>
<Button
variant="outline"
size="icon-sm"
class="md:hidden"
:aria-label="$t('header.menu')"
>
<Menu class="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="right" class="w-64">
<SheetHeader>
<SheetTitle>{{ $t('header.menu') }}</SheetTitle>
</SheetHeader>
<nav class="flex flex-col gap-1 px-4">
<Button
v-for="entry in nav"
:key="entry.name"
variant="ghost"
class="justify-start"
as-child
@click="menuOpen = false"
>
<RouterLink :to="{ name: entry.name }">{{ $t(entry.label) }}</RouterLink>
</Button>
<template v-if="isLoggedIn">
<p class="text-muted-foreground mt-2 truncate px-3 text-xs">
{{ user?.firstname }} {{ user?.name }}
</p>
<Button variant="outline" class="mt-1 justify-start" @click="signOut()">
<LogOut class="size-4" />
{{ $t('header.logout') }}
</Button>
</template>
<Button v-else class="mt-2" @click="login()">{{ $t('header.login') }}</Button>
</nav>
</SheetContent>
</Sheet>
</div>
</header>
</template>

View file

@ -1,105 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Home } from '@lucide/vue'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { locales, setLocale } from '@/services/i18n'
const { t, locale: currentLocale } = useI18n()
const router = useRouter()
const { isMobile, open, setOpen } = useSidebar()
// Add your routes here
const menu = [
{
title: t('sidebar.navigation'),
content: [{ title: t('sidebar.home'), icon: Home, name: 'home' }],
},
]
const selectedLangFlag = computed(
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
)
function navigate(name: string) {
setOpen(false)
router.push({ name })
}
</script>
<template>
<Sidebar @click="setOpen(true)" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" @click.stop="navigate('home')">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<Home class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ $t('app.title') }}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger class="w-fit" as-child>
<SidebarMenuButton @click.stop class="w-[3em] justify-center">
<span :class="'fi fi-' + selectedLangFlag"></span>
</SidebarMenuButton>
<DropdownMenuContent class="w-[3em]" :side="isMobile || open ? 'bottom' : 'right'">
<DropdownMenuItem
v-for="locale in locales"
:key="locale.lang"
@click="setLocale(locale.lang)"
class="justify-center"
>
<span :class="'fi fi-' + locale.flag + ' w-fit'"></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuTrigger>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup v-for="menuGroup in menu" :key="menuGroup.title">
<SidebarGroupLabel>{{ menuGroup.title }}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="menuEntry in menuGroup.content" :key="menuEntry.title">
<SidebarMenuButton asChild>
<a @click.stop="navigate(menuEntry.name)" class="cursor-pointer">
<component :is="menuEntry.icon" />
<span>{{ menuEntry.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Calendar as CalendarIcon } from '@lucide/vue'
import { DateFormatter, type DateValue } from '@internationalized/date'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const model = defineModel<DateValue | undefined>()
defineProps<{ id?: string; minValue?: DateValue; invalid?: boolean }>()
const { locale } = useI18n()
// The popover is controlled so that picking a day closes it
const open = ref(false)
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
const formatter = computed(() => new DateFormatter(intlLocale.value, { dateStyle: 'medium' }))
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
:id="id"
type="button"
variant="outline"
:aria-invalid="invalid || undefined"
class="w-full justify-start px-3 font-normal"
:class="model ? '' : 'text-muted-foreground'"
>
<CalendarIcon class="size-4 shrink-0 opacity-60" />
<span class="truncate">
{{ model ? formatter.format(model.toDate('UTC')) : $t('reservation.pick-date') }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar
v-model="model"
:locale="intlLocale"
:min-value="minValue"
initial-focus
@update:model-value="open = false"
/>
</PopoverContent>
</Popover>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { Clock } from '@lucide/vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const model = defineModel<string | undefined>()
defineProps<{ id?: string; invalid?: boolean }>()
const SLOT_MINUTES = 15
const slots = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => {
const minutes = i * SLOT_MINUTES
const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
const mm = String(minutes % 60).padStart(2, '0')
return `${hh}:${mm}`
})
</script>
<template>
<Select v-model="model">
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3">
<span class="flex min-w-0 items-center gap-2">
<Clock class="size-4 shrink-0 opacity-60" />
<SelectValue :placeholder="$t('reservation.pick-time')" />
</span>
</SelectTrigger>
<SelectContent class="max-h-64">
<SelectItem v-for="slot in slots" :key="slot" :value="slot">{{ slot }}</SelectItem>
</SelectContent>
</Select>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '@/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View file

@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>

View file

@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton,
} from '.'
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot v-slot="{ grid, weekDays }" :class="cn('p-3', props.class)" v-bind="forwarded">
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="mt-2 w-full"
>
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View file

@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { CalendarCellProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarCell, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="
cn(
'relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View file

@ -0,0 +1,38 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarCellTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="
cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarGrid, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from 'reka-ui'
import { CalendarGridBody } from 'reka-ui'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from 'reka-ui'
import { CalendarGridHead } from 'reka-ui'
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarGridRow, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeadCell, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell
:class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeadCell>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeader, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader
:class="cn('relative flex w-full items-center justify-between pt-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeader>
</template>

View file

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from 'reka-ui'
import type { HTMLAttributes, VNode } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeading, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
defineSlots<{
default: (props: { headingValue: string }) => VNode[]
}>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View file

@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { CalendarNextProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from '@lucide/vue'
import { CalendarNext, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View file

@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeft } from '@lucide/vue'
import { CalendarPrev, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View file

@ -0,0 +1,12 @@
export { default as Calendar } from './Calendar.vue'
export { default as CalendarCell } from './CalendarCell.vue'
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
export { default as CalendarGrid } from './CalendarGrid.vue'
export { default as CalendarGridBody } from './CalendarGridBody.vue'
export { default as CalendarGridHead } from './CalendarGridHead.vue'
export { default as CalendarGridRow } from './CalendarGridRow.vue'
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
export { default as CalendarHeader } from './CalendarHeader.vue'
export { default as CalendarHeading } from './CalendarHeading.vue'
export { default as CalendarNextButton } from './CalendarNextButton.vue'
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot v-bind="forwarded">
<slot />
</PopoverRoot>
</template>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger v-bind="props">
<slot />
</PopoverTrigger>
</template>

View file

@ -0,0 +1,3 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
<slot />
</SelectGroup>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from '@lucide/vue'
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText v-bind="props">
<slot />
</SelectItemText>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel :class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)">
<slot />
</SelectLabel>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from '@lucide/vue'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDown class="h-4 w-4" />
</slot>
</SelectScrollDownButton>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUp } from '@lucide/vue'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUp class="h-4 w-4" />
</slot>
</SelectScrollUpButton>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from '@lucide/vue'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
v-bind="forwardedProps"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
</SelectIcon>
</SelectTrigger>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View file

@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'

View file

@ -4,50 +4,394 @@
*/
export interface paths {
'/api/version': {
"/api/version": {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get app version */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown
}
[name: string]: unknown;
};
content: {
'application/json': string
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
"application/json": string;
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get the logged in user
* @description Answers `null` when nobody is logged in.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"] | null;
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Log the user out */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description no content */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/whiskey/authorize": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Whiskey - Get the authorization url */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GetAuthorizeResponse"];
};
};
/** @description no content */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/whiskey/callback": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Whiskey - Complete the login */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PostCallbackParams"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
};
};
/** @description no content */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description no content */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Log in as a dev user
* @description Debug builds only. Takes the email of one of the `dev_users` of the configuration, creates the row if needed, and opens a session.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginDevForm"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
};
};
/** @description no content */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/login/dev-users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the dev users
* @description Debug builds only.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DevUser"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/bikes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get the fleet */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Bike"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>
export type webhooks = Record<string, never>;
export interface components {
schemas: never
responses: never
parameters: never
requestBodies: never
headers: never
pathItems: never
schemas: {
Bike: {
battery?: string | null;
drivetrain?: string | null;
/** Format: int32 */
id: number;
key_number?: string | null;
/** Format: int32 */
key_quantity: number;
name: string;
status: components["schemas"]["BikeStatus"];
};
/** @enum {string} */
BikeStatus: "in_service" | "out_of_service";
DevUser: {
admin: boolean;
email: string;
firstname: string;
name: string;
};
GetAuthorizeResponse: {
redirect_to: string;
};
LoginDevForm: {
/** @description Email of one of the `dev_users` of the configuration */
user: string;
};
PostCallbackParams: {
code: string;
state: string;
};
Unit: {
/** Format: int32 */
id: number;
/** @description The Whiskey group name */
name: string;
};
User: {
admin: boolean;
email: string;
external_id?: string | null;
firstname: string;
/** Format: int32 */
id: number;
name: string;
oidc_sub: string;
units: components["schemas"]["Unit"][];
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>
export type operations = Record<string, never>
export type $defs = Record<string, never>;
export type operations = Record<string, never>;

View file

@ -1,9 +1,59 @@
locale: en
app:
title: CarGAGEP
sidebar:
navigation: Navigation
home: Home
home:
welcome: Welcome!
version: 'Backend version: {version}'
title: Cargobikes
header:
logout: Log out
logout-error: Unable to log out.
reserve: Book
calendar: Calendar
login: Log in
menu: Menu
language: Language
theme-dark: Switch to dark mode
theme-light: Switch to light mode
reservation:
title: Book a Cargobike
intro: >-
Fill in the information below to submit your reservation request.
For more information, see the wiki:
association: Association name
reason: Reason for the reservation
reason-placeholder: Why do you need the cargobike?
start: Start of the reservation
end: End of the reservation
pick-date: Pick a date
pick-time: Pick a time
bikes: Desired bike size
bikes-pick-period: >-
Please first pick a start and end date and time to see the available cargobikes.
bikes-empty: No cargobike available for this period.
bikes-error: Unable to load the cargobikes.
bike-out-of-service: Out of service
telegram: Telegram username
emails: Email addresses of the Linka Go accounts to authorize
email-nth: 'Email address {n}'
add-email: Add an email
remove-email: Remove this address
submit: Send the request
reset: Reset
error-required: This field is required.
error-datetime-required: Pick a date and a time.
error-end-before-start: The end must be after the start.
error-no-bike: Select at least one cargobike.
# '@' starts a linked message in vue-i18n, so it has to be escaped
error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
error-email: One of the email addresses is invalid.
error-form: The form contains errors.
not-implemented: Submitting is not wired to the backend yet.
login:
title: Log in
intro: Log in with your AGEPoly account to book a cargobike.
with-whiskey: Log in with AGEPoly
already: You are already logged in.
finishing: Finishing the login…
dev: Development login
not-allowed: Your account is not allowed to use this application.
error: The login failed.
calendar:
title: Calendar
todo: This page is not built yet.

View file

@ -1,9 +1,60 @@
locale: fr
app:
title: CarGAGEP
sidebar:
navigation: Navigation
home: Accueil
home:
welcome: Bienvenue !
version: 'Version du backend : {version}'
title: Cargobikes
header:
logout: Se déconnecter
logout-error: Impossible de se déconnecter.
reserve: Réserver
calendar: Calendrier
login: Se connecter
menu: Menu
language: Langue
theme-dark: Passer en mode sombre
theme-light: Passer en mode clair
reservation:
title: Réserver un Cargobike
intro: >-
Remplissez les informations ci-dessous pour soumettre votre demande de réservation.
Pour plus d'informations, consultez le wiki :
association: Nom de l'association
reason: Raison de la réservation
reason-placeholder: Pourquoi avez-vous besoin du cargobike ?
start: Début de la réservation
end: Fin de la réservation
pick-date: Choisir une date
pick-time: Choisir une heure
bikes: Taille de vélo souhaitée
bikes-pick-period: >-
Veuillez d'abord choisir une date et une heure de début et de fin pour voir les
cargobikes disponibles.
bikes-empty: Aucun cargobike disponible pour ce créneau.
bikes-error: Impossible de charger les cargobikes.
bike-out-of-service: Hors service
telegram: Username Telegram
emails: Adresses mail du/des comptes Linka Go à autoriser
email-nth: 'Adresse e-mail {n}'
add-email: Ajouter un e-mail
remove-email: Retirer cette adresse
submit: Envoyer la demande
reset: Réinitialiser
error-required: Ce champ est obligatoire.
error-datetime-required: Choisissez une date et une heure.
error-end-before-start: La fin doit être après le début.
error-no-bike: Sélectionnez au moins un cargobike.
# '@' starts a linked message in vue-i18n, so it has to be escaped
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
error-email: Une des adresses e-mail est invalide.
error-form: Le formulaire contient des erreurs.
not-implemented: L'envoi n'est pas encore branché sur le backend.
login:
title: Connexion
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
with-whiskey: Se connecter avec AGEPoly
already: Vous êtes déjà connecté.
finishing: Connexion en cours…
dev: Connexion de développement
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
error: La connexion a échoué.
calendar:
title: Calendrier
todo: Cette page n'est pas encore construite.

View file

@ -1,13 +1,18 @@
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
import router from './router'
import { i18nInstance } from './services/i18n'
import { setQueryClient } from './services/api/client'
const queryClient = new QueryClient()
// The api client needs it to clear the session cache on a 401
setQueryClient(queryClient)
const app = createApp(App)
app.use(i18nInstance)
app.use(router)
app.use(VueQueryPlugin)
app.use(VueQueryPlugin, { queryClient })
app.mount('#app')

View file

@ -1,10 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import ReservationView from '@/views/ReservationView.vue'
import CalendarView from '@/views/CalendarView.vue'
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [{ name: 'home', path: '/', component: HomeView }],
routes: [
{ name: 'login', path: '/', component: LoginView },
{ name: 'reservations', path: '/reservations', component: ReservationView },
{ name: 'calendar', path: '/calendar', component: CalendarView },
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
],
})
export default router

View file

@ -0,0 +1,106 @@
/**
* Session.
*
* `useSession` is the single source of truth for "who is logged in". Every
* mutation writes its answer straight into that cache, and `client.ts` clears
* it on a 401, so the header and the views react without an extra round trip.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import { computed } from 'vue'
import type { User } from '@/utils/types'
import { getClient } from './client'
import { SESSION_KEY } from './keys'
export { SESSION_KEY }
/**
* `/api/me` is a probe, not a protected route: it answers 200 with `null` when
* nobody is logged in, so a page load never looks like an error.
*/
export function useSession() {
const query = useQuery({
queryKey: SESSION_KEY,
staleTime: Infinity,
retry: false,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/me')
if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`)
}
return data ?? null
},
})
return {
...query,
user: computed(() => query.data.value ?? null),
isLoggedIn: computed(() => !!query.data.value),
}
}
/**
* Step 1 of the Whiskey login: ask the backend where to send the browser, then
* leave the app. The provider comes back on /whiskey/callback.
*/
export async function getWhiskeyAuthorizationUrl(): Promise<string> {
const { data, response } = await getClient().GET('/api/whiskey/authorize')
if (response.status !== HttpStatus.OK || !data?.redirect_to) {
throw new Error(`authorize failed: ${response.status}`)
}
return data.redirect_to
}
/** Step 2: hand the code and the state back, which is what opens the session */
export function useWhiskeyCallbackMutation() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (form: { code: string; state: string }) => {
const { data } = await getClient().POST('/api/whiskey/callback', { body: form })
return data ?? null
},
onSuccess: (user) => queryClient.setQueryData<User | null>(SESSION_KEY, user),
})
}
export function useLogoutMutation() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async () => {
await getClient().POST('/api/logout')
},
onSuccess: () => queryClient.setQueryData<User | null>(SESSION_KEY, null),
})
}
// --- Development only -------------------------------------------------------
// These routes exist only in a debug build of the backend, and the calls are
// guarded by `import.meta.env.DEV` so they leave the production bundle.
export function useDevUsers() {
return useQuery({
queryKey: ['dev-users'],
enabled: import.meta.env.DEV,
retry: false,
staleTime: Infinity,
queryFn: async () => {
const { data } = await getClient().GET('/api/login/dev-users')
return data ?? []
},
})
}
export function useLoginDevMutation() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (form: { user: string }) => {
const { data } = await getClient().POST('/api/login', { body: form })
return data ?? null
},
onSuccess: (user) => queryClient.setQueryData<User | null>(SESSION_KEY, user),
})
}

View file

@ -0,0 +1,22 @@
/**
* The fleet. One file per domain area, exposing vue-query hooks: views never
* call `fetch` themselves.
*/
import { useQuery } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import { getClient } from './client'
export function useBikes() {
return useQuery({
queryKey: ['bikes'],
staleTime: 60 * 1000,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/bikes')
if (response.status === HttpStatus.OK) {
return data
}
throw new Error(`Unexpected status code received: ${response.status}`)
},
})
}

View file

@ -1,7 +1,18 @@
import createClient from 'openapi-fetch'
import { HttpStatus } from 'http-status-ts'
import type { QueryClient } from '@tanstack/vue-query'
import type { paths } from '@/lib/api'
import { Forbidden, Unauthorized } from '@/utils/types'
import { SESSION_KEY } from './keys'
// Registered by main.ts. The interceptor below needs it to drop the session
// from the cache as soon as the backend says the cookie is gone.
let queryClient: QueryClient | null = null
export function setQueryClient(client: QueryClient) {
queryClient = client
}
// Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
const client = createClient<paths>({
@ -17,6 +28,12 @@ client.use({
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
)
throw new Error('Server error')
} else if (response.status === HttpStatus.UNAUTHORIZED) {
// The session is gone: every view reading it must see that at once
queryClient?.setQueryData(SESSION_KEY, null)
throw new Unauthorized()
} else if (response.status === HttpStatus.FORBIDDEN) {
throw new Forbidden()
} else if (response.status === HttpStatus.NOT_FOUND) {
throw new Error(`Not found: ${response.url}`)
}

View file

@ -0,0 +1,6 @@
/**
* Query keys shared between the services and the response interceptor.
* Kept apart from `auth.ts` so that `client.ts` can use the session key without
* importing the service that imports it back.
*/
export const SESSION_KEY = ['session'] as const

View file

@ -0,0 +1,56 @@
/**
* Dark mode. Tailwind is configured with `@custom-variant dark (&:is(.dark *))`,
* so the whole theme is driven by a single `dark` class on <html>.
*
* Three states, like the rest of the web: an explicit choice is remembered in
* localStorage, and 'system' follows the OS preference live.
*/
import { ref, watchEffect } from 'vue'
export const themes = ['light', 'dark', 'system'] as const
export type Theme = (typeof themes)[number]
const STORAGE_KEY = 'theme'
function isTheme(value: unknown): value is Theme {
return themes.includes(value as Theme)
}
function fromLocalStorage(): Theme {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return isTheme(stored) ? stored : 'system'
} catch {
// Private mode, or site data blocked
return 'system'
}
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
export const theme = ref<Theme>(fromLocalStorage())
/** What is actually painted, once 'system' is resolved */
export const isDark = ref(false)
function apply() {
isDark.value = theme.value === 'dark' || (theme.value === 'system' && prefersDark.matches)
document.documentElement.classList.toggle('dark', isDark.value)
}
// Re-applies on every change of `theme`, and follows the OS while on 'system'
watchEffect(apply)
prefersDark.addEventListener('change', apply)
export function setTheme(newTheme: Theme) {
theme.value = newTheme
try {
window.localStorage.setItem(STORAGE_KEY, newTheme)
} catch {
// Nothing to do: the choice simply will not survive a reload
}
}
/** Toggles between light and dark, resolving 'system' to its opposite first */
export function toggleTheme() {
setTheme(isDark.value ? 'light' : 'dark')
}

View file

@ -50,7 +50,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@ -62,7 +62,7 @@
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: oklch(0.546 0.245 262.881);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@ -85,8 +85,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@ -97,7 +97,7 @@
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--ring: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);

View file

@ -1,7 +1,25 @@
import type { components } from '@/lib/api'
// Shorthands over the generated schemas, so views never import `@/lib/api` directly.
// Re-export a type here for every schema the views use, e.g.
// export type Bike = components['schemas']['Bike']
// once `src/api/` exposes the routes and `npm run openapi` has been run again.
export type Schemas = components['schemas']
/** The backend refused the request for lack of a session (401) */
export class Unauthorized extends Error {
constructor() {
super('Unauthorized')
this.name = 'Unauthorized'
Object.setPrototypeOf(this, Unauthorized.prototype)
}
}
/** Logged in, but not allowed to do this (403) */
export class Forbidden extends Error {
constructor() {
super('Forbidden')
this.name = 'Forbidden'
Object.setPrototypeOf(this, Forbidden.prototype)
}
}
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
export type Bike = components['schemas']['Bike']
export type BikeStatus = components['schemas']['BikeStatus']
export type Unit = components['schemas']['Unit']
export type User = components['schemas']['User']

View file

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
/**
* Placeholder: the header links here, and a dead link would be worse than an
* explicit "not built yet". Replace with the real availability calendar.
*/
</script>
<template>
<div class="mx-auto w-full max-w-2xl">
<h1 class="text-xl font-semibold">{{ $t('calendar.title') }}</h1>
<p class="text-muted-foreground mt-2 text-sm">{{ $t('calendar.todo') }}</p>
</div>
</template>

View file

@ -1,16 +0,0 @@
<script setup lang="ts">
/**
* Only page so far: shows the backend version.
* Add a view per route in `router/index.ts`.
*/
import { useVersion } from '@/services/api/misc'
const { data: version } = useVersion()
</script>
<template>
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
<p class="text-muted-foreground text-sm">{{ $t('home.version', { version }) }}</p>
</div>
</template>

View file

@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* Landing page. Its only job is to get the visitor logged in; once they are,
* the router sends them to /reservations.
*/
import { watchEffect } from 'vue'
import { LogIn } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
getWhiskeyAuthorizationUrl,
useDevUsers,
useLoginDevMutation,
useSession,
} from '@/services/api/auth'
const { t } = useI18n()
const router = useRouter()
const { isLoggedIn, isPending } = useSession()
const isDev = import.meta.env.DEV
const { data: devUsers } = useDevUsers()
const loginDevMutation = useLoginDevMutation()
// Nothing to do here once logged in
watchEffect(() => {
if (isLoggedIn.value) router.replace({ name: 'reservations' })
})
function loginOidc() {
getWhiskeyAuthorizationUrl()
.then((url) => {
window.location.href = url
})
.catch(() => toast.error(t('login.error')))
}
function loginAs(user: string) {
loginDevMutation.mutate(
{ user },
{
onSuccess: () => router.push({ name: 'reservations' }),
onError: () => toast.error(t('login.error')),
},
)
}
</script>
<template>
<div class="mx-auto w-full max-w-md">
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ $t('login.title') }}</CardTitle>
<CardDescription>{{ $t('login.intro') }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-4">
<Skeleton v-if="isPending" class="h-10 w-full" />
<p v-else-if="isLoggedIn" class="text-muted-foreground text-sm">
{{ $t('login.already') }}
</p>
<Button v-else @click="loginOidc()">
<LogIn class="size-4" />
{{ $t('login.with-whiskey') }}
</Button>
<!-- Development shortcut: the users listed under `dev_users` in config.yml -->
<div v-if="isDev && devUsers?.length" class="grid gap-2 border-t pt-4">
<p class="text-muted-foreground text-xs font-medium uppercase">
{{ $t('login.dev') }}
</p>
<Button
v-for="user in devUsers"
:key="user.email"
variant="outline"
class="justify-between"
:disabled="loginDevMutation.isPending.value"
@click="loginAs(user.email)"
>
<span class="truncate">{{ user.firstname }} {{ user.name }}</span>
<span v-if="user.admin" class="text-muted-foreground text-xs">admin</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</template>

View file

@ -0,0 +1,329 @@
<script setup lang="ts">
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, TriangleAlert, X } from '@lucide/vue'
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
import { toast } from 'vue-sonner'
import DatePicker from '@/components/DatePicker.vue'
import TimePicker from '@/components/TimePicker.vue'
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 { Textarea } from '@/components/ui/textarea'
import { useBikes } from '@/services/api/bikes'
import type { Bike } from '@/utils/types'
const { t } = useI18n()
const WIKI_URL = 'https://go.agepoly.ch/cargobikes'
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type Form = {
association: string
reason: string
startTime: string | undefined
endTime: string | undefined
bikes: number[]
telegram: string
emails: string[]
}
function emptyForm(): Form {
return {
association: '',
reason: '',
startTime: undefined,
endTime: undefined,
bikes: [],
telegram: '',
emails: [''],
}
}
const form = reactive<Form>(emptyForm())
const startDate = shallowRef<DateValue>()
const endDate = shallowRef<DateValue>()
const errors = reactive<Record<string, string>>({})
const submitted = ref(false)
const minDate = today(getLocalTimeZone())
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
if (!date || !time) return null
const [hours, minutes] = time.split(':').map(Number)
const local = date.toDate(getLocalTimeZone())
local.setHours(hours, minutes, 0, 0)
return local
}
const start = computed(() => toDate(startDate.value, form.startTime))
const end = computed(() => toDate(endDate.value, form.endTime))
const periodPicked = computed(() => start.value !== null && end.value !== null)
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
function isUnavailable(bike: Bike) {
return bike.status === 'out_of_service'
}
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
watch(availableBikes, (available) => {
const ids = new Set(available.map((bike) => bike.id))
form.bikes = form.bikes.filter((id) => ids.has(id))
})
function toggleBike(bike: Bike) {
if (isUnavailable(bike)) return
const index = form.bikes.indexOf(bike.id)
if (index >= 0) form.bikes.splice(index, 1)
else form.bikes.push(bike.id)
}
function addEmail() {
form.emails.push('')
}
function removeEmail(index: number) {
form.emails.splice(index, 1)
if (form.emails.length === 0) form.emails.push('')
}
function validate(): boolean {
Object.keys(errors).forEach((key) => delete errors[key])
if (!form.association.trim()) errors.association = t('reservation.error-required')
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
if (!start.value) errors.start = t('reservation.error-datetime-required')
if (!end.value) errors.end = t('reservation.error-datetime-required')
if (start.value && end.value && end.value <= start.value) {
errors.end = t('reservation.error-end-before-start')
}
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
if (!TELEGRAM_RE.test(form.telegram)) errors.telegram = t('reservation.error-telegram')
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
if (emails.length === 0) errors.emails = t('reservation.error-required')
else if (!emails.every((email) => EMAIL_RE.test(email))) {
errors.emails = t('reservation.error-email')
}
return Object.keys(errors).length === 0
}
function reset() {
Object.assign(form, emptyForm())
startDate.value = undefined
endDate.value = undefined
Object.keys(errors).forEach((key) => delete errors[key])
submitted.value = false
}
function submit() {
submitted.value = true
if (!validate()) {
toast.error(t('reservation.error-form'))
return
}
// TODO: replace with a `useCreateReservation` mutation once
// `POST /api/reservations` exists.
toast.info(t('reservation.not-implemented'))
}
</script>
<template>
<div class="mx-auto w-full max-w-2xl">
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ $t('reservation.title') }}</CardTitle>
<CardDescription>
{{ $t('reservation.intro') }}
<a
:href="WIKI_URL"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline underline-offset-2"
>
go.agepoly.ch/cargobikes
</a>
</CardDescription>
</CardHeader>
<CardContent>
<form class="grid gap-5" novalidate @submit.prevent="submit">
<!-- Association -->
<div class="grid gap-2">
<Label for="association">{{ $t('reservation.association') }}</Label>
<Input
id="association"
v-model.trim="form.association"
:placeholder="$t('reservation.association')"
:aria-invalid="!!errors.association || undefined"
/>
<p v-if="errors.association" class="text-destructive text-xs">
{{ errors.association }}
</p>
</div>
<!-- Reason -->
<div class="grid gap-2">
<Label for="reason">{{ $t('reservation.reason') }}</Label>
<Textarea
id="reason"
v-model.trim="form.reason"
:placeholder="$t('reservation.reason-placeholder')"
:aria-invalid="!!errors.reason || undefined"
rows="2"
/>
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
</div>
<!-- Start -->
<div class="grid gap-2">
<Label for="start-date">{{ $t('reservation.start') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="start-date"
v-model="startDate"
:min-value="minDate"
:invalid="!!errors.start"
/>
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
</div>
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
</div>
<!-- End -->
<div class="grid gap-2">
<Label for="end-date">{{ $t('reservation.end') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="end-date"
v-model="endDate"
:min-value="startDate ?? minDate"
:invalid="!!errors.end"
/>
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
</div>
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
</div>
<!-- Bikes -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.bikes') }}</span>
<div
v-if="!periodPicked"
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ $t('reservation.bikes-pick-period') }}
</div>
<div v-else-if="bikesPending" class="grid gap-2 sm:grid-cols-2">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</div>
<div
v-else-if="bikesError"
class="border-destructive/50 text-destructive flex items-center gap-2 rounded-md border p-3 text-sm"
>
<TriangleAlert class="size-4 shrink-0" />
{{ $t('reservation.bikes-error') }}
</div>
<p v-else-if="availableBikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bikes-empty') }}
</p>
<div v-else class="grid gap-2 sm:grid-cols-2">
<button
v-for="bike in bikes"
:key="bike.id"
type="button"
:disabled="isUnavailable(bike)"
:aria-pressed="form.bikes.includes(bike.id)"
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="
form.bikes.includes(bike.id)
? 'border-primary bg-primary/10 text-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
"
@click="toggleBike(bike)"
>
<span class="truncate">{{ bike.name }}</span>
<span
v-if="isUnavailable(bike)"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
>
{{ $t('reservation.bike-out-of-service') }}
</span>
</button>
</div>
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
</div>
<!-- Telegram -->
<div class="grid gap-2">
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
<Input
id="telegram"
v-model.trim="form.telegram"
placeholder="@username"
autocomplete="off"
:aria-invalid="!!errors.telegram || undefined"
/>
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
</div>
<!-- Linka Go emails -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.emails') }}</span>
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
<Input
v-model.trim="form.emails[index]"
type="email"
inputmode="email"
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
placeholder="prenom.nom@exemple.com"
:aria-invalid="!!errors.emails || undefined"
/>
<Button
v-if="form.emails.length > 1"
type="button"
variant="outline"
size="icon"
:aria-label="$t('reservation.remove-email')"
@click="removeEmail(index)"
>
<X class="size-4" />
</Button>
</div>
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
<div>
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
<Plus class="size-4" />
{{ $t('reservation.add-email') }}
</Button>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-2">
<Button type="submit">{{ $t('reservation.submit') }}</Button>
<Button type="button" variant="outline" @click="reset()">
{{ $t('reservation.reset') }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* Where Whiskey sends the browser back. The code and the state travel in the
* query string; they are handed to the backend, which verifies them and opens
* the session. Nothing is rendered for long.
*/
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { Skeleton } from '@/components/ui/skeleton'
import { Forbidden } from '@/utils/types'
import { useWhiskeyCallbackMutation } from '@/services/api/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const callback = useWhiskeyCallbackMutation()
onMounted(() => {
const code = route.query.code
const state = route.query.state
if (typeof code !== 'string' || typeof state !== 'string') {
toast.error(t('login.error'))
router.replace({ name: 'login' })
return
}
callback.mutate(
{ code, state },
{
onSuccess: () => router.replace({ name: 'reservations' }),
onError: (error) => {
toast.error(error instanceof Forbidden ? t('login.not-allowed') : t('login.error'))
router.replace({ name: 'login' })
},
},
)
})
</script>
<template>
<div class="mx-auto grid w-full max-w-md gap-3">
<p class="text-muted-foreground text-sm">{{ $t('login.finishing') }}</p>
<Skeleton class="h-10 w-full" />
</div>
</template>

View file

@ -1,3 +1,10 @@
//! Login, logout, and "who am I".
//!
//! The OIDC dance is driven by the browser: `authorize` hands back the provider
//! url, the frontend goes there, the provider sends the browser back to
//! `{base_url}/whiskey/callback` (a frontend route), and that page posts the
//! code and the state here.
use aide::{
NoApi,
axum::{
@ -10,20 +17,23 @@ use axum::{Json, debug_handler, http::StatusCode};
use axum_login::AuthSession;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info};
use tracing::{error, info};
use crate::{
api::helpers::unexpected_error,
core::{
controller::{AnonAppController, ControllerError, authn::AuthnControllerError},
controller::{
AnonAppController, AppController, ControllerError, authn::AuthnControllerError,
},
models::user::User,
},
};
pub fn routes() -> ApiRouter {
let mut r = ApiRouter::new()
#[allow(unused_mut)]
let mut router = ApiRouter::new()
.api_route("/api/me", get_with(me, me_docs))
.api_route("/api/logout", post_with(logout, logout_docs))
// .api_route("/api/me", get_with(me, me_docs))
.api_route(
"/api/whiskey/authorize",
get_with(whiskey_authorize, whiskey_authorize_docs),
@ -35,28 +45,40 @@ pub fn routes() -> ApiRouter {
#[cfg(debug_assertions)]
{
r = r.api_route("/api/login", post_with(login_dev, login_dev_docs))
router = router
.api_route("/api/login", post_with(login_dev, login_dev_docs))
.api_route("/api/login/dev-users", get_with(dev_users, dev_users_docs));
}
r
router
}
/// The user behind the current session, or `null` when there is none.
///
/// Deliberately takes the session rather than an `AppController`: this is the
/// probe the frontend runs on every page load, and "nobody is logged in" is a
/// normal answer, not an error. Every other protected route takes an
/// `AppController` and answers 401.
#[debug_handler]
async fn me(NoApi(auth_session): NoApi<AuthSession<AnonAppController>>) -> Json<Option<User>> {
Json(auth_session.user)
}
fn me_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth")
.summary("Get the logged in user")
.description("Answers `null` when nobody is logged in.")
}
/// Asking for an `AppController` *is* the "must be logged in" check: the
/// extractor answers 401 on its own, there is nothing to test here.
#[debug_handler]
async fn logout(
_ac: AppController,
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
) -> Result<(), StatusCode> {
debug!("[HANDLER] logout");
if auth_session.user.is_none() {
debug!("[HANDLER] logout failed: no active session");
return Err(StatusCode::UNAUTHORIZED);
}
match auth_session.logout().await {
Ok(_) => {
debug!("[HANDLER] logout successful");
Ok(())
}
Ok(_) => Ok(()),
Err(err) => {
error!("[HANDLER] logout failed: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
@ -65,10 +87,7 @@ async fn logout(
}
fn logout_docs(op: TransformOperation) -> TransformOperation {
op.summary("logout the user")
.tag("Auth")
.response::<401, ()>()
.security_requirement("session_cookie")
op.tag("Auth").summary("Log the user out").response::<401, ()>()
}
#[derive(Debug, JsonSchema, Serialize)]
@ -81,18 +100,18 @@ async fn whiskey_authorize(
aac: AnonAppController,
) -> Result<Json<GetAuthorizeResponse>, (StatusCode, String)> {
match aac.whiskey_authorize().await {
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => {
info!("[HANDLER] whiskey_authorize: protocol error");
Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned()))
}
Err(err) => unexpected_error("whiskey_authorize", err),
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
}
}
fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation {
op.summary("Whiskey - Get authorization URL")
.tag("Auth")
op.tag("Auth")
.summary("Whiskey - Get the authorization url")
.response::<400, ()>()
}
@ -110,20 +129,17 @@ async fn whiskey_callback(
) -> Result<Json<User>, (StatusCode, String)> {
match aac.whiskey_callback(code, state).await {
Ok(user) => {
let login_res = auth_session.login(&user).await;
if let Err(err) = login_res {
error!("[HANDLER] whiskey_callback failed to login the user: {err:?}");
if let Err(err) = auth_session.login(&user).await {
error!("[HANDLER] whiskey_callback: failed to open the session: {err:?}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected error".to_owned(),
));
}
Ok(Json(user.into()))
Ok(Json(user))
}
Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => {
info!("[HANDLER] whiskey_callback: user not authorized (missing group)");
info!("[HANDLER] whiskey_callback: user not authorized");
Err((
StatusCode::FORBIDDEN,
"You are not authorized to access this yet".to_owned(),
@ -138,8 +154,8 @@ async fn whiskey_callback(
}
fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
op.summary("Whiskey - Callback endpoint")
.tag("Auth")
op.tag("Auth")
.summary("Whiskey - Complete the login")
.response::<400, ()>()
.response::<403, ()>()
}
@ -147,34 +163,77 @@ fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
#[cfg(debug_assertions)]
#[derive(Debug, Deserialize, JsonSchema)]
struct LoginDevForm {
pub user: String,
/// Email of one of the `dev_users` of the configuration
user: String,
}
#[cfg(debug_assertions)]
#[debug_handler]
async fn login_dev(
aac: AnonAppController,
NoApi(mut auth): NoApi<AuthSession<AnonAppController>>,
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
Json(form): Json<LoginDevForm>,
) -> Result<(), StatusCode> {
) -> Result<Json<User>, (StatusCode, String)> {
match aac.get_dev_user(form.user).await {
Ok(user) => {
if let Err(err) = auth.login(&user).await {
error!("Login dev: failed to login the user: {err:?}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
if let Err(err) = auth_session.login(&user).await {
error!("[HANDLER] login_dev: failed to open the session: {err:?}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected error".to_owned(),
));
}
Ok(())
}
Err(err) => {
error!("Login dev: failed to get dev user: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
Ok(Json(user))
}
Err(ControllerError::Authn(AuthnControllerError::DevUserNotFound)) => Err((
StatusCode::NOT_FOUND,
"No such dev user in the configuration".to_owned(),
)),
Err(err) => unexpected_error("login_dev", err),
}
}
#[cfg(debug_assertions)]
fn login_dev_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth")
.summary("Login with a dev user")
.description("This function expect only the name of the user. It created the user in db if required and logins the user with that user.")
.summary("Log in as a dev user")
.description(
"Debug builds only. Takes the email of one of the `dev_users` of the \
configuration, creates the row if needed, and opens a session.",
)
.response::<404, ()>()
}
#[cfg(debug_assertions)]
#[derive(Debug, Serialize, JsonSchema)]
struct DevUser {
email: String,
firstname: String,
name: String,
admin: bool,
}
/// Lets the login page offer the dev users instead of asking for an email
#[cfg(debug_assertions)]
#[debug_handler]
async fn dev_users() -> Json<Vec<DevUser>> {
Json(
crate::utils::config::get()
.dev_users
.iter()
.map(|user| DevUser {
email: user.email.clone(),
firstname: user.firstname.clone(),
name: user.name.clone(),
admin: user.admin,
})
.collect(),
)
}
#[cfg(debug_assertions)]
fn dev_users_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth")
.summary("List the dev users")
.description("Debug builds only.")
}

68
src/api/bikes.rs Normal file
View file

@ -0,0 +1,68 @@
//! Read-only view of the fleet, used by the reservation form to show which
//! cargo bikes can be picked.
use aide::{
axum::{
ApiRouter,
routing::{get_with, put_with},
},
transform::TransformOperation,
};
use axum::{Json, extract::Path, http::StatusCode};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{admin, admin_desc, unexpected_error},
core::{
controller::{AnonAppController, AppController},
models::bike::{Bike, BikeId, BikeStatus},
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route("/", get_with(get_bikes, get_bikes_docs))
.api_route("/{id}/status", put_with(set_status, set_status_docs))
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SetStatusForm {
status: BikeStatus,
}
/// Takes a bike in or out of the fleet. Deleting is not offered: a bike that
/// has been booked must stay, for the history.
#[axum::debug_handler]
async fn set_status(
ac: AppController,
Path(id): Path<BikeId>,
Json(SetStatusForm { status }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> {
match admin(ac)?.set_bike_status(id, status).await {
Ok(()) => Ok(()),
Err(err) if err.is_not_found() => {
Err((StatusCode::NOT_FOUND, "No such bike".to_owned()))
}
Err(err) => unexpected_error("set_bike_status", err),
}
}
fn set_status_docs(op: TransformOperation) -> TransformOperation {
op.tag("Bikes")
.summary("Put a bike in or out of service")
.response_with::<403, (), _>(admin_desc)
.response::<404, ()>()
}
#[axum::debug_handler]
async fn get_bikes(aac: AnonAppController) -> Result<Json<Vec<Bike>>, (StatusCode, String)> {
match aac.get_bikes().await {
Ok(bikes) => Ok(Json(bikes)),
Err(err) => unexpected_error("get_bikes", err),
}
}
fn get_bikes_docs(op: TransformOperation) -> TransformOperation {
op.tag("Bikes").summary("Get the fleet")
}

View file

@ -15,6 +15,18 @@ use axum::{Extension, Json, response::IntoResponse, routing::get};
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
api.title(&format!("{} API documentation", env!("CRATE_NAME")))
.description(format!("Build version: {}", env!("GIT_HASH")).as_str())
.tag(Tag {
name: "Auth".to_owned(),
..Default::default()
})
.tag(Tag {
name: "Bikes".to_owned(),
..Default::default()
})
.tag(Tag {
name: "Reservations".to_owned(),
..Default::default()
})
.tag(Tag {
name: "misc".to_owned(),
..Default::default()

View file

@ -2,7 +2,42 @@ use aide::transform::TransformResponse;
use axum::http::StatusCode;
use tracing::error;
use crate::core::controller::ControllerError;
use crate::core::{
controller::{AdminAppController, AppController, ControllerError, ManagerAppController},
models::unit::UnitId,
};
/// Narrows a session down to "member of this unit", or 403.
/// `manager(ac, unit)?` in a handler is the whole authorization check.
pub fn manager(
ac: AppController,
unit: UnitId,
) -> Result<ManagerAppController, (StatusCode, String)> {
ac.try_into_manager(unit).map_err(|_| {
(
StatusCode::FORBIDDEN,
"You are not allowed to act for this unit".to_owned(),
)
})
}
pub fn manager_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
op.description("Forbidden - the user must be part of the unit")
}
/// Narrows a session down to "admin", or 403
pub fn admin(ac: AppController) -> Result<AdminAppController, (StatusCode, String)> {
ac.try_into_admin().map_err(|_| {
(
StatusCode::FORBIDDEN,
"You are not allowed to perform this action".to_owned(),
)
})
}
pub fn admin_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
op.description("Forbidden - the user must be an admin")
}
/// Last resort branch of a handler `match`: logs the error and answers 500
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
@ -14,7 +49,6 @@ pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (St
}
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
#[allow(dead_code)]
pub fn desc<T>(
description: &str,
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {

View file

@ -1,31 +1,60 @@
//! HTTP layer: routing and OpenAPI generation (aide).
//! HTTP layer: routing, session handling and OpenAPI generation (aide).
//!
//! Handlers stay thin: they extract the controller, call it, and map
//! `ControllerError` to a status code. No business logic here.
//! Authorization is carried by the extractor a handler asks for:
//! - `AnonAppController` always succeeds;
//! - `AppController` resolves the session and answers **401** without one, so
//! a route cannot accidentally be left open.
//!
//! Handlers stay thin: extract the controller, call it, map `ControllerError`
//! to a status code. No business logic here.
use std::sync::Arc;
use aide::{
OperationInput, OperationOutput,
axum::{ApiRouter, routing::get_with},
openapi::OpenApi,
generate::GenContext,
openapi::{OpenApi, Operation, Response},
};
use axum::{
Extension, Json, Router,
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use axum_login::{AuthManagerLayerBuilder, AuthSession, tower_sessions::SessionManagerLayer};
use indexmap::IndexMap;
use tower_sessions::{Expiry, MemoryStore, cookie::SameSite, cookie::time::Duration};
use tracing::error;
use crate::core::controller::AppController;
use crate::{
core::controller::{AnonAppController, AppController},
utils,
};
mod auth;
mod bikes;
mod docs;
mod helpers;
mod reservations;
pub fn get_router(controller: AppController) -> Router {
pub fn get_router(aac: AnonAppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}"));
aide::generate::extract_schemas(true);
let config = utils::config::get();
// Sessions live in memory: everybody is logged out when the backend
// restarts. Swap the store for a persistent one if that becomes a problem.
let session_layer = SessionManagerLayer::new(MemoryStore::default())
// Over plain http in development the cookie cannot be `Secure`
.with_secure(config.get_base_url().starts_with("https://"))
// The provider sends the browser back with a top level navigation
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::minutes(
config.get_session_lifetime(),
)));
let auth_layer = AuthManagerLayerBuilder::new(aac.clone(), session_layer).build();
let mut api = OpenApi::default();
ApiRouter::new()
.api_route(
@ -35,14 +64,19 @@ pub fn get_router(controller: AppController) -> Router {
|op| op.tag("misc").summary("Get app version"),
),
)
// `auth` carries its own `/api/...` paths, so it is merged, not nested
.merge(auth::routes())
.nest_api_service("/api/bikes", bikes::routes())
.nest_api_service("/api/reservations", reservations::routes())
.nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(controller))
.layer(Extension(aac))
.layer(Extension(Arc::new(api)))
.layer(auth_layer)
}
/// Lets a handler take an `AppController` as an argument
impl<S> FromRequestParts<S> for AppController
/// Lets a handler take an `AnonAppController`: always available.
impl<S> FromRequestParts<S> for AnonAppController
where
S: Send + Sync,
{
@ -51,14 +85,65 @@ where
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AppController>()
.get::<AnonAppController>()
.cloned()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)
}
}
// The controller is not part of the request/response bodies: nothing to document
/// Lets a handler take an `AppController`, which requires a session: asking for
/// it *is* the authentication check.
impl<S> FromRequestParts<S> for AppController
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
use axum::RequestPartsExt;
let aac = parts
.extensions
.get::<AnonAppController>()
.cloned()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let session = parts
.extract::<AuthSession<AnonAppController>>()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let user = session.user.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(aac.auth(user))
}
}
// The controllers are not part of the request/response bodies, but asking for
// an `AppController` documents the 401 and the cookie requirement.
impl OperationOutput for AnonAppController {
type Inner = Self;
}
impl OperationInput for AnonAppController {}
impl OperationOutput for AppController {
type Inner = Self;
}
impl OperationInput for AppController {}
impl OperationInput for AppController {
fn inferred_early_responses(
_: &mut GenContext,
op: &mut Operation,
) -> Vec<(Option<u16>, Response)> {
let mut session_cookie = IndexMap::new();
session_cookie.insert("session_cookie".to_owned(), Vec::new());
op.security = vec![session_cookie];
vec![(
Some(401),
Response {
description: "Unauthenticated - a session is required".to_owned(),
content: IndexMap::new(),
..Default::default()
},
)]
}
}

94
src/api/reservations.rs Normal file
View file

@ -0,0 +1,94 @@
//! Reservations, from the administration side.
//!
//! Reading the whole list is an admin action for now; changing a status is
//! reserved to the unit the reservation belongs to (an admin manages every
//! unit), which is why the handler resolves the unit before narrowing the
//! controller down.
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use axum::{
Json,
extract::Path,
http::StatusCode,
};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{admin, admin_desc, manager, manager_desc, unexpected_error},
core::{
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
models::reservation::{Reservation, ReservationId, ReservationStatus},
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route("/", get_with(get_reservations, get_reservations_docs))
.api_route(
"/{id}/status",
aide::axum::routing::put_with(set_status, set_status_docs),
)
}
#[axum::debug_handler]
async fn get_reservations(
ac: AppController,
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
match admin(ac)?.get_reservations().await {
Ok(reservations) => Ok(Json(reservations)),
Err(err) => unexpected_error("get_reservations", err),
}
}
fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Get every reservation")
.response_with::<403, (), _>(admin_desc)
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SetStatusForm {
status: ReservationStatus,
}
#[axum::debug_handler]
async fn set_status(
ac: AppController,
Path(id): Path<ReservationId>,
Json(SetStatusForm { status }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> {
// The unit is not in the body: it is the reservation's own
let reservation = match ac.get_reservation(id).await {
Ok(reservation) => reservation,
Err(err) if err.is_not_found() => {
return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned()));
}
Err(err) => return unexpected_error("set_status", err),
};
match manager(ac, reservation.unit.id)?
.set_reservation_status(id, status)
.await
{
Ok(()) => Ok(()),
Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => {
Err((StatusCode::CONFLICT, err.to_string()))
}
Err(err) => unexpected_error("set_status", err),
}
}
fn set_status_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Move a reservation through its state machine")
.description(
"Refuses a transition the state machine does not allow, with a 409.",
)
.response_with::<403, (), _>(manager_desc)
.response::<404, ()>()
.response::<409, ()>()
}

View file

@ -1,28 +1,42 @@
//! Authentication.
//!
//! Two ways in, both ending on the same `User` row and the same session:
//! - Whiskey (OIDC), the real one;
//! - a user taken from `dev_users` in the configuration, debug builds only.
//!
//! `AppController` is also the `axum-login` backend: the session stores a user
//! id, and `get_user` loads it back on every request.
use axum_login::{AuthUser, AuthnBackend};
use thiserror::Error;
use tracing::debug;
use crate::{
core::{
controller::{AnonAppController, ControllerError},
models::user::{User, UserNoId},
models::user::{NewUser, User, UserId},
},
utils::whiskey::{WhiskeyError, authorize, callback},
utils::whiskey::{UserInfoData, WhiskeyError, authorize, callback},
};
impl AuthUser for User {
type Id = i32;
type Id = UserId;
fn id(&self) -> Self::Id {
self.id
}
/// Invalidates the sessions of a user whose provider identity changed
fn session_auth_hash(&self) -> &[u8] {
&self.oidc_sub.as_bytes()
self.oidc_sub.as_bytes()
}
}
impl AuthnBackend for AnonAppController {
type User = User;
/// Login always goes through `whiskey_callback` or `get_dev_user`, which
/// hand a `User` straight to `AuthSession::login`. There are no credentials
/// to verify here.
type Credentials = ();
type Error = ControllerError;
@ -37,94 +51,139 @@ impl AuthnBackend for AnonAppController {
&self,
user_id: &axum_login::UserId<Self>,
) -> Result<Option<Self::User>, Self::Error> {
Ok(Some(self.db.get_user(user_id.clone()).await?))
match self.db.get_user(*user_id).await {
Ok(user) => Ok(Some(user)),
// The session outlived the user: treat it as logged out
Err(crate::core::repositories::RepositoryError::NotFound(_)) => Ok(None),
Err(err) => Err(err.into()),
}
}
}
impl super::AnonAppController {
impl AnonAppController {
/// Step 1 of the login: builds the provider url the browser must go to, and
/// remembers the pkce verifier and the nonce under the csrf token.
pub async fn whiskey_authorize(&self) -> Result<String, ControllerError> {
// Cheap enough to piggyback on the login, and keeps the table small
if let Ok(count) = self.db.delete_expired_whiskey_data().await
&& count > 0
{
debug!("swept {count} expired oidc states");
}
match authorize().await {
Ok((redirect_to, authorize_backend_data)) => {
self.db.save_whiskey_data(authorize_backend_data).await?;
Ok(redirect_to)
}
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
AuthnControllerError::OIDCProtocolError,
)),
Err(WhiskeyError::ProtocolError) => {
Err(AuthnControllerError::OIDCProtocolError.into())
}
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"Originated from Whiskey".to_string(),
"Originated from Whiskey".to_owned(),
)),
}
}
async fn get_or_create_user_oidc(
&self,
sciper: String,
firstname: String,
name: String,
sub: String,
email: String,
) -> Result<User, ControllerError> {
self.db
.upsert_user(UserNoId {
external_id: Some(sciper),
firstname,
name,
email,
oidc_sub: sub,
units: vec![], //TODO use real units
admin: false,
})
.await
.map_err(Into::into)
}
/// Step 2: the browser comes back with a code, we exchange it and upsert
/// the user the provider describes.
pub async fn whiskey_callback(
&self,
code: String,
state: String,
) -> Result<User, ControllerError> {
let backend_data = self.db.get_whiskey_data(state.clone()).await?;
// Consumes the state: a callback can never be replayed
let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| {
debug!("unknown, already used or expired oidc state");
AuthnControllerError::OIDCProtocolError
})?;
match callback(code, state, backend_data).await {
Ok(user_info) => {
self.get_or_create_user_oidc(
user_info.sciper,
user_info.firstname,
user_info.name,
user_info.sub,
user_info.email,
)
.await
Ok(user_info) => self.upsert_oidc_user(user_info).await,
Err(WhiskeyError::ProtocolError) => {
Err(AuthnControllerError::OIDCProtocolError.into())
}
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
AuthnControllerError::OIDCProtocolError,
)),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"originated from Whiskey".to_string(),
"originated from Whiskey".to_owned(),
)),
}
}
#[cfg(debug_assertions)]
pub async fn get_dev_user(&self, username: String) -> Result<User, ControllerError> {
use crate::core::repositories::RepositoryError;
async fn upsert_oidc_user(&self, info: UserInfoData) -> Result<User, ControllerError> {
let user = self
.db
.upsert_user(NewUser {
external_id: Some(info.sciper),
firstname: info.firstname,
name: info.name,
email: info.email,
oidc_sub: info.sub,
})
.await?;
let user = self.db.get_user_external_id(username.clone()).await;
let user = if let Err(RepositoryError::NotFound(_)) = user {
use crate::utils::config;
let user = config::get()
.get_dev_users()
.into_iter()
.find(|u| u.external_id.clone().is_some_and(|u| u == username))
.ok_or(AuthnControllerError::DevUserNotFound)?;
self.db.upsert_user(user).await?
} else {
user?
// Whiskey is authoritative on the units: a user removed from a group
// there must lose it here too. But only when it actually told us —
// `None` means the claim was absent, and wiping the units on that would
// silently strip everyone's access.
let Some(groups) = info.groups else {
debug!(
"no groups from Whiskey for user {}, units left untouched",
user.id
);
return Ok(user);
};
Ok(user)
// Resolves the group names to units, creating the ones we have never
// seen: a brand new group in Whiskey must not break the login.
let units = self.db.upsert_units(&groups).await?;
if units == user.units {
return Ok(user);
}
debug!(
"units of user {}: {:?} -> {:?}",
user.id,
user.units.iter().map(|u| &u.name).collect::<Vec<_>>(),
units.iter().map(|u| &u.name).collect::<Vec<_>>()
);
let ids = units.iter().map(|u| u.id).collect();
self.db.set_user_units(user.id, ids).await?;
self.db.get_user(user.id).await.map_err(Into::into)
}
/// Logs in one of the `dev_users` of the configuration, picked by email.
/// The row is created on first use, then kept in sync with the config.
#[cfg(debug_assertions)]
pub async fn get_dev_user(&self, email: String) -> Result<User, ControllerError> {
use crate::utils::config;
let dev_user = config::get()
.dev_users
.iter()
.find(|user| user.email == email)
.ok_or(AuthnControllerError::DevUserNotFound)?
.clone();
// Namespaced so a dev user can never collide with a real Whiskey subject
let user = self
.db
.upsert_user(NewUser {
external_id: dev_user.external_id.clone(),
firstname: dev_user.firstname.clone(),
name: dev_user.name.clone(),
email: dev_user.email.clone(),
oidc_sub: format!("dev:{}", dev_user.email),
})
.await?;
// Unlike the Whiskey path, the configuration is authoritative here
let units = self.db.upsert_units(&dev_user.units).await?;
let ids = units.iter().map(|u| u.id).collect();
self.db.set_user_units(user.id, ids).await?;
self.db.set_user_admin(user.id, dev_user.admin).await?;
self.db.get_user(user.id).await.map_err(Into::into)
}
}
@ -136,6 +195,6 @@ pub enum AuthnControllerError {
NotAuthenticated,
#[error("Not authorized")]
NotAuthorized,
#[error("Dev user does not exists")]
#[error("Dev user does not exist")]
DevUserNotFound,
}

View file

@ -1,11 +1,13 @@
use thiserror::Error;
use crate::core::{
controller::ControllerError,
controller::{AdminAppController, AnonAppController, ControllerError},
models::bike::{Bike, BikeId, BikeStatus, NewBike},
};
impl super::AppController {
/// Reading the fleet needs no session: the reservation form shows it before
/// anybody logs in.
impl AnonAppController {
pub async fn get_bikes(&self) -> Result<Vec<Bike>, ControllerError> {
self.db.get_bikes().await.map_err(Into::into)
}
@ -14,6 +16,10 @@ impl super::AppController {
self.db.get_bike(id).await.map_err(Into::into)
}
}
/// Changing the fleet is an admin action
impl AdminAppController {
pub async fn create_bike(&self, bike: NewBike) -> Result<Bike, ControllerError> {
if bike.key_quantity < 0 || bike.name.trim().is_empty() {
return Err(BikesControllerError::BikeInvalid.into());

View file

@ -1,32 +1,126 @@
//! The controller holds the business logic: it is the only place that knows the
//! rules of the app. It talks to the outside world through the repository traits,
//! so it depends neither on axum nor on sqlx.
//! rules of the app. It talks to the outside world through the repository
//! traits, so it depends neither on axum nor on sqlx.
//!
//! One file per domain area, each one adding methods to `AppController` through
//! `impl super::AppController`.
//! Authorization is carried by the **type**, not by a check inside the
//! handlers. Each level derefs into the one below, so a manager can do
//! everything a logged in user can:
//!
//! ```text
//! AnonAppController anybody, logged in or not
//! └─ AppController a logged in user (the api extractor answers 401 without a session)
//! ├─ ManagerAppController a member of one unit, for that unit
//! └─ AdminAppController an admin
//! ```
//!
//! A handler that takes an `AppController` cannot be reached anonymously: there
//! is no way to forget the check.
use std::sync::Arc;
use std::{ops::Deref, sync::Arc};
use thiserror::Error;
use crate::core::{
controller::{bikes::BikesControllerError, reservations::ReservationsControllerError},
controller::{
authn::AuthnControllerError, bikes::BikesControllerError,
reservations::ReservationsControllerError,
},
models::{unit::UnitId, user::User},
repositories::{DatabaseRepository, RepositoryError},
};
pub mod authn;
pub mod bikes;
pub mod reservations;
pub mod users;
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
/// Entry point of the business logic, for anybody. Cheap to clone: handlers get
/// one per request.
#[derive(Clone)]
pub struct AnonAppController {
pub(crate) db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
}
impl AnonAppController {
pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
Self { db }
}
/// Called by the api extractor once the session has been resolved
pub fn auth(self, user: User) -> AppController {
AppController { inner: self, user }
}
}
/// A logged in user.
#[derive(Clone)]
pub struct AppController {
db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
inner: AnonAppController,
user: User,
}
impl Deref for AppController {
type Target = AnonAppController;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl AppController {
pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
Self { db }
pub fn user(&self) -> &User {
&self.user
}
/// An admin manages every unit: refusing them here would only produce
/// surprising 403s on routes they are otherwise allowed to use.
pub fn try_into_manager(self, unit: UnitId) -> Result<ManagerAppController, ControllerError> {
if self.user.admin || self.user.units.iter().any(|u| u.id == unit) {
Ok(ManagerAppController { inner: self, unit })
} else {
Err(ControllerError::ManagerAuthorizationError(unit))
}
}
pub fn try_into_admin(self) -> Result<AdminAppController, ControllerError> {
if self.user.admin {
Ok(AdminAppController { inner: self })
} else {
Err(ControllerError::AdminAuthorizationError)
}
}
}
/// A member of `unit`, acting for that unit
#[derive(Clone)]
pub struct ManagerAppController {
inner: AppController,
pub(crate) unit: UnitId,
}
impl Deref for ManagerAppController {
type Target = AppController;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub struct AdminAppController {
inner: AppController,
}
impl Deref for AdminAppController {
type Target = AppController;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl AdminAppController {
pub fn into_manager(self, unit: UnitId) -> ManagerAppController {
ManagerAppController {
inner: self.inner,
unit,
}
}
}
@ -38,7 +132,15 @@ pub enum ControllerError {
InternalError(String),
#[error("Generic repository error")]
RepositoryError(#[from] RepositoryError),
#[error("Authorization denied: the user is not part of the unit {0:?}")]
ManagerAuthorizationError(UnitId),
#[error("Authorization denied: the user is not an admin")]
AdminAuthorizationError,
#[error("Object's unit modification is not allowed")]
ImmutableUnitModificationError,
#[error("Authentication error: {0}")]
Authn(#[from] AuthnControllerError),
#[error("Bike specific error: {0}")]
Bike(#[from] BikesControllerError),
#[error("Reservation specific error: {0}")]

View file

@ -1,7 +1,7 @@
use thiserror::Error;
use crate::core::{
controller::ControllerError,
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
models::{
bike::BikeStatus,
reservation::{
@ -11,7 +11,8 @@ use crate::core::{
},
};
impl super::AppController {
/// Reading the reservations needs no session: the calendar is public.
impl AnonAppController {
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
self.db.get_reservations().await.map_err(Into::into)
}
@ -30,6 +31,11 @@ impl super::AppController {
self.db.get_reservation(id).await.map_err(Into::into)
}
}
/// Filing a request is done in one's own name: the requester is the session
/// user, never something the client gets to choose.
impl AppController {
pub async fn create_reservation(
&self,
reservation: NewReservation,
@ -44,11 +50,14 @@ impl super::AppController {
}
}
self.db
.create_reservation(reservation)
.create_reservation(reservation, self.user().id)
.await
.map_err(Into::into)
}
}
/// Touching an existing reservation is reserved to its unit (or an admin)
impl ManagerAppController {
pub async fn update_reservation(
&self,
reservation: ReservationEdit,
@ -58,6 +67,9 @@ impl super::AppController {
}
let current = self.db.get_reservation(reservation.id).await?;
if current.unit.id != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
if current.status.is_final() {
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
}
@ -73,7 +85,11 @@ impl super::AppController {
id: ReservationId,
status: ReservationStatus,
) -> Result<(), ControllerError> {
let current = self.db.get_reservation(id).await?.status;
let current = self.db.get_reservation(id).await?;
if current.unit.id != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
let current = current.status;
if current == status {
return Ok(());
}
@ -87,6 +103,9 @@ impl super::AppController {
}
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
if self.db.get_reservation(id).await?.unit.id != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
self.db.delete_reservation(id).await.map_err(Into::into)
}
}

View file

@ -2,15 +2,11 @@
//! provider on login. The only decision that belongs to us is `admin`.
use crate::core::{
controller::ControllerError,
models::user::{NewUser, User, UserId},
controller::{AdminAppController, AnonAppController, ControllerError},
models::user::{User, UserId},
};
impl super::AppController {
pub async fn login(&self, user: NewUser) -> Result<User, ControllerError> {
self.db.upsert_user(user).await.map_err(Into::into)
}
impl AnonAppController {
pub async fn get_user(&self, id: UserId) -> Result<User, ControllerError> {
self.db.get_user(id).await.map_err(Into::into)
}
@ -26,6 +22,9 @@ impl super::AppController {
.map_err(Into::into)
}
}
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)
}

View file

@ -4,7 +4,11 @@ use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::{bike::BikeId, unit::UnitId, user::UserId};
use crate::core::models::{
bike::BikeId,
unit::{Unit, UnitId},
user::{UserId, UserSummary},
};
pub type ReservationId = i32;
@ -42,11 +46,11 @@ impl ReservationStatus {
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Reservation {
pub id: ReservationId,
pub unit: UnitId,
pub unit: Unit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester: UserId,
pub users: Vec<UserId>,
pub users: Vec<UserSummary>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
@ -58,7 +62,6 @@ pub struct NewReservation {
pub unit: UnitId,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester: UserId,
pub users: Vec<UserId>,
pub telegram: String,
pub description: String,
@ -79,9 +82,7 @@ pub struct ReservationEdit {
impl NewReservation {
pub fn is_valid(&self) -> bool {
self.end_time > self.start_time
&& !self.bikes.is_empty()
&& self.users.contains(&self.requester)
self.end_time > self.start_time && !self.bikes.is_empty()
}
}

View file

@ -1 +1,11 @@
pub type UnitId = String;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub type UnitId = i32;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct Unit {
pub id: UnitId,
/// The Whiskey group name
pub name: String,
}

View file

@ -1,7 +1,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::unit::UnitId;
use crate::core::models::unit::Unit;
pub type UserId = i32;
@ -13,7 +13,7 @@ pub struct User {
pub name: String,
pub email: String,
pub oidc_sub: String,
pub units: Vec<UnitId>,
pub units: Vec<Unit>,
pub admin: bool,
}
@ -24,5 +24,14 @@ pub struct NewUser {
pub name: String,
pub email: String,
pub oidc_sub: String,
pub units: Vec<UnitId>,
}
/// A user as they appear inside another object (a reservation, ...): enough to
/// show who they are, without dragging their units along.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct UserSummary {
pub id: UserId,
pub firstname: String,
pub name: String,
pub email: String,
}

View file

@ -8,18 +8,27 @@ use async_trait::async_trait;
use thiserror::Error;
use crate::core::repositories::{
bikes_repository::BikesRepository, reservations_repository::ReservationsRepository,
bikes_repository::BikesRepository, oidc_states_repository::OidcStatesRepository,
reservations_repository::ReservationsRepository, units_repository::UnitsRepository,
users_repository::UsersRepository,
};
pub mod bikes_repository;
pub mod oidc_states_repository;
pub mod reservations_repository;
pub mod units_repository;
pub mod users_repository;
/// Add every new repository trait here so the controller can use it through `db`
#[async_trait]
pub trait DatabaseRepository:
UsersRepository + BikesRepository + ReservationsRepository + Send + Sync
UsersRepository
+ BikesRepository
+ ReservationsRepository
+ UnitsRepository
+ OidcStatesRepository
+ Send
+ Sync
{
}

View file

@ -4,12 +4,19 @@ use crate::{core::repositories::RepositoryError, utils::whiskey};
#[async_trait]
pub trait OidcStatesRepository {
async fn get_whiskey_data(
/// Reads the state back **and consumes it**: an authorization state is
/// single use, otherwise the callback could be replayed. Returns
/// `NotFound` when the state is unknown, already used, or expired.
async fn take_whiskey_data(
&self,
csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError>;
async fn save_whiskey_data(
&self,
data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError>;
/// Drops the states nobody came back for
async fn delete_expired_whiskey_data(&self) -> Result<u64, RepositoryError>;
}

View file

@ -6,6 +6,7 @@ use crate::core::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
},
unit::UnitId,
user::UserId,
},
repositories::RepositoryError,
};
@ -19,9 +20,12 @@ pub trait ReservationsRepository {
) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
/// Always stored as `Requested`: the state machine starts here. The
/// requester comes from the session, not from the request body.
async fn create_reservation(
&self,
reservation: NewReservation,
requester: UserId,
) -> Result<Reservation, RepositoryError>;
async fn update_reservation(&self, reservation: ReservationEdit)

View file

@ -0,0 +1,12 @@
use async_trait::async_trait;
use crate::core::{models::unit::Unit, repositories::RepositoryError};
#[async_trait]
pub trait UnitsRepository {
async fn get_units(&self) -> Result<Vec<Unit>, RepositoryError>;
/// Resolves group names to units, creating the ones we have never seen.
/// This is what keeps a brand new Whiskey group from breaking a login.
async fn upsert_units(&self, names: &[String]) -> Result<Vec<Unit>, RepositoryError>;
}

View file

@ -1,7 +1,10 @@
use async_trait::async_trait;
use crate::core::{
models::user::{NewUser, User, UserId},
models::{
unit::UnitId,
user::{NewUser, User, UserId},
},
repositories::RepositoryError,
};
@ -12,7 +15,14 @@ pub trait UsersRepository {
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError>;
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
/// Creates the user, or refreshes the row from the provider claims.
/// `oidc_sub` is the identity. `admin` and the units are ours and are left
/// untouched, so a login never demotes anybody nor loses their units.
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
/// 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_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
}

View file

@ -9,7 +9,7 @@ use tower_http::services::{ServeDir, ServeFile};
use tracing::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use crate::{core::controller::AppController, services::database::SqlxDatabase};
use crate::{core::controller::AnonAppController, services::database::SqlxDatabase};
mod api;
mod core;
@ -24,7 +24,7 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(format!("info, {}=debug", env!("CRATE_NAME")))),
.unwrap_or_else(|_| EnvFilter::new(format!("info,{}=debug", env!("CRATE_NAME")))),
)
.init();
@ -32,15 +32,17 @@ async fn main() {
.await
.expect("Unable to connect to database");
let controller = AppController::new(Arc::new(Box::new(db)));
let aac = AnonAppController::new(Arc::new(Box::new(db)));
// Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path.
// (`fallback` and not `not_found_service`, which would force a 404 status)
let app =
api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
ServeFile::new(format!("{}/index.html", config.frontend_dir)),
));
let app = api::get_router(aac).fallback_service(
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
"{}/index.html",
config.frontend_dir
))),
);
let bind_address = config.get_bind_address();
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();

View file

@ -5,7 +5,9 @@
//! data must be present (`cargo sqlx prepare`).
mod bikes;
mod oidc_states;
mod reservations;
mod units;
mod users;
use async_trait::async_trait;

View file

@ -1,5 +1,11 @@
//! Short lived state of an in-flight OIDC authorization.
//!
//! The whole `AuthorizeBackendData` is stored as json under the csrf token: it
//! is opaque to the database, and nothing else ever reads it.
use async_trait::async_trait;
use sqlx::{prelude::FromRow, query, query_as};
use chrono::{Duration, Utc};
use sqlx::{query, query_as};
use crate::{
core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository},
@ -7,66 +13,66 @@ use crate::{
utils::whiskey,
};
#[derive(FromRow)]
struct DBOidcStateData {
/// A user has this long to come back from the provider
const STATE_LIFETIME_MINUTES: i64 = 15;
struct OidcStateDB {
key: String,
data: String,
}
impl TryFrom<whiskey::AuthorizeBackendData> for DBOidcStateData {
impl TryFrom<whiskey::AuthorizeBackendData> for OidcStateDB {
type Error = RepositoryError;
fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> {
Ok(DBOidcStateData {
Ok(OidcStateDB {
key: value.csrf_token(),
data: serde_json::to_string(&value)?,
})
}
}
impl TryInto<whiskey::AuthorizeBackendData> for DBOidcStateData {
impl TryFrom<OidcStateDB> for whiskey::AuthorizeBackendData {
type Error = RepositoryError;
fn try_into(self) -> Result<whiskey::AuthorizeBackendData, Self::Error> {
let value: whiskey::AuthorizeBackendData = serde_json::from_str(&self.data)?;
if value.csrf_token() != self.key {
fn try_from(value: OidcStateDB) -> Result<Self, Self::Error> {
let data: whiskey::AuthorizeBackendData = serde_json::from_str(&value.data)?;
if data.csrf_token() != value.key {
return Err(RepositoryError::TypeConversion(
"key of whiskey authorize backend data doesn't match".to_owned(),
));
}
Ok(value)
Ok(data)
}
}
#[async_trait]
impl OidcStatesRepository for SqlxDatabase {
async fn get_whiskey_data(
async fn take_whiskey_data(
&self,
csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError> {
let cutoff = Utc::now() - Duration::minutes(STATE_LIFETIME_MINUTES);
query_as!(
DBOidcStateData,
r#"SELECT
key,
data
FROM oidc_states
WHERE key = $1"#,
csrf_token
OidcStateDB,
r#"DELETE FROM oidc_states
WHERE key = $1 AND created_at > $2
RETURNING key, data"#,
csrf_token,
cutoff
)
.fetch_one(&self.pool)
.await?
.try_into()
}
// TODO: Expire the data and remove it periodically
async fn save_whiskey_data(
&self,
data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError> {
let data: DBOidcStateData = data.try_into()?;
let data: OidcStateDB = data.try_into()?;
query!(
r#"INSERT INTO oidc_states
(key, data)
r#"INSERT INTO oidc_states (key, data)
VALUES ($1, $2)"#,
data.key,
data.data
@ -75,4 +81,14 @@ impl OidcStatesRepository for SqlxDatabase {
.await?;
Ok(())
}
async fn delete_expired_whiskey_data(&self) -> Result<u64, RepositoryError> {
let cutoff = Utc::now() - Duration::minutes(STATE_LIFETIME_MINUTES);
Ok(
query!(r#"DELETE FROM oidc_states WHERE created_at <= $1"#, cutoff)
.execute(&self.pool)
.await?
.rows_affected(),
)
}
}

View file

@ -5,6 +5,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::{query, query_as};
use crate::{
@ -13,7 +14,8 @@ use crate::{
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
},
unit::UnitId,
unit::{Unit, UnitId},
user::UserId,
},
repositories::{RepositoryError, reservations_repository::ReservationsRepository},
},
@ -59,31 +61,37 @@ impl From<ReservationStatus> for ReservationStatusDB {
struct ReservationDB {
pub id: i32,
pub unit: String,
pub unit_id: i32,
pub unit_name: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester_id: i32,
pub telegram: String,
pub description: String,
pub status: ReservationStatusDB,
pub users: Vec<i32>,
pub users: Value,
pub bikes: Vec<i32>,
}
impl From<ReservationDB> for Reservation {
fn from(value: ReservationDB) -> Self {
Reservation {
impl TryFrom<ReservationDB> for Reservation {
type Error = RepositoryError;
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
Ok(Reservation {
id: value.id,
unit: value.unit,
unit: Unit {
id: value.unit_id,
name: value.unit_name,
},
start_time: value.start_time,
end_time: value.end_time,
requester: value.requester_id,
users: value.users,
users: serde_json::from_value(value.users)?,
telegram: value.telegram,
description: value.description,
bikes: value.bikes,
status: value.status.into(),
}
})
}
}
@ -135,29 +143,38 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.unit_id,
un."name" AS unit_name,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'firstname', u.firstname,
'name', u."name",
'email', u.email
) ORDER BY u."name", u.firstname)
FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
), '[]'::json) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
JOIN units un ON un.id = r.unit_id
ORDER BY r.start_time DESC"#
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(Into::into)
.collect())
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_unit_reservations(
@ -168,31 +185,40 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.unit_id,
un."name" AS unit_name,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'firstname', u.firstname,
'name', u."name",
'email', u.email
) ORDER BY u."name", u.firstname)
FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
), '[]'::json) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
WHERE r.unit = $1
JOIN units un ON un.id = r.unit_id
WHERE r.unit_id = $1
ORDER BY r.start_time DESC"#,
unit
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(Into::into)
.collect())
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
@ -200,46 +226,56 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB,
r#"SELECT
r.id,
r.unit,
r.unit_id,
un."name" AS unit_name,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.status AS "status: ReservationStatusDB",
ARRAY(
SELECT user_id FROM reservations_users
WHERE reservation_id = r.id ORDER BY user_id
) AS "users!",
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'firstname', u.firstname,
'name', u."name",
'email', u.email
) ORDER BY u."name", u.firstname)
FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
), '[]'::json) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
JOIN units un ON un.id = r.unit_id
WHERE r.id = $1"#,
id
)
.fetch_one(&self.pool)
.await?
.into())
.try_into()?)
}
async fn create_reservation(
&self,
reservation: NewReservation,
requester: UserId,
) -> Result<Reservation, RepositoryError> {
let mut tx = self.pool.begin().await?;
// No status here: the column defaults to 'requested', the start of the
// state machine.
let id = query!(
r#"INSERT INTO reservations (unit, start_time, end_time, requester_id, telegram, "description")
r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description")
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#,
reservation.unit,
reservation.start_time,
reservation.end_time,
reservation.requester,
requester,
reservation.telegram,
reservation.description
)
@ -247,22 +283,19 @@ impl ReservationsRepository for SqlxDatabase {
.await?
.id;
Self::set_reservation_links(&mut tx, id, &reservation.users, &reservation.bikes).await?;
// The requester is always allowed to pick the bikes up
let mut users = reservation.users.clone();
if !users.contains(&requester) {
users.push(requester);
}
Self::set_reservation_links(&mut tx, id, &users, &reservation.bikes).await?;
tx.commit().await?;
Ok(Reservation {
id,
unit: reservation.unit,
start_time: reservation.start_time,
end_time: reservation.end_time,
requester: reservation.requester,
users: reservation.users,
telegram: reservation.telegram,
description: reservation.description,
bikes: reservation.bikes,
status: ReservationStatus::Requested,
})
// Read it back through the normal query rather than rebuilding it by
// hand: the caller gets the unit and the users exactly as any other read
// would return them.
self.get_reservation(id).await
}
async fn update_reservation(
@ -273,7 +306,7 @@ impl ReservationsRepository for SqlxDatabase {
let result = query!(
r#"UPDATE reservations
SET unit = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
WHERE id = $1"#,
reservation.id,
reservation.unit,

View file

@ -0,0 +1,48 @@
//! Units. Rows are created on demand, from the group names Whiskey hands out.
use async_trait::async_trait;
use sqlx::{query, query_as};
use crate::{
core::{
models::unit::Unit,
repositories::{RepositoryError, units_repository::UnitsRepository},
},
services::database::SqlxDatabase,
};
#[async_trait]
impl UnitsRepository for SqlxDatabase {
async fn get_units(&self) -> Result<Vec<Unit>, RepositoryError> {
Ok(
query_as!(Unit, r#"SELECT id, "name" FROM units ORDER BY "name""#)
.fetch_all(&self.pool)
.await?,
)
}
async fn upsert_units(&self, names: &[String]) -> Result<Vec<Unit>, RepositoryError> {
let mut tx = self.pool.begin().await?;
query!(
r#"INSERT INTO units ("name")
SELECT DISTINCT UNNEST($1::text[])
ON CONFLICT ("name") DO NOTHING"#,
names
)
.execute(&mut *tx)
.await?;
// Read back after the insert: the ones that already existed are in here too
let units = query_as!(
Unit,
r#"SELECT id, "name" FROM units WHERE "name" = ANY($1::text[]) ORDER BY "name""#,
names
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(units)
}
}

View file

@ -4,7 +4,7 @@ use sqlx::{Executor, Postgres, query, query_as};
use crate::{
core::{
models::{
unit::UnitId,
unit::{Unit, UnitId},
user::{NewUser, User, UserId},
},
repositories::{RepositoryError, users_repository::UsersRepository},
@ -23,7 +23,7 @@ struct UserDB {
}
impl UserDB {
fn into_user(self, units: Vec<UnitIdDB>) -> User {
fn into_user(self, units: Vec<Unit>) -> User {
User {
id: self.id,
external_id: self.external_id,
@ -32,23 +32,23 @@ impl UserDB {
email: self.email,
oidc_sub: self.oidc_sub,
admin: self.admin,
units: units.into_iter().map(|u| u.name).collect(),
units,
}
}
}
struct UnitIdDB {
pub name: UnitId,
}
impl SqlxDatabase {
async fn user_with_units<'a, E>(user: UserDB, executor: E) -> Result<User, RepositoryError>
where
E: Executor<'a, Database = Postgres>,
{
let units = query_as!(
UnitIdDB,
r#"SELECT unit_name AS "name!" FROM units_users WHERE user_id = $1 ORDER BY unit_name"#,
Unit,
r#"SELECT u.id, u."name"
FROM units u
JOIN units_users uu ON uu.unit_id = u.id
WHERE uu.user_id = $1
ORDER BY u."name""#,
user.id
)
.fetch_all(executor)
@ -130,30 +130,27 @@ impl UsersRepository for SqlxDatabase {
.fetch_one(&mut *tx)
.await?;
query!(r#"DELETE FROM units_users WHERE user_id = $1"#, user_db.id)
let user = Self::user_with_units(user_db, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError> {
let mut tx = self.pool.begin().await?;
query!(r#"DELETE FROM units_users WHERE user_id = $1"#, id)
.execute(&mut *tx)
.await?;
query!(
r#"INSERT INTO units_users (user_id, unit_name)
SELECT $1, UNNEST($2::text[])"#,
user_db.id,
&user.units
r#"INSERT INTO units_users (user_id, unit_id)
SELECT $1, UNNEST($2::integer[])
ON CONFLICT DO NOTHING"#,
id,
&units
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(User {
id: user_db.id,
external_id: user_db.external_id,
firstname: user_db.firstname,
name: user_db.name,
email: user_db.email,
oidc_sub: user_db.oidc_sub,
units: user.units,
admin: user_db.admin,
})
Ok(())
}
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> {

View file

@ -7,6 +7,10 @@ use serde::Deserialize;
pub struct ServerConfig {
pub address: Option<String>,
pub port: Option<u16>,
/// Public origin the browser reaches the app on. The OIDC redirect uri is
/// built from it, so it must match what is registered on the provider.
/// In development this is the vite dev server, not the backend.
pub base_url: Option<String>,
}
#[derive(Deserialize, Clone)]
@ -18,10 +22,38 @@ pub struct PostgresConfig {
pub name: String,
}
/// Whiskey, the AGEPoly OIDC provider
#[derive(Deserialize, Clone)]
pub struct OidcConfig {
pub issuer_url: String,
pub client_id: String,
pub client_secret: String,
/// How long a session stays valid, in minutes
pub session_lifetime: Option<i64>,
}
/// A user that can be logged in without going through the provider.
/// Only usable in debug builds, see `POST /api/login`.
#[derive(Deserialize, Clone)]
pub struct DevUserConfig {
pub firstname: String,
pub name: String,
/// Also the handle used to pick the user at login
pub email: String,
pub external_id: Option<String>,
#[serde(default)]
pub units: Vec<String>,
#[serde(default)]
pub admin: bool,
}
#[derive(Deserialize, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
pub postgres: PostgresConfig,
pub oidc: OidcConfig,
#[serde(default)]
pub dev_users: Vec<DevUserConfig>,
/// Directory containing the built frontend
pub frontend_dir: String,
}
@ -33,6 +65,20 @@ impl AppConfig {
format!("{}:{}", address, port)
}
/// Origin used to build the OIDC redirect uri. Defaults to the vite dev
/// server, which is what a developer reaches the app on.
pub fn get_base_url(&self) -> String {
self.server
.base_url
.clone()
.unwrap_or("http://localhost:5000".to_owned())
}
/// Session lifetime in minutes
pub fn get_session_lifetime(&self) -> i64 {
self.oidc.session_lifetime.unwrap_or(60)
}
pub fn get_postgresql_url(&self) -> String {
let host = self.postgres.host.clone().unwrap_or("localhost".to_owned());
let port = self.postgres.port.unwrap_or(5432);

View file

@ -1 +1,2 @@
pub mod config;
pub mod whiskey;

View file

@ -40,7 +40,9 @@ async fn get_client() -> &'static Client {
CLIENT
.get_or_init(|| async {
let http_client = get_http_client();
let config = config::get().clone();
let config = config::get();
let redirect_url = format!("{}/whiskey/callback", config.get_base_url());
let config = config.clone();
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oidc.issuer_url).unwrap(),
http_client,
@ -54,7 +56,7 @@ async fn get_client() -> &'static Client {
Some(ClientSecret::new(config.oidc.client_secret)),
)
.set_redirect_uri(
RedirectUrl::new(format!("{}/whiskey/callback", config.server.base_url)).unwrap(),
RedirectUrl::new(redirect_url).unwrap(),
)
})
.await
@ -83,7 +85,6 @@ pub async fn authorize() -> Result<(String, AuthorizeBackendData), WhiskeyError>
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_owned()))
.add_scope(Scope::new("profile".to_owned()))
.add_scope(Scope::new("email".to_owned()))
.set_pkce_challenge(pkce_challenge)
@ -106,6 +107,7 @@ pub struct UserInfoData {
pub name: String,
pub firstname: String,
pub email: String,
pub groups: Option<Vec<String>>,
}
pub async fn callback(
@ -214,18 +216,56 @@ pub async fn callback(
let sciper = claims.additional_claims().sciper.clone();
let groups = match claims.additional_claims().groups.clone() {
Some(groups) => Some(groups),
None => {
debug!("no groups claim in the id_token, trying the userinfo endpoint");
groups_from_userinfo(client, token_response.access_token()).await
}
};
debug!("Whiskey groups for {sciper}: {groups:?}");
Ok(UserInfoData {
firstname,
name,
sub,
sciper,
email,
groups,
})
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
async fn groups_from_userinfo(
client: &Client,
access_token: &openidconnect::AccessToken,
) -> Option<Vec<String>> {
let request = match client.user_info(access_token.to_owned(), None) {
Ok(request) => request,
Err(err) => {
debug!("no userinfo endpoint advertised: {err:?}");
return None;
}
};
match request
.request_async(get_http_client())
.await
.map(|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
claims.additional_claims().groups.clone()
}) {
Ok(groups) => groups,
Err(err) => {
debug!("userinfo request failed: {err:?}");
None
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct WhiskeyClaims {
pub sciper: String,
#[serde(default)]
pub groups: Option<Vec<String>>,
}
impl openidconnect::AdditionalClaims for WhiskeyClaims {}
@ -259,3 +299,51 @@ pub type Client = openidconnect::Client<
openidconnect::EndpointMaybeSet,
openidconnect::EndpointMaybeSet,
>;
#[cfg(test)]
mod tests {
use super::WhiskeyClaims;
fn parse(json: &str) -> WhiskeyClaims {
serde_json::from_str(json).expect("claims should parse")
}
/// An absent claim must not be read as "belongs to no group": that would
/// wipe the units of every user at every login.
#[test]
fn absent_groups_claim_is_none() {
assert_eq!(parse(r#"{"sciper":"123456"}"#).groups, None);
}
#[test]
fn groups_are_read() {
assert_eq!(
parse(r#"{"sciper":"123456","groups":["agepoly","balelec"]}"#).groups,
Some(vec!["agepoly".to_owned(), "balelec".to_owned()])
);
}
/// Distinct from the absent case: here Whiskey did answer, and the answer
/// is that the user is in nothing.
#[test]
fn empty_groups_claim_is_some_empty() {
assert_eq!(
parse(r#"{"sciper":"123456","groups":[]}"#).groups,
Some(vec![])
);
}
/// The id_token carries plenty of claims we do not model
#[test]
fn unknown_claims_are_ignored() {
assert_eq!(
parse(r#"{"sciper":"123456","groups":["agepoly"],"uid":"x","other":42}"#).groups,
Some(vec!["agepoly".to_owned()])
);
}
#[test]
fn a_null_groups_claim_is_none() {
assert_eq!(parse(r#"{"sciper":"123456","groups":null}"#).groups, None);
}
}