Compare commits

..

No commits in common. "1ae4ba3a58c1b9a07bacbda52421b209a72f0abf" and "5169cad8ef4c9cab75e9c6079b064bebf7d59ee6" have entirely different histories.

99 changed files with 572 additions and 11579 deletions

View file

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

1189
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -17,17 +17,11 @@ routes and the frontend views are not written yet.
- Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui` - 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 - A single binary in production: the backend serves the built frontend
Users are mirrored from AGEPoly's OIDC provider (Whiskey): `users.oidc_sub` is the Authentication is **not wired yet**. Users are mirrored from AGEPoly's OIDC provider
identity, and a login upserts the row from the claims of the token. Whiskey calls the units (Whiskey): `users.oidc_sub` is the identity, and `AppController::login` upserts the row from
**groups** and sends them in the `groups` claim; each one is a row in `units`, shared by the claims of the token. Because the units also come from Whiskey, they are stored as plain
everybody who belongs to it, and the memberships are rewritten at every login — so a user text (`units_users.unit_name`, `reservations.unit`) rather than as an enum or a reference
removed from a group there loses it here too. A group we have never seen is created on the table: a unit unknown to us must never break a login.
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 ## Getting started
@ -96,27 +90,6 @@ 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 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`). 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 ### Adding an entity
1. `dbmate n create_things` and write the migration, then `dbmate up` 1. `dbmate n create_things` and write the migration, then `dbmate up`
@ -184,13 +157,10 @@ vue router keeps working on a page reload. Only one process to deploy.
## Data model ## Data model
``` ```
units ──< units_users >── users a unit (a Whiskey group) has many members, users ──< units_users a user belongs to several units (from Whiskey)
│ │ and a user belongs to many units │
│ ├──< reservations_users >── reservations ├──< reservations_users >── reservations ──< reservations_bikes >── bikes
│ └──── reservations.requester_id └──── 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 A reservation moves through a state machine, enforced in

View file

@ -0,0 +1,15 @@
# 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

@ -1,16 +0,0 @@
-- 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

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

View file

@ -8,47 +8,26 @@ 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) (3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Units are Whiskey's "groups". In real life the rows are created on the fly at -- Unit names come from Whiskey; these are placeholders for development
-- login; these are placeholders for development. INSERT INTO public.units_users (user_id, unit_name) VALUES
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'), (1, 'agepoly'),
(2, 'agepoly'), (2, 'agepoly'),
(2, 'clic'), (2, 'clic'),
(1, 'S4S'),
(1, 'Balélec'),
(3, 'clic') (3, 'clic')
) AS membership (user_id, unit_name)
JOIN public.units u ON u."name" = membership.unit_name
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), (1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'), (2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'), (3, 'Cargo 3', '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; ON CONFLICT DO NOTHING;
INSERT INTO public.reservations INSERT INTO public.reservations
(id, unit_id, start_time, end_time, requester_id, telegram, "description", status) (id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES
SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status (1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin',
FROM (VALUES 'Transport du matériel pour la rentrée', 'approved'),
(1, 'agepoly', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz, (2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre',
1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status), 'Déménagement du stock de la commission', 'requested')
(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; ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
@ -60,7 +39,6 @@ INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Keep the sequences in sync with the explicit ids inserted above -- 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.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.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)); 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" 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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cargobikes</title> <title>CarGAGEP</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,11 @@
<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

@ -1,185 +0,0 @@
<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

@ -0,0 +1,105 @@
<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

@ -1,50 +0,0 @@
<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

@ -1,36 +0,0 @@
<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

@ -1,17 +0,0 @@
<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

@ -1,14 +0,0 @@
<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

@ -1,14 +0,0 @@
<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

@ -1,24 +0,0 @@
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

@ -1,61 +0,0 @@
<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

@ -1,27 +0,0 @@
<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

@ -1,38 +0,0 @@
<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

@ -1,22 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

@ -1,19 +0,0 @@
<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

@ -1,22 +0,0 @@
<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

@ -1,22 +0,0 @@
<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

@ -1,29 +0,0 @@
<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

@ -1,32 +0,0 @@
<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

@ -1,32 +0,0 @@
<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

@ -1,12 +0,0 @@
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

@ -1,25 +0,0 @@
<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

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

View file

@ -1,15 +0,0 @@
<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

@ -1,40 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

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

View file

@ -1,15 +0,0 @@
<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

@ -1,54 +0,0 @@
<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

@ -1,17 +0,0 @@
<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

@ -1,36 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

@ -1,14 +0,0 @@
<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

@ -1,25 +0,0 @@
<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

@ -1,25 +0,0 @@
<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

@ -1,15 +0,0 @@
<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

@ -1,31 +0,0 @@
<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

@ -1,12 +0,0 @@
<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

@ -1,11 +0,0 @@
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,394 +4,50 @@
*/ */
export interface paths { export interface paths {
"/api/version": { '/api/version': {
parameters: { parameters: {
query?: never; query?: never
header?: never; header?: never
path?: never; path?: never
cookie?: never; cookie?: never
}; }
/** Get app version */ /** Get app version */
get: { get: {
parameters: { parameters: {
query?: never; query?: never
header?: never; header?: never
path?: never; path?: never
cookie?: never; cookie?: never
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"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>; requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': string
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: never
Bike: { responses: never
battery?: string | null; parameters: never
drivetrain?: string | null; requestBodies: never
/** Format: int32 */ headers: never
id: number; pathItems: never
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 $defs = Record<string, never>
export type operations = Record<string, never>; export type operations = Record<string, never>

View file

@ -1,59 +1,9 @@
locale: en locale: en
app: app:
title: Cargobikes title: CarGAGEP
header: sidebar:
logout: Log out navigation: Navigation
logout-error: Unable to log out. home: Home
reserve: Book home:
calendar: Calendar welcome: Welcome!
login: Log in version: 'Backend version: {version}'
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,60 +1,9 @@
locale: fr locale: fr
app: app:
title: Cargobikes title: CarGAGEP
header: sidebar:
logout: Se déconnecter navigation: Navigation
logout-error: Impossible de se déconnecter. home: Accueil
reserve: Réserver home:
calendar: Calendrier welcome: Bienvenue !
login: Se connecter version: 'Version du backend : {version}'
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,18 +1,13 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { i18nInstance } from './services/i18n' 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) const app = createApp(App)
app.use(i18nInstance) app.use(i18nInstance)
app.use(router) app.use(router)
app.use(VueQueryPlugin, { queryClient }) app.use(VueQueryPlugin)
app.mount('#app') app.mount('#app')

View file

@ -1,19 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/LoginView.vue' import HomeView from '@/views/HomeView.vue'
import ReservationView from '@/views/ReservationView.vue'
import CalendarView from '@/views/CalendarView.vue'
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [{ name: 'home', path: '/', component: HomeView }],
{ 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 export default router

View file

@ -1,106 +0,0 @@
/**
* 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

@ -1,22 +0,0 @@
/**
* 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,18 +1,7 @@
import createClient from 'openapi-fetch' import createClient from 'openapi-fetch'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
import type { QueryClient } from '@tanstack/vue-query'
import type { paths } from '@/lib/api' 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` // Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
const client = createClient<paths>({ const client = createClient<paths>({
@ -28,12 +17,6 @@ client.use({
`Server error from ${response.url}: ${response.status} ${response.statusText}`, `Server error from ${response.url}: ${response.status} ${response.statusText}`,
) )
throw new Error('Server error') 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) { } else if (response.status === HttpStatus.NOT_FOUND) {
throw new Error(`Not found: ${response.url}`) throw new Error(`Not found: ${response.url}`)
} }

View file

@ -1,6 +0,0 @@
/**
* 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

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

View file

@ -1,25 +1,7 @@
import type { components } from '@/lib/api' import type { components } from '@/lib/api'
/** The backend refused the request for lack of a session (401) */ // Shorthands over the generated schemas, so views never import `@/lib/api` directly.
export class Unauthorized extends Error { // Re-export a type here for every schema the views use, e.g.
constructor() { // export type Bike = components['schemas']['Bike']
super('Unauthorized') // once `src/api/` exposes the routes and `npm run openapi` has been run again.
this.name = 'Unauthorized' export type Schemas = components['schemas']
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

@ -1,13 +0,0 @@
<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

@ -0,0 +1,16 @@
<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

@ -1,93 +0,0 @@
<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

@ -1,329 +0,0 @@
<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

@ -1,49 +0,0 @@
<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,10 +1,3 @@
//! 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::{ use aide::{
NoApi, NoApi,
axum::{ axum::{
@ -17,23 +10,20 @@ use axum::{Json, debug_handler, http::StatusCode};
use axum_login::AuthSession; use axum_login::AuthSession;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, info}; use tracing::{debug, error, info};
use crate::{ use crate::{
api::helpers::unexpected_error, api::helpers::unexpected_error,
core::{ core::{
controller::{ controller::{AnonAppController, ControllerError, authn::AuthnControllerError},
AnonAppController, AppController, ControllerError, authn::AuthnControllerError,
},
models::user::User, models::user::User,
}, },
}; };
pub fn routes() -> ApiRouter { pub fn routes() -> ApiRouter {
#[allow(unused_mut)] let mut r = ApiRouter::new()
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/logout", post_with(logout, logout_docs))
// .api_route("/api/me", get_with(me, me_docs))
.api_route( .api_route(
"/api/whiskey/authorize", "/api/whiskey/authorize",
get_with(whiskey_authorize, whiskey_authorize_docs), get_with(whiskey_authorize, whiskey_authorize_docs),
@ -45,40 +35,28 @@ pub fn routes() -> ApiRouter {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
router = router r = r.api_route("/api/login", post_with(login_dev, login_dev_docs))
.api_route("/api/login", post_with(login_dev, login_dev_docs))
.api_route("/api/login/dev-users", get_with(dev_users, dev_users_docs));
} }
router r
} }
/// 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] #[debug_handler]
async fn logout( async fn logout(
_ac: AppController,
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>, NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
) -> Result<(), StatusCode> { ) -> 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 { match auth_session.logout().await {
Ok(_) => Ok(()), Ok(_) => {
debug!("[HANDLER] logout successful");
Ok(())
}
Err(err) => { Err(err) => {
error!("[HANDLER] logout failed: {err:?}"); error!("[HANDLER] logout failed: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR) Err(StatusCode::INTERNAL_SERVER_ERROR)
@ -87,7 +65,10 @@ async fn logout(
} }
fn logout_docs(op: TransformOperation) -> TransformOperation { fn logout_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth").summary("Log the user out").response::<401, ()>() op.summary("logout the user")
.tag("Auth")
.response::<401, ()>()
.security_requirement("session_cookie")
} }
#[derive(Debug, JsonSchema, Serialize)] #[derive(Debug, JsonSchema, Serialize)]
@ -100,18 +81,18 @@ async fn whiskey_authorize(
aac: AnonAppController, aac: AnonAppController,
) -> Result<Json<GetAuthorizeResponse>, (StatusCode, String)> { ) -> Result<Json<GetAuthorizeResponse>, (StatusCode, String)> {
match aac.whiskey_authorize().await { match aac.whiskey_authorize().await {
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => { Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => {
info!("[HANDLER] whiskey_authorize: protocol error"); info!("[HANDLER] whiskey_authorize: protocol error");
Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned())) Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned()))
} }
Err(err) => unexpected_error("whiskey_authorize", err), Err(err) => unexpected_error("whiskey_authorize", err),
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
} }
} }
fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation { fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth") op.summary("Whiskey - Get authorization URL")
.summary("Whiskey - Get the authorization url") .tag("Auth")
.response::<400, ()>() .response::<400, ()>()
} }
@ -129,17 +110,20 @@ async fn whiskey_callback(
) -> Result<Json<User>, (StatusCode, String)> { ) -> Result<Json<User>, (StatusCode, String)> {
match aac.whiskey_callback(code, state).await { match aac.whiskey_callback(code, state).await {
Ok(user) => { Ok(user) => {
if let Err(err) = auth_session.login(&user).await { let login_res = auth_session.login(&user).await;
error!("[HANDLER] whiskey_callback: failed to open the session: {err:?}");
if let Err(err) = login_res {
error!("[HANDLER] whiskey_callback failed to login the user: {err:?}");
return Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected error".to_owned(), "Unexpected error".to_owned(),
)); ));
} }
Ok(Json(user))
Ok(Json(user.into()))
} }
Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => { Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => {
info!("[HANDLER] whiskey_callback: user not authorized"); info!("[HANDLER] whiskey_callback: user not authorized (missing group)");
Err(( Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"You are not authorized to access this yet".to_owned(), "You are not authorized to access this yet".to_owned(),
@ -154,8 +138,8 @@ async fn whiskey_callback(
} }
fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation { fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth") op.summary("Whiskey - Callback endpoint")
.summary("Whiskey - Complete the login") .tag("Auth")
.response::<400, ()>() .response::<400, ()>()
.response::<403, ()>() .response::<403, ()>()
} }
@ -163,77 +147,34 @@ fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct LoginDevForm { struct LoginDevForm {
/// Email of one of the `dev_users` of the configuration pub user: String,
user: String,
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
#[debug_handler] #[debug_handler]
async fn login_dev( async fn login_dev(
aac: AnonAppController, aac: AnonAppController,
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>, NoApi(mut auth): NoApi<AuthSession<AnonAppController>>,
Json(form): Json<LoginDevForm>, Json(form): Json<LoginDevForm>,
) -> Result<Json<User>, (StatusCode, String)> { ) -> Result<(), StatusCode> {
match aac.get_dev_user(form.user).await { match aac.get_dev_user(form.user).await {
Ok(user) => { Ok(user) => {
if let Err(err) = auth_session.login(&user).await { if let Err(err) = auth.login(&user).await {
error!("[HANDLER] login_dev: failed to open the session: {err:?}"); error!("Login dev: failed to login the user: {err:?}");
return Err(( return Err(StatusCode::INTERNAL_SERVER_ERROR);
StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected error".to_owned(),
));
} }
Ok(Json(user)) Ok(())
}
Err(err) => {
error!("Login dev: failed to get dev user: {err:?}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
} }
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)] #[cfg(debug_assertions)]
fn login_dev_docs(op: TransformOperation) -> TransformOperation { fn login_dev_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth") op.tag("Auth")
.summary("Log in as a dev user") .summary("Login with a dev user")
.description( .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.")
"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.")
} }

View file

@ -1,68 +0,0 @@
//! 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,18 +15,6 @@ use axum::{Extension, Json, response::IntoResponse, routing::get};
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi { pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
api.title(&format!("{} API documentation", env!("CRATE_NAME"))) api.title(&format!("{} API documentation", env!("CRATE_NAME")))
.description(format!("Build version: {}", env!("GIT_HASH")).as_str()) .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 { .tag(Tag {
name: "misc".to_owned(), name: "misc".to_owned(),
..Default::default() ..Default::default()

View file

@ -2,42 +2,7 @@ use aide::transform::TransformResponse;
use axum::http::StatusCode; use axum::http::StatusCode;
use tracing::error; use tracing::error;
use crate::core::{ use crate::core::controller::ControllerError;
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 /// 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)> { pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
@ -49,6 +14,7 @@ pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (St
} }
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))` /// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
#[allow(dead_code)]
pub fn desc<T>( pub fn desc<T>(
description: &str, description: &str,
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> { ) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {

View file

@ -1,60 +1,31 @@
//! HTTP layer: routing, session handling and OpenAPI generation (aide). //! HTTP layer: routing and OpenAPI generation (aide).
//! //!
//! Authorization is carried by the extractor a handler asks for: //! Handlers stay thin: they extract the controller, call it, and map
//! - `AnonAppController` always succeeds; //! `ControllerError` to a status code. No business logic here.
//! - `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 std::sync::Arc;
use aide::{ use aide::{
OperationInput, OperationOutput, OperationInput, OperationOutput,
axum::{ApiRouter, routing::get_with}, axum::{ApiRouter, routing::get_with},
generate::GenContext, openapi::OpenApi,
openapi::{OpenApi, Operation, Response},
}; };
use axum::{ use axum::{
Extension, Json, Router, Extension, Json, Router,
extract::FromRequestParts, extract::FromRequestParts,
http::{StatusCode, request::Parts}, 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 tracing::error;
use crate::{ use crate::core::controller::AppController;
core::controller::{AnonAppController, AppController},
utils,
};
mod auth;
mod bikes;
mod docs; mod docs;
mod helpers; mod helpers;
mod reservations;
pub fn get_router(aac: AnonAppController) -> Router { pub fn get_router(controller: AppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}")); aide::generate::on_error(|err| error!("aide generated error: {err}"));
aide::generate::extract_schemas(true); 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(); let mut api = OpenApi::default();
ApiRouter::new() ApiRouter::new()
.api_route( .api_route(
@ -64,19 +35,14 @@ pub fn get_router(aac: AnonAppController) -> Router {
|op| op.tag("misc").summary("Get app version"), |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()) .nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata) .finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(aac)) .layer(Extension(controller))
.layer(Extension(Arc::new(api))) .layer(Extension(Arc::new(api)))
.layer(auth_layer)
} }
/// Lets a handler take an `AnonAppController`: always available. /// Lets a handler take an `AppController` as an argument
impl<S> FromRequestParts<S> for AnonAppController impl<S> FromRequestParts<S> for AppController
where where
S: Send + Sync, S: Send + Sync,
{ {
@ -85,65 +51,14 @@ where
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts parts
.extensions .extensions
.get::<AnonAppController>() .get::<AppController>()
.cloned() .cloned()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR) .ok_or(StatusCode::INTERNAL_SERVER_ERROR)
} }
} }
/// Lets a handler take an `AppController`, which requires a session: asking for // The controller is not part of the request/response bodies: nothing to document
/// 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 { impl OperationOutput for AppController {
type Inner = Self; 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()
},
)]
}
}

View file

@ -1,94 +0,0 @@
//! 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,42 +1,28 @@
//! 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 axum_login::{AuthUser, AuthnBackend};
use thiserror::Error; use thiserror::Error;
use tracing::debug;
use crate::{ use crate::{
core::{ core::{
controller::{AnonAppController, ControllerError}, controller::{AnonAppController, ControllerError},
models::user::{NewUser, User, UserId}, models::user::{User, UserNoId},
}, },
utils::whiskey::{UserInfoData, WhiskeyError, authorize, callback}, utils::whiskey::{WhiskeyError, authorize, callback},
}; };
impl AuthUser for User { impl AuthUser for User {
type Id = UserId; type Id = i32;
fn id(&self) -> Self::Id { fn id(&self) -> Self::Id {
self.id self.id
} }
/// Invalidates the sessions of a user whose provider identity changed
fn session_auth_hash(&self) -> &[u8] { fn session_auth_hash(&self) -> &[u8] {
self.oidc_sub.as_bytes() &self.oidc_sub.as_bytes()
} }
} }
impl AuthnBackend for AnonAppController { impl AuthnBackend for AnonAppController {
type User = User; 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 Credentials = ();
type Error = ControllerError; type Error = ControllerError;
@ -51,139 +37,94 @@ impl AuthnBackend for AnonAppController {
&self, &self,
user_id: &axum_login::UserId<Self>, user_id: &axum_login::UserId<Self>,
) -> Result<Option<Self::User>, Self::Error> { ) -> Result<Option<Self::User>, Self::Error> {
match self.db.get_user(*user_id).await { Ok(Some(self.db.get_user(user_id.clone()).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 AnonAppController { impl super::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> { 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 { match authorize().await {
Ok((redirect_to, authorize_backend_data)) => { Ok((redirect_to, authorize_backend_data)) => {
self.db.save_whiskey_data(authorize_backend_data).await?; self.db.save_whiskey_data(authorize_backend_data).await?;
Ok(redirect_to) Ok(redirect_to)
} }
Err(WhiskeyError::ProtocolError) => { Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
Err(AuthnControllerError::OIDCProtocolError.into()) AuthnControllerError::OIDCProtocolError,
} )),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"Originated from Whiskey".to_owned(), "Originated from Whiskey".to_string(),
)), )),
} }
} }
/// Step 2: the browser comes back with a code, we exchange it and upsert async fn get_or_create_user_oidc(
/// the user the provider describes. &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)
}
pub async fn whiskey_callback( pub async fn whiskey_callback(
&self, &self,
code: String, code: String,
state: String, state: String,
) -> Result<User, ControllerError> { ) -> Result<User, ControllerError> {
// Consumes the state: a callback can never be replayed let backend_data = self.db.get_whiskey_data(state.clone()).await?;
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 { match callback(code, state, backend_data).await {
Ok(user_info) => self.upsert_oidc_user(user_info).await, Ok(user_info) => {
Err(WhiskeyError::ProtocolError) => { self.get_or_create_user_oidc(
Err(AuthnControllerError::OIDCProtocolError.into()) user_info.sciper,
user_info.firstname,
user_info.name,
user_info.sub,
user_info.email,
)
.await
} }
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
AuthnControllerError::OIDCProtocolError,
)),
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"originated from Whiskey".to_owned(), "originated from Whiskey".to_string(),
)), )),
} }
} }
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?;
// 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);
};
// 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)] #[cfg(debug_assertions)]
pub async fn get_dev_user(&self, email: String) -> Result<User, ControllerError> { pub async fn get_dev_user(&self, username: String) -> Result<User, ControllerError> {
use crate::core::repositories::RepositoryError;
let user = self.db.get_user_external_id(username.clone()).await;
let user = if let Err(RepositoryError::NotFound(_)) = user {
use crate::utils::config; use crate::utils::config;
let dev_user = config::get() let user = config::get()
.dev_users .get_dev_users()
.iter() .into_iter()
.find(|user| user.email == email) .find(|u| u.external_id.clone().is_some_and(|u| u == username))
.ok_or(AuthnControllerError::DevUserNotFound)? .ok_or(AuthnControllerError::DevUserNotFound)?;
.clone();
// Namespaced so a dev user can never collide with a real Whiskey subject self.db.upsert_user(user).await?
let user = self } else {
.db user?
.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 Ok(user)
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)
} }
} }
@ -195,6 +136,6 @@ pub enum AuthnControllerError {
NotAuthenticated, NotAuthenticated,
#[error("Not authorized")] #[error("Not authorized")]
NotAuthorized, NotAuthorized,
#[error("Dev user does not exist")] #[error("Dev user does not exists")]
DevUserNotFound, DevUserNotFound,
} }

View file

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

View file

@ -1,126 +1,32 @@
//! The controller holds the business logic: it is the only place that knows the //! 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 //! rules of the app. It talks to the outside world through the repository traits,
//! traits, so it depends neither on axum nor on sqlx. //! so it depends neither on axum nor on sqlx.
//! //!
//! Authorization is carried by the **type**, not by a check inside the //! One file per domain area, each one adding methods to `AppController` through
//! handlers. Each level derefs into the one below, so a manager can do //! `impl super::AppController`.
//! 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::{ops::Deref, sync::Arc}; use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use crate::core::{ use crate::core::{
controller::{ controller::{bikes::BikesControllerError, reservations::ReservationsControllerError},
authn::AuthnControllerError, bikes::BikesControllerError,
reservations::ReservationsControllerError,
},
models::{unit::UnitId, user::User},
repositories::{DatabaseRepository, RepositoryError}, repositories::{DatabaseRepository, RepositoryError},
}; };
pub mod authn;
pub mod bikes; pub mod bikes;
pub mod reservations; pub mod reservations;
pub mod users; pub mod users;
/// Entry point of the business logic, for anybody. Cheap to clone: handlers get /// Entry point of the business logic. Cheap to clone: handlers get one per request.
/// 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)] #[derive(Clone)]
pub struct AppController { pub struct AppController {
inner: AnonAppController, db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
user: User,
}
impl Deref for AppController {
type Target = AnonAppController;
fn deref(&self) -> &Self::Target {
&self.inner
}
} }
impl AppController { impl AppController {
pub fn user(&self) -> &User { pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
&self.user Self { db }
}
/// 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,
}
} }
} }
@ -132,15 +38,7 @@ pub enum ControllerError {
InternalError(String), InternalError(String),
#[error("Generic repository error")] #[error("Generic repository error")]
RepositoryError(#[from] RepositoryError), 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}")] #[error("Bike specific error: {0}")]
Bike(#[from] BikesControllerError), Bike(#[from] BikesControllerError),
#[error("Reservation specific error: {0}")] #[error("Reservation specific error: {0}")]

View file

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

View file

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

View file

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

View file

@ -1,11 +1 @@
use schemars::JsonSchema; pub type UnitId = String;
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 schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::core::models::unit::Unit; use crate::core::models::unit::UnitId;
pub type UserId = i32; pub type UserId = i32;
@ -13,7 +13,7 @@ pub struct User {
pub name: String, pub name: String,
pub email: String, pub email: String,
pub oidc_sub: String, pub oidc_sub: String,
pub units: Vec<Unit>, pub units: Vec<UnitId>,
pub admin: bool, pub admin: bool,
} }
@ -24,14 +24,5 @@ pub struct NewUser {
pub name: String, pub name: String,
pub email: String, pub email: String,
pub oidc_sub: 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,27 +8,18 @@ use async_trait::async_trait;
use thiserror::Error; use thiserror::Error;
use crate::core::repositories::{ use crate::core::repositories::{
bikes_repository::BikesRepository, oidc_states_repository::OidcStatesRepository, bikes_repository::BikesRepository, reservations_repository::ReservationsRepository,
reservations_repository::ReservationsRepository, units_repository::UnitsRepository,
users_repository::UsersRepository, users_repository::UsersRepository,
}; };
pub mod bikes_repository; pub mod bikes_repository;
pub mod oidc_states_repository;
pub mod reservations_repository; pub mod reservations_repository;
pub mod units_repository;
pub mod users_repository; pub mod users_repository;
/// Add every new repository trait here so the controller can use it through `db` /// Add every new repository trait here so the controller can use it through `db`
#[async_trait] #[async_trait]
pub trait DatabaseRepository: pub trait DatabaseRepository:
UsersRepository UsersRepository + BikesRepository + ReservationsRepository + Send + Sync
+ BikesRepository
+ ReservationsRepository
+ UnitsRepository
+ OidcStatesRepository
+ Send
+ Sync
{ {
} }

View file

@ -4,19 +4,12 @@ use crate::{core::repositories::RepositoryError, utils::whiskey};
#[async_trait] #[async_trait]
pub trait OidcStatesRepository { pub trait OidcStatesRepository {
/// Reads the state back **and consumes it**: an authorization state is async fn get_whiskey_data(
/// 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, &self,
csrf_token: String, csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError>; ) -> Result<whiskey::AuthorizeBackendData, RepositoryError>;
async fn save_whiskey_data( async fn save_whiskey_data(
&self, &self,
data: whiskey::AuthorizeBackendData, data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError>; ) -> Result<(), RepositoryError>;
/// Drops the states nobody came back for
async fn delete_expired_whiskey_data(&self) -> Result<u64, RepositoryError>;
} }

View file

@ -6,7 +6,6 @@ use crate::core::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
}, },
unit::UnitId, unit::UnitId,
user::UserId,
}, },
repositories::RepositoryError, repositories::RepositoryError,
}; };
@ -20,12 +19,9 @@ pub trait ReservationsRepository {
) -> Result<Vec<Reservation>, RepositoryError>; ) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_reservation(&self, id: ReservationId) -> Result<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( async fn create_reservation(
&self, &self,
reservation: NewReservation, reservation: NewReservation,
requester: UserId,
) -> Result<Reservation, RepositoryError>; ) -> Result<Reservation, RepositoryError>;
async fn update_reservation(&self, reservation: ReservationEdit) async fn update_reservation(&self, reservation: ReservationEdit)

View file

@ -1,12 +0,0 @@
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,10 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::core::{ use crate::core::{
models::{ models::user::{NewUser, User, UserId},
unit::UnitId,
user::{NewUser, User, UserId},
},
repositories::RepositoryError, repositories::RepositoryError,
}; };
@ -15,14 +12,7 @@ pub trait UsersRepository {
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError>; 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>; 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>; 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>; 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::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use crate::{core::controller::AnonAppController, services::database::SqlxDatabase}; use crate::{core::controller::AppController, services::database::SqlxDatabase};
mod api; mod api;
mod core; mod core;
@ -32,17 +32,15 @@ async fn main() {
.await .await
.expect("Unable to connect to database"); .expect("Unable to connect to database");
let aac = AnonAppController::new(Arc::new(Box::new(db))); let controller = AppController::new(Arc::new(Box::new(db)));
// Anything that is not an api route is served from the built frontend, // 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. // 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) // (`fallback` and not `not_found_service`, which would force a 404 status)
let app = api::get_router(aac).fallback_service( let app =
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!( api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
"{}/index.html", ServeFile::new(format!("{}/index.html", config.frontend_dir)),
config.frontend_dir ));
))),
);
let bind_address = config.get_bind_address(); let bind_address = config.get_bind_address();
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();

View file

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

View file

@ -1,11 +1,5 @@
//! 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 async_trait::async_trait;
use chrono::{Duration, Utc}; use sqlx::{prelude::FromRow, query, query_as};
use sqlx::{query, query_as};
use crate::{ use crate::{
core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository}, core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository},
@ -13,66 +7,66 @@ use crate::{
utils::whiskey, utils::whiskey,
}; };
/// A user has this long to come back from the provider #[derive(FromRow)]
const STATE_LIFETIME_MINUTES: i64 = 15; struct DBOidcStateData {
struct OidcStateDB {
key: String, key: String,
data: String, data: String,
} }
impl TryFrom<whiskey::AuthorizeBackendData> for OidcStateDB { impl TryFrom<whiskey::AuthorizeBackendData> for DBOidcStateData {
type Error = RepositoryError; type Error = RepositoryError;
fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> { fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> {
Ok(OidcStateDB { Ok(DBOidcStateData {
key: value.csrf_token(), key: value.csrf_token(),
data: serde_json::to_string(&value)?, data: serde_json::to_string(&value)?,
}) })
} }
} }
impl TryFrom<OidcStateDB> for whiskey::AuthorizeBackendData { impl TryInto<whiskey::AuthorizeBackendData> for DBOidcStateData {
type Error = RepositoryError; type Error = RepositoryError;
fn try_from(value: OidcStateDB) -> Result<Self, Self::Error> { fn try_into(self) -> Result<whiskey::AuthorizeBackendData, Self::Error> {
let data: whiskey::AuthorizeBackendData = serde_json::from_str(&value.data)?; let value: whiskey::AuthorizeBackendData = serde_json::from_str(&self.data)?;
if data.csrf_token() != value.key { if value.csrf_token() != self.key {
return Err(RepositoryError::TypeConversion( return Err(RepositoryError::TypeConversion(
"key of whiskey authorize backend data doesn't match".to_owned(), "key of whiskey authorize backend data doesn't match".to_owned(),
)); ));
} }
Ok(data) Ok(value)
} }
} }
#[async_trait] #[async_trait]
impl OidcStatesRepository for SqlxDatabase { impl OidcStatesRepository for SqlxDatabase {
async fn take_whiskey_data( async fn get_whiskey_data(
&self, &self,
csrf_token: String, csrf_token: String,
) -> Result<whiskey::AuthorizeBackendData, RepositoryError> { ) -> Result<whiskey::AuthorizeBackendData, RepositoryError> {
let cutoff = Utc::now() - Duration::minutes(STATE_LIFETIME_MINUTES);
query_as!( query_as!(
OidcStateDB, DBOidcStateData,
r#"DELETE FROM oidc_states r#"SELECT
WHERE key = $1 AND created_at > $2 key,
RETURNING key, data"#, data
csrf_token, FROM oidc_states
cutoff WHERE key = $1"#,
csrf_token
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await? .await?
.try_into() .try_into()
} }
// TODO: Expire the data and remove it periodically
async fn save_whiskey_data( async fn save_whiskey_data(
&self, &self,
data: whiskey::AuthorizeBackendData, data: whiskey::AuthorizeBackendData,
) -> Result<(), RepositoryError> { ) -> Result<(), RepositoryError> {
let data: OidcStateDB = data.try_into()?; let data: DBOidcStateData = data.try_into()?;
query!( query!(
r#"INSERT INTO oidc_states (key, data) r#"INSERT INTO oidc_states
(key, data)
VALUES ($1, $2)"#, VALUES ($1, $2)"#,
data.key, data.key,
data.data data.data
@ -81,14 +75,4 @@ impl OidcStatesRepository for SqlxDatabase {
.await?; .await?;
Ok(()) 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,7 +5,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::{query, query_as}; use sqlx::{query, query_as};
use crate::{ use crate::{
@ -14,8 +13,7 @@ use crate::{
reservation::{ reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
}, },
unit::{Unit, UnitId}, unit::UnitId,
user::UserId,
}, },
repositories::{RepositoryError, reservations_repository::ReservationsRepository}, repositories::{RepositoryError, reservations_repository::ReservationsRepository},
}, },
@ -61,37 +59,31 @@ impl From<ReservationStatus> for ReservationStatusDB {
struct ReservationDB { struct ReservationDB {
pub id: i32, pub id: i32,
pub unit_id: i32, pub unit: String,
pub unit_name: String,
pub start_time: DateTime<Utc>, pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>, pub end_time: DateTime<Utc>,
pub requester_id: i32, pub requester_id: i32,
pub telegram: String, pub telegram: String,
pub description: String, pub description: String,
pub status: ReservationStatusDB, pub status: ReservationStatusDB,
pub users: Value, pub users: Vec<i32>,
pub bikes: Vec<i32>, pub bikes: Vec<i32>,
} }
impl TryFrom<ReservationDB> for Reservation { impl From<ReservationDB> for Reservation {
type Error = RepositoryError; fn from(value: ReservationDB) -> Self {
Reservation {
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
Ok(Reservation {
id: value.id, id: value.id,
unit: Unit { unit: value.unit,
id: value.unit_id,
name: value.unit_name,
},
start_time: value.start_time, start_time: value.start_time,
end_time: value.end_time, end_time: value.end_time,
requester: value.requester_id, requester: value.requester_id,
users: serde_json::from_value(value.users)?, users: value.users,
telegram: value.telegram, telegram: value.telegram,
description: value.description, description: value.description,
bikes: value.bikes, bikes: value.bikes,
status: value.status.into(), status: value.status.into(),
}) }
} }
} }
@ -143,38 +135,29 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB, ReservationDB,
r#"SELECT r#"SELECT
r.id, r.id,
r.unit_id, r.unit,
un."name" AS unit_name,
r.start_time, r.start_time,
r.end_time, r.end_time,
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( ARRAY(
SELECT json_agg(json_build_object( SELECT user_id FROM reservations_users
'id', u.id, WHERE reservation_id = r.id ORDER BY user_id
'firstname', u.firstname, ) AS "users!",
'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( ARRAY(
SELECT bike_id FROM reservations_bikes SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!" ) AS "bikes!"
FROM reservations r FROM reservations r
JOIN units un ON un.id = r.unit_id
ORDER BY r.start_time DESC"# ORDER BY r.start_time DESC"#
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await? .await?
.into_iter() .into_iter()
.map(TryInto::try_into) .map(Into::into)
.collect::<Result<Vec<_>, _>>()?) .collect())
} }
async fn get_unit_reservations( async fn get_unit_reservations(
@ -185,40 +168,31 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB, ReservationDB,
r#"SELECT r#"SELECT
r.id, r.id,
r.unit_id, r.unit,
un."name" AS unit_name,
r.start_time, r.start_time,
r.end_time, r.end_time,
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( ARRAY(
SELECT json_agg(json_build_object( SELECT user_id FROM reservations_users
'id', u.id, WHERE reservation_id = r.id ORDER BY user_id
'firstname', u.firstname, ) AS "users!",
'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( ARRAY(
SELECT bike_id FROM reservations_bikes SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!" ) AS "bikes!"
FROM reservations r FROM reservations r
JOIN units un ON un.id = r.unit_id WHERE r.unit = $1
WHERE r.unit_id = $1
ORDER BY r.start_time DESC"#, ORDER BY r.start_time DESC"#,
unit unit
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await? .await?
.into_iter() .into_iter()
.map(TryInto::try_into) .map(Into::into)
.collect::<Result<Vec<_>, _>>()?) .collect())
} }
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> { async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
@ -226,56 +200,46 @@ impl ReservationsRepository for SqlxDatabase {
ReservationDB, ReservationDB,
r#"SELECT r#"SELECT
r.id, r.id,
r.unit_id, r.unit,
un."name" AS unit_name,
r.start_time, r.start_time,
r.end_time, r.end_time,
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( ARRAY(
SELECT json_agg(json_build_object( SELECT user_id FROM reservations_users
'id', u.id, WHERE reservation_id = r.id ORDER BY user_id
'firstname', u.firstname, ) AS "users!",
'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( ARRAY(
SELECT bike_id FROM reservations_bikes SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!" ) AS "bikes!"
FROM reservations r FROM reservations r
JOIN units un ON un.id = r.unit_id
WHERE r.id = $1"#, WHERE r.id = $1"#,
id id
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await? .await?
.try_into()?) .into())
} }
async fn create_reservation( async fn create_reservation(
&self, &self,
reservation: NewReservation, reservation: NewReservation,
requester: UserId,
) -> Result<Reservation, RepositoryError> { ) -> Result<Reservation, RepositoryError> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
// No status here: the column defaults to 'requested', the start of the // No status here: the column defaults to 'requested', the start of the
// state machine. // state machine.
let id = query!( let id = query!(
r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description") r#"INSERT INTO reservations (unit, start_time, end_time, requester_id, telegram, "description")
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#, RETURNING id"#,
reservation.unit, reservation.unit,
reservation.start_time, reservation.start_time,
reservation.end_time, reservation.end_time,
requester, reservation.requester,
reservation.telegram, reservation.telegram,
reservation.description reservation.description
) )
@ -283,19 +247,22 @@ impl ReservationsRepository for SqlxDatabase {
.await? .await?
.id; .id;
// The requester is always allowed to pick the bikes up Self::set_reservation_links(&mut tx, id, &reservation.users, &reservation.bikes).await?;
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?; tx.commit().await?;
// Read it back through the normal query rather than rebuilding it by Ok(Reservation {
// hand: the caller gets the unit and the users exactly as any other read id,
// would return them. unit: reservation.unit,
self.get_reservation(id).await 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,
})
} }
async fn update_reservation( async fn update_reservation(
@ -306,7 +273,7 @@ impl ReservationsRepository for SqlxDatabase {
let result = query!( let result = query!(
r#"UPDATE reservations r#"UPDATE reservations
SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6 SET unit = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
WHERE id = $1"#, WHERE id = $1"#,
reservation.id, reservation.id,
reservation.unit, reservation.unit,

View file

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

View file

@ -7,10 +7,6 @@ use serde::Deserialize;
pub struct ServerConfig { pub struct ServerConfig {
pub address: Option<String>, pub address: Option<String>,
pub port: Option<u16>, 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)] #[derive(Deserialize, Clone)]
@ -22,38 +18,10 @@ pub struct PostgresConfig {
pub name: String, 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)] #[derive(Deserialize, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub server: ServerConfig, pub server: ServerConfig,
pub postgres: PostgresConfig, pub postgres: PostgresConfig,
pub oidc: OidcConfig,
#[serde(default)]
pub dev_users: Vec<DevUserConfig>,
/// Directory containing the built frontend /// Directory containing the built frontend
pub frontend_dir: String, pub frontend_dir: String,
} }
@ -65,20 +33,6 @@ impl AppConfig {
format!("{}:{}", address, port) 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 { pub fn get_postgresql_url(&self) -> String {
let host = self.postgres.host.clone().unwrap_or("localhost".to_owned()); let host = self.postgres.host.clone().unwrap_or("localhost".to_owned());
let port = self.postgres.port.unwrap_or(5432); let port = self.postgres.port.unwrap_or(5432);

View file

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

View file

@ -40,9 +40,7 @@ async fn get_client() -> &'static Client {
CLIENT CLIENT
.get_or_init(|| async { .get_or_init(|| async {
let http_client = get_http_client(); let http_client = get_http_client();
let config = config::get(); let config = config::get().clone();
let redirect_url = format!("{}/whiskey/callback", config.get_base_url());
let config = config.clone();
let provider_metadata = CoreProviderMetadata::discover_async( let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oidc.issuer_url).unwrap(), IssuerUrl::new(config.oidc.issuer_url).unwrap(),
http_client, http_client,
@ -56,7 +54,7 @@ async fn get_client() -> &'static Client {
Some(ClientSecret::new(config.oidc.client_secret)), Some(ClientSecret::new(config.oidc.client_secret)),
) )
.set_redirect_uri( .set_redirect_uri(
RedirectUrl::new(redirect_url).unwrap(), RedirectUrl::new(format!("{}/whiskey/callback", config.server.base_url)).unwrap(),
) )
}) })
.await .await
@ -85,6 +83,7 @@ pub async fn authorize() -> Result<(String, AuthorizeBackendData), WhiskeyError>
CsrfToken::new_random, CsrfToken::new_random,
Nonce::new_random, Nonce::new_random,
) )
.add_scope(Scope::new("openid".to_owned()))
.add_scope(Scope::new("profile".to_owned())) .add_scope(Scope::new("profile".to_owned()))
.add_scope(Scope::new("email".to_owned())) .add_scope(Scope::new("email".to_owned()))
.set_pkce_challenge(pkce_challenge) .set_pkce_challenge(pkce_challenge)
@ -107,7 +106,6 @@ pub struct UserInfoData {
pub name: String, pub name: String,
pub firstname: String, pub firstname: String,
pub email: String, pub email: String,
pub groups: Option<Vec<String>>,
} }
pub async fn callback( pub async fn callback(
@ -216,56 +214,18 @@ pub async fn callback(
let sciper = claims.additional_claims().sciper.clone(); 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 { Ok(UserInfoData {
firstname, firstname,
name, name,
sub, sub,
sciper, sciper,
email, email,
groups,
}) })
} }
async fn groups_from_userinfo( #[derive(Serialize, Deserialize, Debug, PartialEq)]
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 struct WhiskeyClaims {
pub sciper: String, pub sciper: String,
#[serde(default)]
pub groups: Option<Vec<String>>,
} }
impl openidconnect::AdditionalClaims for WhiskeyClaims {} impl openidconnect::AdditionalClaims for WhiskeyClaims {}
@ -299,51 +259,3 @@ pub type Client = openidconnect::Client<
openidconnect::EndpointMaybeSet, openidconnect::EndpointMaybeSet,
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);
}
}