feat: add oidc
This commit is contained in:
parent
5169cad8ef
commit
7e958b89c6
98 changed files with 11440 additions and 572 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"frontend/src/locales"
|
||||
]
|
||||
],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
1189
Cargo.lock
generated
1189
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,8 +13,11 @@ aide = { version = "0.15.1", features = [
|
|||
] }
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
axum-login = "0.18.0"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] }
|
||||
config = "0.15.23"
|
||||
indexmap = "2.14.0"
|
||||
openidconnect = "4.0.1"
|
||||
schemars = { version = "0.9", features = ["chrono04"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
|
|
@ -27,5 +30,6 @@ sqlx = { version = "0.8.6", features = [
|
|||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "signal"] }
|
||||
tower-http = { version = "0.6.10", features = ["fs"] }
|
||||
tower-sessions = { version = "0.14", default-features = false, features = ["memory-store"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -17,11 +17,17 @@ routes and the frontend views are not written yet.
|
|||
- Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui`
|
||||
- A single binary in production: the backend serves the built frontend
|
||||
|
||||
Authentication is **not wired yet**. Users are mirrored from AGEPoly's OIDC provider
|
||||
(Whiskey): `users.oidc_sub` is the identity, and `AppController::login` upserts the row from
|
||||
the claims of the token. Because the units also come from Whiskey, they are stored as plain
|
||||
text (`units_users.unit_name`, `reservations.unit`) rather than as an enum or a reference
|
||||
table: a unit unknown to us must never break a login.
|
||||
Users are mirrored from AGEPoly's OIDC provider (Whiskey): `users.oidc_sub` is the
|
||||
identity, and a login upserts the row from the claims of the token. Whiskey calls the units
|
||||
**groups** and sends them in the `groups` claim; each one is a row in `units`, shared by
|
||||
everybody who belongs to it, and the memberships are rewritten at every login — so a user
|
||||
removed from a group there loses it here too. A group we have never seen is created on the
|
||||
spot rather than rejected: an unknown unit must never break a login.
|
||||
|
||||
`admin` is ours, and is never touched by a login.
|
||||
|
||||
In a debug build, `dev_users` from the configuration can be logged in through
|
||||
`POST /api/login` without going through the provider — see the login page.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
|
@ -90,6 +96,27 @@ another implementation (a mock in tests, another storage) without touching the l
|
|||
Handlers stay thin — extract the controller, call it, map the error to a status code — and
|
||||
their OpenAPI documentation sits right next to them (`fn *_docs`).
|
||||
|
||||
### Authorization is a type, not a check
|
||||
|
||||
There are four controllers, each dereferencing into the one above it:
|
||||
|
||||
```
|
||||
AnonAppController anybody: reading the fleet, the calendar, the login routes
|
||||
└─ AppController a logged in user
|
||||
├─ ManagerAppController a member of one unit, acting for that unit
|
||||
└─ AdminAppController an admin
|
||||
```
|
||||
|
||||
A handler declares what it needs in its signature. `AnonAppController` always extracts;
|
||||
`AppController` resolves the session cookie and answers **401** on its own, so a protected
|
||||
route cannot be left open by forgetting a check. Going further down is explicit and
|
||||
fallible — `try_into_manager(unit)` and `try_into_admin()`, wrapped by `api::helpers` so a
|
||||
refusal becomes a 403.
|
||||
|
||||
Sessions are handled by `axum-login` on top of `tower-sessions`, wired in `api/mod.rs`.
|
||||
The store is in memory: **everybody is logged out when the backend restarts**. Swap it for a
|
||||
persistent store if that becomes a problem.
|
||||
|
||||
### Adding an entity
|
||||
|
||||
1. `dbmate n create_things` and write the migration, then `dbmate up`
|
||||
|
|
@ -157,10 +184,13 @@ vue router keeps working on a page reload. Only one process to deploy.
|
|||
## Data model
|
||||
|
||||
```
|
||||
users ──< units_users a user belongs to several units (from Whiskey)
|
||||
│
|
||||
├──< reservations_users >── reservations ──< reservations_bikes >── bikes
|
||||
└──── reservations.requester_id
|
||||
units ──< units_users >── users a unit (a Whiskey group) has many members,
|
||||
│ │ and a user belongs to many units
|
||||
│ ├──< reservations_users >── reservations
|
||||
│ └──── reservations.requester_id
|
||||
└──── reservations.unit_id exactly one unit borrows the bikes
|
||||
|
||||
reservations ──< reservations_bikes >── bikes
|
||||
```
|
||||
|
||||
A reservation moves through a state machine, enforced in
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
# Copy to config.yml (gitignored) and adapt.
|
||||
# Every value can also be given as an environment variable, e.g. APP__SERVER__PORT=3000
|
||||
server:
|
||||
address: 0.0.0.0
|
||||
port: 3000
|
||||
|
||||
postgres:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: postgres
|
||||
name: cargagep
|
||||
|
||||
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
||||
frontend_dir: frontend/dist
|
||||
16
db/migrations/20260823170000_create_oidc_states.sql
Normal file
16
db/migrations/20260823170000_create_oidc_states.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- migrate:up
|
||||
|
||||
-- Short lived state of an in-flight OIDC authorization: the csrf token is the
|
||||
-- key the provider hands back, `data` holds the pkce verifier and the nonce.
|
||||
-- A row is consumed by the callback (deleted on read), so a state can never be
|
||||
-- replayed, and `created_at` lets the stale ones be swept.
|
||||
CREATE TABLE oidc_states (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX oidc_states_created_at_idx ON oidc_states (created_at);
|
||||
|
||||
-- migrate:down
|
||||
DROP TABLE IF EXISTS oidc_states;
|
||||
56
db/migrations/20260823210000_create_units.sql
Normal file
56
db/migrations/20260823210000_create_units.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
-- migrate:up
|
||||
|
||||
-- A unit (an AGEPoly committee or commission) is a row of its own rather than a
|
||||
-- string repeated on every membership: it can be renamed, and per-unit data can
|
||||
-- be hung off it later without touching anything else. `name` is the Whiskey
|
||||
-- group name and stays the key we match the provider on.
|
||||
CREATE TABLE units (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Everything that already named a unit becomes a row
|
||||
INSERT INTO units ("name")
|
||||
SELECT DISTINCT unit_name FROM units_users
|
||||
UNION
|
||||
SELECT DISTINCT unit FROM reservations
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
|
||||
-- units_users: unit_name -> unit_id
|
||||
ALTER TABLE units_users
|
||||
ADD COLUMN unit_id INTEGER REFERENCES units (id) ON DELETE CASCADE;
|
||||
UPDATE units_users uu SET unit_id = u.id FROM units u WHERE u."name" = uu.unit_name;
|
||||
ALTER TABLE units_users ALTER COLUMN unit_id SET NOT NULL;
|
||||
ALTER TABLE units_users DROP CONSTRAINT units_users_pkey;
|
||||
ALTER TABLE units_users DROP COLUMN unit_name;
|
||||
ALTER TABLE units_users ADD PRIMARY KEY (unit_id, user_id);
|
||||
|
||||
CREATE INDEX units_users_user_id_idx ON units_users (user_id);
|
||||
|
||||
-- reservations: unit -> unit_id
|
||||
ALTER TABLE reservations ADD COLUMN unit_id INTEGER REFERENCES units (id);
|
||||
UPDATE reservations r SET unit_id = u.id FROM units u WHERE u."name" = r.unit;
|
||||
ALTER TABLE reservations ALTER COLUMN unit_id SET NOT NULL;
|
||||
DROP INDEX reservations_unit_idx;
|
||||
ALTER TABLE reservations DROP COLUMN unit;
|
||||
CREATE INDEX reservations_unit_id_idx ON reservations (unit_id);
|
||||
|
||||
-- migrate:down
|
||||
|
||||
ALTER TABLE reservations ADD COLUMN unit TEXT;
|
||||
UPDATE reservations r SET unit = u."name" FROM units u WHERE u.id = r.unit_id;
|
||||
ALTER TABLE reservations ALTER COLUMN unit SET NOT NULL;
|
||||
DROP INDEX reservations_unit_id_idx;
|
||||
ALTER TABLE reservations DROP COLUMN unit_id;
|
||||
CREATE INDEX reservations_unit_idx ON reservations (unit);
|
||||
|
||||
ALTER TABLE units_users ADD COLUMN unit_name TEXT;
|
||||
UPDATE units_users uu SET unit_name = u."name" FROM units u WHERE u.id = uu.unit_id;
|
||||
ALTER TABLE units_users ALTER COLUMN unit_name SET NOT NULL;
|
||||
ALTER TABLE units_users DROP CONSTRAINT units_users_pkey;
|
||||
DROP INDEX units_users_user_id_idx;
|
||||
ALTER TABLE units_users DROP COLUMN unit_id;
|
||||
ALTER TABLE units_users ADD PRIMARY KEY (user_id, unit_name);
|
||||
CREATE INDEX units_users_unit_name_idx ON units_users (unit_name);
|
||||
|
||||
DROP TABLE units;
|
||||
115
db/schema.sql
115
db/schema.sql
|
|
@ -1,6 +1,6 @@
|
|||
\restrict dbmate
|
||||
|
||||
-- Dumped from database version 18.6
|
||||
-- Dumped from database version 18.3
|
||||
-- Dumped by pg_dump version 18.6
|
||||
|
||||
SET statement_timeout = 0;
|
||||
|
|
@ -79,19 +79,30 @@ CREATE SEQUENCE public.bikes_id_seq
|
|||
ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: oidc_states; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.oidc_states (
|
||||
key text NOT NULL,
|
||||
data text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: reservations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.reservations (
|
||||
id integer NOT NULL,
|
||||
unit text NOT NULL,
|
||||
start_time timestamp with time zone NOT NULL,
|
||||
end_time timestamp with time zone NOT NULL,
|
||||
requester_id integer NOT NULL,
|
||||
telegram text NOT NULL,
|
||||
description text DEFAULT ''::text NOT NULL,
|
||||
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
|
||||
unit_id integer NOT NULL,
|
||||
CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
|
||||
CONSTRAINT reservations_time_order CHECK ((end_time > start_time))
|
||||
);
|
||||
|
|
@ -146,13 +157,43 @@ CREATE TABLE public.schema_migrations (
|
|||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.units (
|
||||
id integer NOT NULL,
|
||||
name text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.units_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.units_id_seq OWNED BY public.units.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_users; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.units_users (
|
||||
user_id integer NOT NULL,
|
||||
unit_name text NOT NULL
|
||||
unit_id integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -205,6 +246,13 @@ ALTER TABLE ONLY public.bikes ALTER COLUMN id SET DEFAULT nextval('public.bikes_
|
|||
ALTER TABLE ONLY public.reservations ALTER COLUMN id SET DEFAULT nextval('public.reservations_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.units ALTER COLUMN id SET DEFAULT nextval('public.units_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -220,6 +268,14 @@ ALTER TABLE ONLY public.bikes
|
|||
ADD CONSTRAINT bikes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: oidc_states oidc_states_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.oidc_states
|
||||
ADD CONSTRAINT oidc_states_pkey PRIMARY KEY (key);
|
||||
|
||||
|
||||
--
|
||||
-- Name: reservations_bikes reservations_bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -252,12 +308,28 @@ ALTER TABLE ONLY public.schema_migrations
|
|||
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units units_name_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.units
|
||||
ADD CONSTRAINT units_name_key UNIQUE (name);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units units_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.units
|
||||
ADD CONSTRAINT units_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_users units_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.units_users
|
||||
ADD CONSTRAINT units_users_pkey PRIMARY KEY (user_id, unit_name);
|
||||
ADD CONSTRAINT units_users_pkey PRIMARY KEY (unit_id, user_id);
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -292,6 +364,13 @@ ALTER TABLE ONLY public.users
|
|||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: oidc_states_created_at_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX oidc_states_created_at_idx ON public.oidc_states USING btree (created_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: reservations_bikes_bike_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -314,17 +393,17 @@ CREATE INDEX reservations_status_idx ON public.reservations USING btree (status)
|
|||
|
||||
|
||||
--
|
||||
-- Name: reservations_unit_idx; Type: INDEX; Schema: public; Owner: -
|
||||
-- Name: reservations_unit_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX reservations_unit_idx ON public.reservations USING btree (unit);
|
||||
CREATE INDEX reservations_unit_id_idx ON public.reservations USING btree (unit_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_users_unit_name_idx; Type: INDEX; Schema: public; Owner: -
|
||||
-- Name: units_users_user_id_idx; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX units_users_unit_name_idx ON public.units_users USING btree (unit_name);
|
||||
CREATE INDEX units_users_user_id_idx ON public.units_users USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
|
|
@ -351,6 +430,14 @@ ALTER TABLE ONLY public.reservations
|
|||
ADD CONSTRAINT reservations_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: reservations reservations_unit_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.reservations
|
||||
ADD CONSTRAINT reservations_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES public.units(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: reservations_users reservations_users_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -367,6 +454,14 @@ ALTER TABLE ONLY public.reservations_users
|
|||
ADD CONSTRAINT reservations_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_users units_users_unit_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.units_users
|
||||
ADD CONSTRAINT units_users_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES public.units(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: units_users units_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -389,4 +484,6 @@ ALTER TABLE ONLY public.units_users
|
|||
INSERT INTO public.schema_migrations (version) VALUES
|
||||
('20260823153300'),
|
||||
('20260823153310'),
|
||||
('20260823153320');
|
||||
('20260823153320'),
|
||||
('20260823170000'),
|
||||
('20260823210000');
|
||||
|
|
|
|||
42
db/seed.sql
42
db/seed.sql
|
|
@ -8,26 +8,47 @@ INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, a
|
|||
(3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Unit names come from Whiskey; these are placeholders for development
|
||||
INSERT INTO public.units_users (user_id, unit_name) VALUES
|
||||
-- Units are Whiskey's "groups". In real life the rows are created on the fly at
|
||||
-- login; these are placeholders for development.
|
||||
INSERT INTO public.units ("name") VALUES
|
||||
('agepoly'),
|
||||
('clic'),
|
||||
('S4S'),
|
||||
('Balélec')
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
|
||||
-- Resolved by name so the seed never depends on generated ids
|
||||
INSERT INTO public.units_users (user_id, unit_id)
|
||||
SELECT membership.user_id, u.id
|
||||
FROM (VALUES
|
||||
(1, 'agepoly'),
|
||||
(2, 'agepoly'),
|
||||
(2, 'clic'),
|
||||
(1, 'S4S'),
|
||||
(1, 'Balélec'),
|
||||
(3, 'clic')
|
||||
) AS membership (user_id, unit_name)
|
||||
JOIN public.units u ON u."name" = membership.unit_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
|
||||
(1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
||||
(2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
|
||||
(3, 'Cargo 3', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service')
|
||||
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
||||
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
|
||||
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'),
|
||||
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
||||
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.reservations
|
||||
(id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES
|
||||
(1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin',
|
||||
'Transport du matériel pour la rentrée', 'approved'),
|
||||
(2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre',
|
||||
'Déménagement du stock de la commission', 'requested')
|
||||
(id, unit_id, start_time, end_time, requester_id, telegram, "description", status)
|
||||
SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status
|
||||
FROM (VALUES
|
||||
(1, 'agepoly', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz,
|
||||
1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status),
|
||||
(2, 'clic', '2026-09-05 09:00:00+02'::timestamptz, '2026-09-06 17:00:00+02'::timestamptz,
|
||||
3, '@chloe_favre', 'Déménagement du stock de la commission', 'requested'::reservation_status)
|
||||
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status)
|
||||
JOIN public.units u ON u."name" = r.unit_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
|
||||
|
|
@ -39,6 +60,7 @@ INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
|
|||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Keep the sequences in sync with the explicit ids inserted above
|
||||
SELECT setval('public.units_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.units));
|
||||
SELECT setval('public.users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.users));
|
||||
SELECT setval('public.bikes_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.bikes));
|
||||
SELECT setval('public.reservations_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.reservations));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CarGAGEP</title>
|
||||
<title>Cargobikes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
6787
frontend/package-lock.json
generated
Normal file
6787
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,17 +17,18 @@
|
|||
"openapi": "openapi-typescript http://localhost:3000/api/docs/private/api.json -o ./src/lib/api.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.26.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@lucide/vue": "^1.33.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/vue-query": "^5.100.10",
|
||||
"@tanstack/vue-query-devtools": "^6.1.29",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"http-status-ts": "^2.0.1",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"postcss": "^8.5.14",
|
||||
"reka-ui": "^2.10.1",
|
||||
"reka-ui": "^2.10.3",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import AppContent from './components/AppContent.vue'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<AppContent />
|
||||
</SidebarProvider>
|
||||
<div class="bg-background text-foreground flex min-h-svh flex-col">
|
||||
<AppHeader />
|
||||
<main class="w-full flex-1 px-4 py-6 sm:px-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<VueQueryDevtools />
|
||||
<Toaster position="top-center" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
const { setOpen } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main @click="setOpen(false)" class="py-5 px-7 min-h-full w-full">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
185
frontend/src/components/AppHeader.vue
Normal file
185
frontend/src/components/AppHeader.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { getWhiskeyAuthorizationUrl, useLogoutMutation, useSession } from '@/services/api/auth'
|
||||
import { locales, setLocale, type Locale } from '@/services/i18n'
|
||||
import { isDark, toggleTheme } from '@/services/theme'
|
||||
|
||||
const { t, locale: currentLocale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const flags: Record<Locale, string> = { fr: '🇫🇷', en: '🇬🇧' }
|
||||
|
||||
const nav = [
|
||||
{ name: 'reservations', label: 'header.reserve' },
|
||||
{ name: 'calendar', label: 'header.calendar' },
|
||||
]
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const { user, isLoggedIn } = useSession()
|
||||
const logoutMutation = useLogoutMutation()
|
||||
|
||||
function login() {
|
||||
menuOpen.value = false
|
||||
getWhiskeyAuthorizationUrl()
|
||||
.then((url) => {
|
||||
window.location.href = url
|
||||
})
|
||||
.catch(() => toast.error(t('login.error')))
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
menuOpen.value = false
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => router.push({ name: 'login' }),
|
||||
onError: () => toast.error(t('header.logout-error')),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-background sticky top-0 z-40 w-full border-b">
|
||||
<div class="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:gap-3">
|
||||
<!-- Identity -->
|
||||
<RouterLink :to="{ name: 'reservations' }" class="flex min-w-0 items-center gap-2">
|
||||
<img src="/logo-agep.png" alt="AGEPoly" class="h-6 w-auto shrink-0 sm:h-7" />
|
||||
<span class="text-primary truncate text-sm font-semibold sm:text-base">{{
|
||||
$t('app.title')
|
||||
}}</span>
|
||||
<img
|
||||
src="/cargobike-icon.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="hidden h-6 w-auto shrink-0 sm:block dark:invert"
|
||||
/>
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Navigation, from md upwards -->
|
||||
<nav class="hidden items-center gap-1 md:flex">
|
||||
<Button
|
||||
v-for="entry in nav"
|
||||
:key="entry.name"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-primary"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="{ name: entry.name }">{{ $t(entry.label) }}</RouterLink>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
<!-- Theme -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
:aria-label="$t(isDark ? 'header.theme-light' : 'header.theme-dark')"
|
||||
:title="$t(isDark ? 'header.theme-light' : 'header.theme-dark')"
|
||||
@click="toggleTheme()"
|
||||
>
|
||||
<Sun v-if="isDark" class="size-4" />
|
||||
<Moon v-else class="size-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Language -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" :aria-label="$t('header.language')">
|
||||
<span aria-hidden="true">{{ flags[currentLocale as Locale] }}</span>
|
||||
<span class="hidden uppercase sm:inline">{{ currentLocale }}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-for="locale in locales"
|
||||
:key="locale.lang"
|
||||
@click="setLocale(locale.lang)"
|
||||
>
|
||||
<span aria-hidden="true">{{ flags[locale.lang] }}</span>
|
||||
<span class="uppercase">{{ locale.lang }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Session, from md upwards -->
|
||||
<DropdownMenu v-if="isLoggedIn">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="hidden md:inline-flex">
|
||||
<User class="size-4" />
|
||||
<span class="max-w-32 truncate">{{ user?.firstname }}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel class="font-normal">
|
||||
<span class="block truncate">{{ user?.firstname }} {{ user?.name }}</span>
|
||||
<span class="text-muted-foreground block truncate text-xs">{{ user?.email }}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
{{ $t('header.logout') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button v-else size="sm" class="hidden md:inline-flex" @click="login()">
|
||||
{{ $t('header.login') }}
|
||||
</Button>
|
||||
|
||||
<!-- Everything else, below md -->
|
||||
<Sheet v-model:open="menuOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="md:hidden"
|
||||
:aria-label="$t('header.menu')"
|
||||
>
|
||||
<Menu class="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" class="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ $t('header.menu') }}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav class="flex flex-col gap-1 px-4">
|
||||
<Button
|
||||
v-for="entry in nav"
|
||||
:key="entry.name"
|
||||
variant="ghost"
|
||||
class="justify-start"
|
||||
as-child
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
<RouterLink :to="{ name: entry.name }">{{ $t(entry.label) }}</RouterLink>
|
||||
</Button>
|
||||
<template v-if="isLoggedIn">
|
||||
<p class="text-muted-foreground mt-2 truncate px-3 text-xs">
|
||||
{{ user?.firstname }} {{ user?.name }}
|
||||
</p>
|
||||
<Button variant="outline" class="mt-1 justify-start" @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
{{ $t('header.logout') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else class="mt-2" @click="login()">{{ $t('header.login') }}</Button>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Home } from '@lucide/vue'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { locales, setLocale } from '@/services/i18n'
|
||||
|
||||
const { t, locale: currentLocale } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isMobile, open, setOpen } = useSidebar()
|
||||
|
||||
// Add your routes here
|
||||
const menu = [
|
||||
{
|
||||
title: t('sidebar.navigation'),
|
||||
content: [{ title: t('sidebar.home'), icon: Home, name: 'home' }],
|
||||
},
|
||||
]
|
||||
|
||||
const selectedLangFlag = computed(
|
||||
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
|
||||
)
|
||||
|
||||
function navigate(name: string) {
|
||||
setOpen(false)
|
||||
router.push({ name })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar @click="setOpen(true)" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" @click.stop="navigate('home')">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<Home class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ $t('app.title') }}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="w-fit" as-child>
|
||||
<SidebarMenuButton @click.stop class="w-[3em] justify-center">
|
||||
<span :class="'fi fi-' + selectedLangFlag"></span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenuContent class="w-[3em]" :side="isMobile || open ? 'bottom' : 'right'">
|
||||
<DropdownMenuItem
|
||||
v-for="locale in locales"
|
||||
:key="locale.lang"
|
||||
@click="setLocale(locale.lang)"
|
||||
class="justify-center"
|
||||
>
|
||||
<span :class="'fi fi-' + locale.flag + ' w-fit'"></span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup v-for="menuGroup in menu" :key="menuGroup.title">
|
||||
<SidebarGroupLabel>{{ menuGroup.title }}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="menuEntry in menuGroup.content" :key="menuEntry.title">
|
||||
<SidebarMenuButton asChild>
|
||||
<a @click.stop="navigate(menuEntry.name)" class="cursor-pointer">
|
||||
<component :is="menuEntry.icon" />
|
||||
<span>{{ menuEntry.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</template>
|
||||
50
frontend/src/components/DatePicker.vue
Normal file
50
frontend/src/components/DatePicker.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Calendar as CalendarIcon } from '@lucide/vue'
|
||||
import { DateFormatter, type DateValue } from '@internationalized/date'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
const model = defineModel<DateValue | undefined>()
|
||||
defineProps<{ id?: string; minValue?: DateValue; invalid?: boolean }>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
// The popover is controlled so that picking a day closes it
|
||||
const open = ref(false)
|
||||
|
||||
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
|
||||
const formatter = computed(() => new DateFormatter(intlLocale.value, { dateStyle: 'medium' }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
:id="id"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="w-full justify-start px-3 font-normal"
|
||||
:class="model ? '' : 'text-muted-foreground'"
|
||||
>
|
||||
<CalendarIcon class="size-4 shrink-0 opacity-60" />
|
||||
<span class="truncate">
|
||||
{{ model ? formatter.format(model.toDate('UTC')) : $t('reservation.pick-date') }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
v-model="model"
|
||||
:locale="intlLocale"
|
||||
:min-value="minValue"
|
||||
initial-focus
|
||||
@update:model-value="open = false"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
36
frontend/src/components/TimePicker.vue
Normal file
36
frontend/src/components/TimePicker.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const model = defineModel<string | undefined>()
|
||||
defineProps<{ id?: string; invalid?: boolean }>()
|
||||
|
||||
const SLOT_MINUTES = 15
|
||||
const slots = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => {
|
||||
const minutes = i * SLOT_MINUTES
|
||||
const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
|
||||
const mm = String(minutes % 60).padStart(2, '0')
|
||||
return `${hh}:${mm}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="model">
|
||||
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Clock class="size-4 shrink-0 opacity-60" />
|
||||
<SelectValue :placeholder="$t('reservation.pick-time')" />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-64">
|
||||
<SelectItem v-for="slot in slots" :key="slot" :value="slot">{{ slot }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
17
frontend/src/components/ui/alert/Alert.vue
Normal file
17
frontend/src/components/ui/alert/Alert.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { AlertVariants } from '.'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { alertVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: AlertVariants['variant']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
14
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
14
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
||||
24
frontend/src/components/ui/alert/index.ts
Normal file
24
frontend/src/components/ui/alert/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as AlertDescription } from './AlertDescription.vue'
|
||||
export { default as AlertTitle } from './AlertTitle.vue'
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
61
frontend/src/components/ui/calendar/Calendar.vue
Normal file
61
frontend/src/components/ui/calendar/Calendar.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarRootEmits, CalendarRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CalendarCell,
|
||||
CalendarCellTrigger,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHead,
|
||||
CalendarGridRow,
|
||||
CalendarHeadCell,
|
||||
CalendarHeader,
|
||||
CalendarHeading,
|
||||
CalendarNextButton,
|
||||
CalendarPrevButton,
|
||||
} from '.'
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot v-slot="{ grid, weekDays }" :class="cn('p-3', props.class)" v-bind="forwarded">
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell v-for="day in weekDays" :key="day">
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow
|
||||
v-for="(weekDates, index) in month.rows"
|
||||
:key="`weekDate-${index}`"
|
||||
class="mt-2 w-full"
|
||||
>
|
||||
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
|
||||
<CalendarCellTrigger :day="weekDate" :month="month.value" />
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
27
frontend/src/components/ui/calendar/CalendarCell.vue
Normal file
27
frontend/src/components/ui/calendar/CalendarCell.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCell, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="
|
||||
cn(
|
||||
'relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
38
frontend/src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
38
frontend/src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCellTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
22
frontend/src/components/ui/calendar/CalendarGrid.vue
Normal file
22
frontend/src/components/ui/calendar/CalendarGrid.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGrid, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
12
frontend/src/components/ui/calendar/CalendarGridBody.vue
Normal file
12
frontend/src/components/ui/calendar/CalendarGridBody.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridBodyProps } from 'reka-ui'
|
||||
import { CalendarGridBody } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
12
frontend/src/components/ui/calendar/CalendarGridHead.vue
Normal file
12
frontend/src/components/ui/calendar/CalendarGridHead.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridHeadProps } from 'reka-ui'
|
||||
import { CalendarGridHead } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
19
frontend/src/components/ui/calendar/CalendarGridRow.vue
Normal file
19
frontend/src/components/ui/calendar/CalendarGridRow.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridRowProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGridRow, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
22
frontend/src/components/ui/calendar/CalendarHeadCell.vue
Normal file
22
frontend/src/components/ui/calendar/CalendarHeadCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadCellProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeadCell, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell
|
||||
:class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
22
frontend/src/components/ui/calendar/CalendarHeader.vue
Normal file
22
frontend/src/components/ui/calendar/CalendarHeader.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeaderProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeader, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader
|
||||
:class="cn('relative flex w-full items-center justify-between pt-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
29
frontend/src/components/ui/calendar/CalendarHeading.vue
Normal file
29
frontend/src/components/ui/calendar/CalendarHeading.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadingProps } from 'reka-ui'
|
||||
import type { HTMLAttributes, VNode } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeading, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { headingValue: string }) => VNode[]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
32
frontend/src/components/ui/calendar/CalendarNextButton.vue
Normal file
32
frontend/src/components/ui/calendar/CalendarNextButton.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarNextProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { CalendarNext, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
32
frontend/src/components/ui/calendar/CalendarPrevButton.vue
Normal file
32
frontend/src/components/ui/calendar/CalendarPrevButton.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarPrevProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeft } from '@lucide/vue'
|
||||
import { CalendarPrev, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
12
frontend/src/components/ui/calendar/index.ts
Normal file
12
frontend/src/components/ui/calendar/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { default as Calendar } from './Calendar.vue'
|
||||
export { default as CalendarCell } from './CalendarCell.vue'
|
||||
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
|
||||
export { default as CalendarGrid } from './CalendarGrid.vue'
|
||||
export { default as CalendarGridBody } from './CalendarGridBody.vue'
|
||||
export { default as CalendarGridHead } from './CalendarGridHead.vue'
|
||||
export { default as CalendarGridRow } from './CalendarGridRow.vue'
|
||||
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
|
||||
export { default as CalendarHeader } from './CalendarHeader.vue'
|
||||
export { default as CalendarHeading } from './CalendarHeading.vue'
|
||||
export { default as CalendarNextButton } from './CalendarNextButton.vue'
|
||||
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'
|
||||
25
frontend/src/components/ui/label/Label.vue
Normal file
25
frontend/src/components/ui/label/Label.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
frontend/src/components/ui/label/index.ts
Normal file
1
frontend/src/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Label } from './Label.vue'
|
||||
15
frontend/src/components/ui/popover/Popover.vue
Normal file
15
frontend/src/components/ui/popover/Popover.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
|
||||
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
40
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
40
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'center',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
12
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
12
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from 'reka-ui'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger v-bind="props">
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
3
frontend/src/components/ui/popover/index.ts
Normal file
3
frontend/src/components/ui/popover/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Popover } from './Popover.vue'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
export { default as PopoverTrigger } from './PopoverTrigger.vue'
|
||||
15
frontend/src/components/ui/select/Select.vue
Normal file
15
frontend/src/components/ui/select/Select.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
54
frontend/src/components/ui/select/SelectContent.vue
Normal file
54
frontend/src/components/ui/select/SelectContent.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
position: 'popper',
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
17
frontend/src/components/ui/select/SelectGroup.vue
Normal file
17
frontend/src/components/ui/select/SelectGroup.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
36
frontend/src/components/ui/select/SelectItem.vue
Normal file
36
frontend/src/components/ui/select/SelectItem.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
||||
12
frontend/src/components/ui/select/SelectItemText.vue
Normal file
12
frontend/src/components/ui/select/SelectItemText.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from 'reka-ui'
|
||||
import { SelectItemText } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText v-bind="props">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
14
frontend/src/components/ui/select/SelectLabel.vue
Normal file
14
frontend/src/components/ui/select/SelectLabel.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel :class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)">
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
25
frontend/src/components/ui/select/SelectScrollDownButton.vue
Normal file
25
frontend/src/components/ui/select/SelectScrollDownButton.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
25
frontend/src/components/ui/select/SelectScrollUpButton.vue
Normal file
25
frontend/src/components/ui/select/SelectScrollUpButton.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronUp } from '@lucide/vue'
|
||||
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp class="h-4 w-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
15
frontend/src/components/ui/select/SelectSeparator.vue
Normal file
15
frontend/src/components/ui/select/SelectSeparator.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||
</template>
|
||||
31
frontend/src/components/ui/select/SelectTrigger.vue
Normal file
31
frontend/src/components/ui/select/SelectTrigger.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
12
frontend/src/components/ui/select/SelectValue.vue
Normal file
12
frontend/src/components/ui/select/SelectValue.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
11
frontend/src/components/ui/select/index.ts
Normal file
11
frontend/src/components/ui/select/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export { default as Select } from './Select.vue'
|
||||
export { default as SelectContent } from './SelectContent.vue'
|
||||
export { default as SelectGroup } from './SelectGroup.vue'
|
||||
export { default as SelectItem } from './SelectItem.vue'
|
||||
export { default as SelectItemText } from './SelectItemText.vue'
|
||||
export { default as SelectLabel } from './SelectLabel.vue'
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||
export { default as SelectValue } from './SelectValue.vue'
|
||||
416
frontend/src/lib/api.d.ts
vendored
416
frontend/src/lib/api.d.ts
vendored
|
|
@ -4,50 +4,394 @@
|
|||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/version': {
|
||||
"/api/version": {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get app version */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': string
|
||||
"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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
put?: never
|
||||
post?: never
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
}
|
||||
export type webhooks = Record<string, never>
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: never
|
||||
responses: never
|
||||
parameters: never
|
||||
requestBodies: never
|
||||
headers: never
|
||||
pathItems: never
|
||||
schemas: {
|
||||
Bike: {
|
||||
battery?: string | null;
|
||||
drivetrain?: string | null;
|
||||
/** Format: int32 */
|
||||
id: number;
|
||||
key_number?: string | null;
|
||||
/** Format: int32 */
|
||||
key_quantity: number;
|
||||
name: string;
|
||||
status: components["schemas"]["BikeStatus"];
|
||||
};
|
||||
/** @enum {string} */
|
||||
BikeStatus: "in_service" | "out_of_service";
|
||||
DevUser: {
|
||||
admin: boolean;
|
||||
email: string;
|
||||
firstname: string;
|
||||
name: string;
|
||||
};
|
||||
GetAuthorizeResponse: {
|
||||
redirect_to: string;
|
||||
};
|
||||
LoginDevForm: {
|
||||
/** @description Email of one of the `dev_users` of the configuration */
|
||||
user: string;
|
||||
};
|
||||
PostCallbackParams: {
|
||||
code: string;
|
||||
state: string;
|
||||
};
|
||||
Unit: {
|
||||
/** Format: int32 */
|
||||
id: number;
|
||||
/** @description The Whiskey group name */
|
||||
name: string;
|
||||
};
|
||||
User: {
|
||||
admin: boolean;
|
||||
email: string;
|
||||
external_id?: string | null;
|
||||
firstname: string;
|
||||
/** Format: int32 */
|
||||
id: number;
|
||||
name: string;
|
||||
oidc_sub: string;
|
||||
units: components["schemas"]["Unit"][];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>
|
||||
export type operations = Record<string, never>
|
||||
export type $defs = Record<string, never>;
|
||||
export type operations = Record<string, never>;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,59 @@
|
|||
locale: en
|
||||
app:
|
||||
title: CarGAGEP
|
||||
sidebar:
|
||||
navigation: Navigation
|
||||
home: Home
|
||||
home:
|
||||
welcome: Welcome!
|
||||
version: 'Backend version: {version}'
|
||||
title: Cargobikes
|
||||
header:
|
||||
logout: Log out
|
||||
logout-error: Unable to log out.
|
||||
reserve: Book
|
||||
calendar: Calendar
|
||||
login: Log in
|
||||
menu: Menu
|
||||
language: Language
|
||||
theme-dark: Switch to dark mode
|
||||
theme-light: Switch to light mode
|
||||
reservation:
|
||||
title: Book a Cargobike
|
||||
intro: >-
|
||||
Fill in the information below to submit your reservation request.
|
||||
For more information, see the wiki:
|
||||
association: Association name
|
||||
reason: Reason for the reservation
|
||||
reason-placeholder: Why do you need the cargobike?
|
||||
start: Start of the reservation
|
||||
end: End of the reservation
|
||||
pick-date: Pick a date
|
||||
pick-time: Pick a time
|
||||
bikes: Desired bike size
|
||||
bikes-pick-period: >-
|
||||
Please first pick a start and end date and time to see the available cargobikes.
|
||||
bikes-empty: No cargobike available for this period.
|
||||
bikes-error: Unable to load the cargobikes.
|
||||
bike-out-of-service: Out of service
|
||||
telegram: Telegram username
|
||||
emails: Email addresses of the Linka Go accounts to authorize
|
||||
email-nth: 'Email address {n}'
|
||||
add-email: Add an email
|
||||
remove-email: Remove this address
|
||||
submit: Send the request
|
||||
reset: Reset
|
||||
error-required: This field is required.
|
||||
error-datetime-required: Pick a date and a time.
|
||||
error-end-before-start: The end must be after the start.
|
||||
error-no-bike: Select at least one cargobike.
|
||||
# '@' starts a linked message in vue-i18n, so it has to be escaped
|
||||
error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
|
||||
error-email: One of the email addresses is invalid.
|
||||
error-form: The form contains errors.
|
||||
not-implemented: Submitting is not wired to the backend yet.
|
||||
login:
|
||||
title: Log in
|
||||
intro: Log in with your AGEPoly account to book a cargobike.
|
||||
with-whiskey: Log in with AGEPoly
|
||||
already: You are already logged in.
|
||||
finishing: Finishing the login…
|
||||
dev: Development login
|
||||
not-allowed: Your account is not allowed to use this application.
|
||||
error: The login failed.
|
||||
calendar:
|
||||
title: Calendar
|
||||
todo: This page is not built yet.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,60 @@
|
|||
locale: fr
|
||||
app:
|
||||
title: CarGAGEP
|
||||
sidebar:
|
||||
navigation: Navigation
|
||||
home: Accueil
|
||||
home:
|
||||
welcome: Bienvenue !
|
||||
version: 'Version du backend : {version}'
|
||||
title: Cargobikes
|
||||
header:
|
||||
logout: Se déconnecter
|
||||
logout-error: Impossible de se déconnecter.
|
||||
reserve: Réserver
|
||||
calendar: Calendrier
|
||||
login: Se connecter
|
||||
menu: Menu
|
||||
language: Langue
|
||||
theme-dark: Passer en mode sombre
|
||||
theme-light: Passer en mode clair
|
||||
reservation:
|
||||
title: Réserver un Cargobike
|
||||
intro: >-
|
||||
Remplissez les informations ci-dessous pour soumettre votre demande de réservation.
|
||||
Pour plus d'informations, consultez le wiki :
|
||||
association: Nom de l'association
|
||||
reason: Raison de la réservation
|
||||
reason-placeholder: Pourquoi avez-vous besoin du cargobike ?
|
||||
start: Début de la réservation
|
||||
end: Fin de la réservation
|
||||
pick-date: Choisir une date
|
||||
pick-time: Choisir une heure
|
||||
bikes: Taille de vélo souhaitée
|
||||
bikes-pick-period: >-
|
||||
Veuillez d'abord choisir une date et une heure de début et de fin pour voir les
|
||||
cargobikes disponibles.
|
||||
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
||||
bikes-error: Impossible de charger les cargobikes.
|
||||
bike-out-of-service: Hors service
|
||||
telegram: Username Telegram
|
||||
emails: Adresses mail du/des comptes Linka Go à autoriser
|
||||
email-nth: 'Adresse e-mail {n}'
|
||||
add-email: Ajouter un e-mail
|
||||
remove-email: Retirer cette adresse
|
||||
submit: Envoyer la demande
|
||||
reset: Réinitialiser
|
||||
error-required: Ce champ est obligatoire.
|
||||
error-datetime-required: Choisissez une date et une heure.
|
||||
error-end-before-start: La fin doit être après le début.
|
||||
error-no-bike: Sélectionnez au moins un cargobike.
|
||||
# '@' starts a linked message in vue-i18n, so it has to be escaped
|
||||
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
|
||||
error-email: Une des adresses e-mail est invalide.
|
||||
error-form: Le formulaire contient des erreurs.
|
||||
not-implemented: L'envoi n'est pas encore branché sur le backend.
|
||||
login:
|
||||
title: Connexion
|
||||
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
||||
with-whiskey: Se connecter avec AGEPoly
|
||||
already: Vous êtes déjà connecté.
|
||||
finishing: Connexion en cours…
|
||||
dev: Connexion de développement
|
||||
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
|
||||
error: La connexion a échoué.
|
||||
calendar:
|
||||
title: Calendrier
|
||||
todo: Cette page n'est pas encore construite.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { createApp } from 'vue'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { i18nInstance } from './services/i18n'
|
||||
import { setQueryClient } from './services/api/client'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
// The api client needs it to clear the session cache on a 401
|
||||
setQueryClient(queryClient)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(i18nInstance)
|
||||
app.use(router)
|
||||
app.use(VueQueryPlugin)
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
app.mount('#app')
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import ReservationView from '@/views/ReservationView.vue'
|
||||
import CalendarView from '@/views/CalendarView.vue'
|
||||
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [{ name: 'home', path: '/', component: HomeView }],
|
||||
routes: [
|
||||
{ name: 'login', path: '/', component: LoginView },
|
||||
{ name: 'reservations', path: '/reservations', component: ReservationView },
|
||||
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
||||
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
|
||||
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
106
frontend/src/services/api/auth.ts
Normal file
106
frontend/src/services/api/auth.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Session.
|
||||
*
|
||||
* `useSession` is the single source of truth for "who is logged in". Every
|
||||
* mutation writes its answer straight into that cache, and `client.ts` clears
|
||||
* it on a 401, so the header and the views react without an extra round trip.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { User } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
import { SESSION_KEY } from './keys'
|
||||
|
||||
export { SESSION_KEY }
|
||||
|
||||
/**
|
||||
* `/api/me` is a probe, not a protected route: it answers 200 with `null` when
|
||||
* nobody is logged in, so a page load never looks like an error.
|
||||
*/
|
||||
export function useSession() {
|
||||
const query = useQuery({
|
||||
queryKey: SESSION_KEY,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/me')
|
||||
if (response.status !== HttpStatus.OK) {
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
}
|
||||
return data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
user: computed(() => query.data.value ?? null),
|
||||
isLoggedIn: computed(() => !!query.data.value),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the Whiskey login: ask the backend where to send the browser, then
|
||||
* leave the app. The provider comes back on /whiskey/callback.
|
||||
*/
|
||||
export async function getWhiskeyAuthorizationUrl(): Promise<string> {
|
||||
const { data, response } = await getClient().GET('/api/whiskey/authorize')
|
||||
if (response.status !== HttpStatus.OK || !data?.redirect_to) {
|
||||
throw new Error(`authorize failed: ${response.status}`)
|
||||
}
|
||||
return data.redirect_to
|
||||
}
|
||||
|
||||
/** Step 2: hand the code and the state back, which is what opens the session */
|
||||
export function useWhiskeyCallbackMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async (form: { code: string; state: string }) => {
|
||||
const { data } = await getClient().POST('/api/whiskey/callback', { body: form })
|
||||
return data ?? null
|
||||
},
|
||||
onSuccess: (user) => queryClient.setQueryData<User | null>(SESSION_KEY, user),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogoutMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async () => {
|
||||
await getClient().POST('/api/logout')
|
||||
},
|
||||
onSuccess: () => queryClient.setQueryData<User | null>(SESSION_KEY, null),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Development only -------------------------------------------------------
|
||||
// These routes exist only in a debug build of the backend, and the calls are
|
||||
// guarded by `import.meta.env.DEV` so they leave the production bundle.
|
||||
|
||||
export function useDevUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['dev-users'],
|
||||
enabled: import.meta.env.DEV,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => {
|
||||
const { data } = await getClient().GET('/api/login/dev-users')
|
||||
return data ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLoginDevMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async (form: { user: string }) => {
|
||||
const { data } = await getClient().POST('/api/login', { body: form })
|
||||
return data ?? null
|
||||
},
|
||||
onSuccess: (user) => queryClient.setQueryData<User | null>(SESSION_KEY, user),
|
||||
})
|
||||
}
|
||||
22
frontend/src/services/api/bikes.ts
Normal file
22
frontend/src/services/api/bikes.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* The fleet. One file per domain area, exposing vue-query hooks: views never
|
||||
* call `fetch` themselves.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
|
||||
import { getClient } from './client'
|
||||
|
||||
export function useBikes() {
|
||||
return useQuery({
|
||||
queryKey: ['bikes'],
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/bikes')
|
||||
if (response.status === HttpStatus.OK) {
|
||||
return data
|
||||
}
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,7 +1,18 @@
|
|||
import createClient from 'openapi-fetch'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import type { paths } from '@/lib/api'
|
||||
import { Forbidden, Unauthorized } from '@/utils/types'
|
||||
import { SESSION_KEY } from './keys'
|
||||
|
||||
// Registered by main.ts. The interceptor below needs it to drop the session
|
||||
// from the cache as soon as the backend says the cookie is gone.
|
||||
let queryClient: QueryClient | null = null
|
||||
|
||||
export function setQueryClient(client: QueryClient) {
|
||||
queryClient = client
|
||||
}
|
||||
|
||||
// Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
|
||||
const client = createClient<paths>({
|
||||
|
|
@ -17,6 +28,12 @@ client.use({
|
|||
`Server error from ${response.url}: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
throw new Error('Server error')
|
||||
} else if (response.status === HttpStatus.UNAUTHORIZED) {
|
||||
// The session is gone: every view reading it must see that at once
|
||||
queryClient?.setQueryData(SESSION_KEY, null)
|
||||
throw new Unauthorized()
|
||||
} else if (response.status === HttpStatus.FORBIDDEN) {
|
||||
throw new Forbidden()
|
||||
} else if (response.status === HttpStatus.NOT_FOUND) {
|
||||
throw new Error(`Not found: ${response.url}`)
|
||||
}
|
||||
|
|
|
|||
6
frontend/src/services/api/keys.ts
Normal file
6
frontend/src/services/api/keys.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Query keys shared between the services and the response interceptor.
|
||||
* Kept apart from `auth.ts` so that `client.ts` can use the session key without
|
||||
* importing the service that imports it back.
|
||||
*/
|
||||
export const SESSION_KEY = ['session'] as const
|
||||
56
frontend/src/services/theme.ts
Normal file
56
frontend/src/services/theme.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Dark mode. Tailwind is configured with `@custom-variant dark (&:is(.dark *))`,
|
||||
* so the whole theme is driven by a single `dark` class on <html>.
|
||||
*
|
||||
* Three states, like the rest of the web: an explicit choice is remembered in
|
||||
* localStorage, and 'system' follows the OS preference live.
|
||||
*/
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
export const themes = ['light', 'dark', 'system'] as const
|
||||
export type Theme = (typeof themes)[number]
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
|
||||
function isTheme(value: unknown): value is Theme {
|
||||
return themes.includes(value as Theme)
|
||||
}
|
||||
|
||||
function fromLocalStorage(): Theme {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return isTheme(stored) ? stored : 'system'
|
||||
} catch {
|
||||
// Private mode, or site data blocked
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
export const theme = ref<Theme>(fromLocalStorage())
|
||||
/** What is actually painted, once 'system' is resolved */
|
||||
export const isDark = ref(false)
|
||||
|
||||
function apply() {
|
||||
isDark.value = theme.value === 'dark' || (theme.value === 'system' && prefersDark.matches)
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
}
|
||||
|
||||
// Re-applies on every change of `theme`, and follows the OS while on 'system'
|
||||
watchEffect(apply)
|
||||
prefersDark.addEventListener('change', apply)
|
||||
|
||||
export function setTheme(newTheme: Theme) {
|
||||
theme.value = newTheme
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, newTheme)
|
||||
} catch {
|
||||
// Nothing to do: the choice simply will not survive a reload
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggles between light and dark, resolving 'system' to its opposite first */
|
||||
export function toggleTheme() {
|
||||
setTheme(isDark.value ? 'light' : 'dark')
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.546 0.245 262.881);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -85,8 +85,8 @@
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
import type { components } from '@/lib/api'
|
||||
|
||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly.
|
||||
// Re-export a type here for every schema the views use, e.g.
|
||||
// export type Bike = components['schemas']['Bike']
|
||||
// once `src/api/` exposes the routes and `npm run openapi` has been run again.
|
||||
export type Schemas = components['schemas']
|
||||
/** The backend refused the request for lack of a session (401) */
|
||||
export class Unauthorized extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized')
|
||||
this.name = 'Unauthorized'
|
||||
Object.setPrototypeOf(this, Unauthorized.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logged in, but not allowed to do this (403) */
|
||||
export class Forbidden extends Error {
|
||||
constructor() {
|
||||
super('Forbidden')
|
||||
this.name = 'Forbidden'
|
||||
Object.setPrototypeOf(this, Forbidden.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
||||
export type Bike = components['schemas']['Bike']
|
||||
export type BikeStatus = components['schemas']['BikeStatus']
|
||||
export type Unit = components['schemas']['Unit']
|
||||
export type User = components['schemas']['User']
|
||||
|
|
|
|||
0
frontend/src/views/AdminView.vue
Normal file
0
frontend/src/views/AdminView.vue
Normal file
13
frontend/src/views/CalendarView.vue
Normal file
13
frontend/src/views/CalendarView.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Placeholder: the header links here, and a dead link would be worse than an
|
||||
* explicit "not built yet". Replace with the real availability calendar.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-2xl">
|
||||
<h1 class="text-xl font-semibold">{{ $t('calendar.title') }}</h1>
|
||||
<p class="text-muted-foreground mt-2 text-sm">{{ $t('calendar.todo') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Only page so far: shows the backend version.
|
||||
* Add a view per route in `router/index.ts`.
|
||||
*/
|
||||
import { useVersion } from '@/services/api/misc'
|
||||
|
||||
const { data: version } = useVersion()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ $t('home.version', { version }) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
93
frontend/src/views/LoginView.vue
Normal file
93
frontend/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Landing page. Its only job is to get the visitor logged in; once they are,
|
||||
* the router sends them to /reservations.
|
||||
*/
|
||||
import { watchEffect } from 'vue'
|
||||
import { LogIn } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
getWhiskeyAuthorizationUrl,
|
||||
useDevUsers,
|
||||
useLoginDevMutation,
|
||||
useSession,
|
||||
} from '@/services/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const { isLoggedIn, isPending } = useSession()
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const { data: devUsers } = useDevUsers()
|
||||
const loginDevMutation = useLoginDevMutation()
|
||||
|
||||
// Nothing to do here once logged in
|
||||
watchEffect(() => {
|
||||
if (isLoggedIn.value) router.replace({ name: 'reservations' })
|
||||
})
|
||||
|
||||
function loginOidc() {
|
||||
getWhiskeyAuthorizationUrl()
|
||||
.then((url) => {
|
||||
window.location.href = url
|
||||
})
|
||||
.catch(() => toast.error(t('login.error')))
|
||||
}
|
||||
|
||||
function loginAs(user: string) {
|
||||
loginDevMutation.mutate(
|
||||
{ user },
|
||||
{
|
||||
onSuccess: () => router.push({ name: 'reservations' }),
|
||||
onError: () => toast.error(t('login.error')),
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">{{ $t('login.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('login.intro') }}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4">
|
||||
<Skeleton v-if="isPending" class="h-10 w-full" />
|
||||
<p v-else-if="isLoggedIn" class="text-muted-foreground text-sm">
|
||||
{{ $t('login.already') }}
|
||||
</p>
|
||||
<Button v-else @click="loginOidc()">
|
||||
<LogIn class="size-4" />
|
||||
{{ $t('login.with-whiskey') }}
|
||||
</Button>
|
||||
|
||||
<!-- Development shortcut: the users listed under `dev_users` in config.yml -->
|
||||
<div v-if="isDev && devUsers?.length" class="grid gap-2 border-t pt-4">
|
||||
<p class="text-muted-foreground text-xs font-medium uppercase">
|
||||
{{ $t('login.dev') }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="user in devUsers"
|
||||
:key="user.email"
|
||||
variant="outline"
|
||||
class="justify-between"
|
||||
:disabled="loginDevMutation.isPending.value"
|
||||
@click="loginAs(user.email)"
|
||||
>
|
||||
<span class="truncate">{{ user.firstname }} {{ user.name }}</span>
|
||||
<span v-if="user.admin" class="text-muted-foreground text-xs">admin</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
329
frontend/src/views/ReservationView.vue
Normal file
329
frontend/src/views/ReservationView.vue
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Plus, TriangleAlert, X } from '@lucide/vue'
|
||||
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import TimePicker from '@/components/TimePicker.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useBikes } from '@/services/api/bikes'
|
||||
import type { Bike } from '@/utils/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const WIKI_URL = 'https://go.agepoly.ch/cargobikes'
|
||||
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
type Form = {
|
||||
association: string
|
||||
reason: string
|
||||
startTime: string | undefined
|
||||
endTime: string | undefined
|
||||
bikes: number[]
|
||||
telegram: string
|
||||
emails: string[]
|
||||
}
|
||||
|
||||
function emptyForm(): Form {
|
||||
return {
|
||||
association: '',
|
||||
reason: '',
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
bikes: [],
|
||||
telegram: '',
|
||||
emails: [''],
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<Form>(emptyForm())
|
||||
|
||||
const startDate = shallowRef<DateValue>()
|
||||
const endDate = shallowRef<DateValue>()
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitted = ref(false)
|
||||
|
||||
const minDate = today(getLocalTimeZone())
|
||||
|
||||
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
||||
if (!date || !time) return null
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const local = date.toDate(getLocalTimeZone())
|
||||
local.setHours(hours, minutes, 0, 0)
|
||||
return local
|
||||
}
|
||||
|
||||
const start = computed(() => toDate(startDate.value, form.startTime))
|
||||
const end = computed(() => toDate(endDate.value, form.endTime))
|
||||
const periodPicked = computed(() => start.value !== null && end.value !== null)
|
||||
|
||||
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
|
||||
|
||||
function isUnavailable(bike: Bike) {
|
||||
return bike.status === 'out_of_service'
|
||||
}
|
||||
|
||||
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
|
||||
|
||||
watch(availableBikes, (available) => {
|
||||
const ids = new Set(available.map((bike) => bike.id))
|
||||
form.bikes = form.bikes.filter((id) => ids.has(id))
|
||||
})
|
||||
|
||||
function toggleBike(bike: Bike) {
|
||||
if (isUnavailable(bike)) return
|
||||
const index = form.bikes.indexOf(bike.id)
|
||||
if (index >= 0) form.bikes.splice(index, 1)
|
||||
else form.bikes.push(bike.id)
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
form.emails.push('')
|
||||
}
|
||||
|
||||
function removeEmail(index: number) {
|
||||
form.emails.splice(index, 1)
|
||||
if (form.emails.length === 0) form.emails.push('')
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
|
||||
if (!form.association.trim()) errors.association = t('reservation.error-required')
|
||||
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
|
||||
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
||||
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
||||
if (start.value && end.value && end.value <= start.value) {
|
||||
errors.end = t('reservation.error-end-before-start')
|
||||
}
|
||||
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
||||
if (!TELEGRAM_RE.test(form.telegram)) errors.telegram = t('reservation.error-telegram')
|
||||
|
||||
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
||||
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
||||
else if (!emails.every((email) => EMAIL_RE.test(email))) {
|
||||
errors.emails = t('reservation.error-email')
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(form, emptyForm())
|
||||
startDate.value = undefined
|
||||
endDate.value = undefined
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
submitted.value = false
|
||||
}
|
||||
|
||||
function submit() {
|
||||
submitted.value = true
|
||||
if (!validate()) {
|
||||
toast.error(t('reservation.error-form'))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: replace with a `useCreateReservation` mutation once
|
||||
// `POST /api/reservations` exists.
|
||||
toast.info(t('reservation.not-implemented'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">{{ $t('reservation.title') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ $t('reservation.intro') }}
|
||||
<a
|
||||
:href="WIKI_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary underline underline-offset-2"
|
||||
>
|
||||
go.agepoly.ch/cargobikes
|
||||
</a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form class="grid gap-5" novalidate @submit.prevent="submit">
|
||||
<!-- Association -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="association">{{ $t('reservation.association') }}</Label>
|
||||
<Input
|
||||
id="association"
|
||||
v-model.trim="form.association"
|
||||
:placeholder="$t('reservation.association')"
|
||||
:aria-invalid="!!errors.association || undefined"
|
||||
/>
|
||||
<p v-if="errors.association" class="text-destructive text-xs">
|
||||
{{ errors.association }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="reason">{{ $t('reservation.reason') }}</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
v-model.trim="form.reason"
|
||||
:placeholder="$t('reservation.reason-placeholder')"
|
||||
:aria-invalid="!!errors.reason || undefined"
|
||||
rows="2"
|
||||
/>
|
||||
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="start-date">{{ $t('reservation.start') }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<DatePicker
|
||||
id="start-date"
|
||||
v-model="startDate"
|
||||
:min-value="minDate"
|
||||
:invalid="!!errors.start"
|
||||
/>
|
||||
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
||||
</div>
|
||||
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="end-date">{{ $t('reservation.end') }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<DatePicker
|
||||
id="end-date"
|
||||
v-model="endDate"
|
||||
:min-value="startDate ?? minDate"
|
||||
:invalid="!!errors.end"
|
||||
/>
|
||||
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
||||
</div>
|
||||
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bikes -->
|
||||
<div class="grid gap-2">
|
||||
<span class="text-sm font-medium">{{ $t('reservation.bikes') }}</span>
|
||||
|
||||
<div
|
||||
v-if="!periodPicked"
|
||||
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{ $t('reservation.bikes-pick-period') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="bikesPending" class="grid gap-2 sm:grid-cols-2">
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="bikesError"
|
||||
class="border-destructive/50 text-destructive flex items-center gap-2 rounded-md border p-3 text-sm"
|
||||
>
|
||||
<TriangleAlert class="size-4 shrink-0" />
|
||||
{{ $t('reservation.bikes-error') }}
|
||||
</div>
|
||||
|
||||
<p v-else-if="availableBikes.length === 0" class="text-muted-foreground text-sm">
|
||||
{{ $t('reservation.bikes-empty') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="bike in bikes"
|
||||
:key="bike.id"
|
||||
type="button"
|
||||
:disabled="isUnavailable(bike)"
|
||||
:aria-pressed="form.bikes.includes(bike.id)"
|
||||
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="
|
||||
form.bikes.includes(bike.id)
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
"
|
||||
@click="toggleBike(bike)"
|
||||
>
|
||||
<span class="truncate">{{ bike.name }}</span>
|
||||
<span
|
||||
v-if="isUnavailable(bike)"
|
||||
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
|
||||
>
|
||||
{{ $t('reservation.bike-out-of-service') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
|
||||
<Input
|
||||
id="telegram"
|
||||
v-model.trim="form.telegram"
|
||||
placeholder="@username"
|
||||
autocomplete="off"
|
||||
:aria-invalid="!!errors.telegram || undefined"
|
||||
/>
|
||||
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Linka Go emails -->
|
||||
<div class="grid gap-2">
|
||||
<span class="text-sm font-medium">{{ $t('reservation.emails') }}</span>
|
||||
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
|
||||
<Input
|
||||
v-model.trim="form.emails[index]"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
|
||||
placeholder="prenom.nom@exemple.com"
|
||||
:aria-invalid="!!errors.emails || undefined"
|
||||
/>
|
||||
<Button
|
||||
v-if="form.emails.length > 1"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:aria-label="$t('reservation.remove-email')"
|
||||
@click="removeEmail(index)"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
|
||||
<div>
|
||||
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
|
||||
<Plus class="size-4" />
|
||||
{{ $t('reservation.add-email') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="submit">{{ $t('reservation.submit') }}</Button>
|
||||
<Button type="button" variant="outline" @click="reset()">
|
||||
{{ $t('reservation.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
49
frontend/src/views/WhiskeyCallbackView.vue
Normal file
49
frontend/src/views/WhiskeyCallbackView.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Where Whiskey sends the browser back. The code and the state travel in the
|
||||
* query string; they are handed to the backend, which verifies them and opens
|
||||
* the session. Nothing is rendered for long.
|
||||
*/
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Forbidden } from '@/utils/types'
|
||||
import { useWhiskeyCallbackMutation } from '@/services/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const callback = useWhiskeyCallbackMutation()
|
||||
|
||||
onMounted(() => {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
|
||||
if (typeof code !== 'string' || typeof state !== 'string') {
|
||||
toast.error(t('login.error'))
|
||||
router.replace({ name: 'login' })
|
||||
return
|
||||
}
|
||||
|
||||
callback.mutate(
|
||||
{ code, state },
|
||||
{
|
||||
onSuccess: () => router.replace({ name: 'reservations' }),
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Forbidden ? t('login.not-allowed') : t('login.error'))
|
||||
router.replace({ name: 'login' })
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto grid w-full max-w-md gap-3">
|
||||
<p class="text-muted-foreground text-sm">{{ $t('login.finishing') }}</p>
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
151
src/api/auth.rs
151
src/api/auth.rs
|
|
@ -1,3 +1,10 @@
|
|||
//! Login, logout, and "who am I".
|
||||
//!
|
||||
//! The OIDC dance is driven by the browser: `authorize` hands back the provider
|
||||
//! url, the frontend goes there, the provider sends the browser back to
|
||||
//! `{base_url}/whiskey/callback` (a frontend route), and that page posts the
|
||||
//! code and the state here.
|
||||
|
||||
use aide::{
|
||||
NoApi,
|
||||
axum::{
|
||||
|
|
@ -10,20 +17,23 @@ use axum::{Json, debug_handler, http::StatusCode};
|
|||
use axum_login::AuthSession;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
api::helpers::unexpected_error,
|
||||
core::{
|
||||
controller::{AnonAppController, ControllerError, authn::AuthnControllerError},
|
||||
controller::{
|
||||
AnonAppController, AppController, ControllerError, authn::AuthnControllerError,
|
||||
},
|
||||
models::user::User,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn routes() -> ApiRouter {
|
||||
let mut r = ApiRouter::new()
|
||||
#[allow(unused_mut)]
|
||||
let mut router = ApiRouter::new()
|
||||
.api_route("/api/me", get_with(me, me_docs))
|
||||
.api_route("/api/logout", post_with(logout, logout_docs))
|
||||
// .api_route("/api/me", get_with(me, me_docs))
|
||||
.api_route(
|
||||
"/api/whiskey/authorize",
|
||||
get_with(whiskey_authorize, whiskey_authorize_docs),
|
||||
|
|
@ -35,28 +45,40 @@ pub fn routes() -> ApiRouter {
|
|||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
r = r.api_route("/api/login", post_with(login_dev, login_dev_docs))
|
||||
router = router
|
||||
.api_route("/api/login", post_with(login_dev, login_dev_docs))
|
||||
.api_route("/api/login/dev-users", get_with(dev_users, dev_users_docs));
|
||||
}
|
||||
|
||||
r
|
||||
router
|
||||
}
|
||||
|
||||
/// The user behind the current session, or `null` when there is none.
|
||||
///
|
||||
/// Deliberately takes the session rather than an `AppController`: this is the
|
||||
/// probe the frontend runs on every page load, and "nobody is logged in" is a
|
||||
/// normal answer, not an error. Every other protected route takes an
|
||||
/// `AppController` and answers 401.
|
||||
#[debug_handler]
|
||||
async fn me(NoApi(auth_session): NoApi<AuthSession<AnonAppController>>) -> Json<Option<User>> {
|
||||
Json(auth_session.user)
|
||||
}
|
||||
|
||||
fn me_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Auth")
|
||||
.summary("Get the logged in user")
|
||||
.description("Answers `null` when nobody is logged in.")
|
||||
}
|
||||
|
||||
/// Asking for an `AppController` *is* the "must be logged in" check: the
|
||||
/// extractor answers 401 on its own, there is nothing to test here.
|
||||
#[debug_handler]
|
||||
async fn logout(
|
||||
_ac: AppController,
|
||||
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
|
||||
) -> Result<(), StatusCode> {
|
||||
debug!("[HANDLER] logout");
|
||||
|
||||
if auth_session.user.is_none() {
|
||||
debug!("[HANDLER] logout failed: no active session");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
match auth_session.logout().await {
|
||||
Ok(_) => {
|
||||
debug!("[HANDLER] logout successful");
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
error!("[HANDLER] logout failed: {err:?}");
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
|
|
@ -65,10 +87,7 @@ async fn logout(
|
|||
}
|
||||
|
||||
fn logout_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.summary("logout the user")
|
||||
.tag("Auth")
|
||||
.response::<401, ()>()
|
||||
.security_requirement("session_cookie")
|
||||
op.tag("Auth").summary("Log the user out").response::<401, ()>()
|
||||
}
|
||||
|
||||
#[derive(Debug, JsonSchema, Serialize)]
|
||||
|
|
@ -81,18 +100,18 @@ async fn whiskey_authorize(
|
|||
aac: AnonAppController,
|
||||
) -> Result<Json<GetAuthorizeResponse>, (StatusCode, String)> {
|
||||
match aac.whiskey_authorize().await {
|
||||
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
|
||||
Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => {
|
||||
info!("[HANDLER] whiskey_authorize: protocol error");
|
||||
Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned()))
|
||||
}
|
||||
Err(err) => unexpected_error("whiskey_authorize", err),
|
||||
Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })),
|
||||
}
|
||||
}
|
||||
|
||||
fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.summary("Whiskey - Get authorization URL")
|
||||
.tag("Auth")
|
||||
op.tag("Auth")
|
||||
.summary("Whiskey - Get the authorization url")
|
||||
.response::<400, ()>()
|
||||
}
|
||||
|
||||
|
|
@ -110,20 +129,17 @@ async fn whiskey_callback(
|
|||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
match aac.whiskey_callback(code, state).await {
|
||||
Ok(user) => {
|
||||
let login_res = auth_session.login(&user).await;
|
||||
|
||||
if let Err(err) = login_res {
|
||||
error!("[HANDLER] whiskey_callback failed to login the user: {err:?}");
|
||||
if let Err(err) = auth_session.login(&user).await {
|
||||
error!("[HANDLER] whiskey_callback: failed to open the session: {err:?}");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Unexpected error".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(user.into()))
|
||||
Ok(Json(user))
|
||||
}
|
||||
Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => {
|
||||
info!("[HANDLER] whiskey_callback: user not authorized (missing group)");
|
||||
info!("[HANDLER] whiskey_callback: user not authorized");
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"You are not authorized to access this yet".to_owned(),
|
||||
|
|
@ -138,8 +154,8 @@ async fn whiskey_callback(
|
|||
}
|
||||
|
||||
fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.summary("Whiskey - Callback endpoint")
|
||||
.tag("Auth")
|
||||
op.tag("Auth")
|
||||
.summary("Whiskey - Complete the login")
|
||||
.response::<400, ()>()
|
||||
.response::<403, ()>()
|
||||
}
|
||||
|
|
@ -147,34 +163,77 @@ fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation {
|
|||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct LoginDevForm {
|
||||
pub user: String,
|
||||
/// Email of one of the `dev_users` of the configuration
|
||||
user: String,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[debug_handler]
|
||||
async fn login_dev(
|
||||
aac: AnonAppController,
|
||||
NoApi(mut auth): NoApi<AuthSession<AnonAppController>>,
|
||||
NoApi(mut auth_session): NoApi<AuthSession<AnonAppController>>,
|
||||
Json(form): Json<LoginDevForm>,
|
||||
) -> Result<(), StatusCode> {
|
||||
) -> Result<Json<User>, (StatusCode, String)> {
|
||||
match aac.get_dev_user(form.user).await {
|
||||
Ok(user) => {
|
||||
if let Err(err) = auth.login(&user).await {
|
||||
error!("Login dev: failed to login the user: {err:?}");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if let Err(err) = auth_session.login(&user).await {
|
||||
error!("[HANDLER] login_dev: failed to open the session: {err:?}");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Unexpected error".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Login dev: failed to get dev user: {err:?}");
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
Ok(Json(user))
|
||||
}
|
||||
Err(ControllerError::Authn(AuthnControllerError::DevUserNotFound)) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"No such dev user in the configuration".to_owned(),
|
||||
)),
|
||||
Err(err) => unexpected_error("login_dev", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn login_dev_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Auth")
|
||||
.summary("Login with a dev user")
|
||||
.description("This function expect only the name of the user. It created the user in db if required and logins the user with that user.")
|
||||
.summary("Log in as a dev user")
|
||||
.description(
|
||||
"Debug builds only. Takes the email of one of the `dev_users` of the \
|
||||
configuration, creates the row if needed, and opens a session.",
|
||||
)
|
||||
.response::<404, ()>()
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct DevUser {
|
||||
email: String,
|
||||
firstname: String,
|
||||
name: String,
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
/// Lets the login page offer the dev users instead of asking for an email
|
||||
#[cfg(debug_assertions)]
|
||||
#[debug_handler]
|
||||
async fn dev_users() -> Json<Vec<DevUser>> {
|
||||
Json(
|
||||
crate::utils::config::get()
|
||||
.dev_users
|
||||
.iter()
|
||||
.map(|user| DevUser {
|
||||
email: user.email.clone(),
|
||||
firstname: user.firstname.clone(),
|
||||
name: user.name.clone(),
|
||||
admin: user.admin,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn dev_users_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Auth")
|
||||
.summary("List the dev users")
|
||||
.description("Debug builds only.")
|
||||
}
|
||||
|
|
|
|||
29
src/api/bikes.rs
Normal file
29
src/api/bikes.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! 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},
|
||||
transform::TransformOperation,
|
||||
};
|
||||
use axum::{Json, http::StatusCode};
|
||||
|
||||
use crate::{
|
||||
api::helpers::unexpected_error,
|
||||
core::{controller::AnonAppController, models::bike::Bike},
|
||||
};
|
||||
|
||||
pub fn routes() -> ApiRouter {
|
||||
ApiRouter::new().api_route("/", get_with(get_bikes, get_bikes_docs))
|
||||
}
|
||||
|
||||
#[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")
|
||||
}
|
||||
|
|
@ -15,6 +15,14 @@ use axum::{Extension, Json, response::IntoResponse, routing::get};
|
|||
pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
|
||||
api.title(&format!("{} API documentation", env!("CRATE_NAME")))
|
||||
.description(format!("Build version: {}", env!("GIT_HASH")).as_str())
|
||||
.tag(Tag {
|
||||
name: "Auth".to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.tag(Tag {
|
||||
name: "Bikes".to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.tag(Tag {
|
||||
name: "misc".to_owned(),
|
||||
..Default::default()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,42 @@ use aide::transform::TransformResponse;
|
|||
use axum::http::StatusCode;
|
||||
use tracing::error;
|
||||
|
||||
use crate::core::controller::ControllerError;
|
||||
use crate::core::{
|
||||
controller::{AdminAppController, AppController, ControllerError, ManagerAppController},
|
||||
models::unit::UnitId,
|
||||
};
|
||||
|
||||
/// Narrows a session down to "member of this unit", or 403.
|
||||
/// `manager(ac, unit)?` in a handler is the whole authorization check.
|
||||
pub fn manager(
|
||||
ac: AppController,
|
||||
unit: UnitId,
|
||||
) -> Result<ManagerAppController, (StatusCode, String)> {
|
||||
ac.try_into_manager(unit).map_err(|_| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You are not allowed to act for this unit".to_owned(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn manager_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||
op.description("Forbidden - the user must be part of the unit")
|
||||
}
|
||||
|
||||
/// Narrows a session down to "admin", or 403
|
||||
pub fn admin(ac: AppController) -> Result<AdminAppController, (StatusCode, String)> {
|
||||
ac.try_into_admin().map_err(|_| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"You are not allowed to perform this action".to_owned(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn admin_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||
op.description("Forbidden - the user must be an admin")
|
||||
}
|
||||
|
||||
/// Last resort branch of a handler `match`: logs the error and answers 500
|
||||
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
|
||||
|
|
@ -14,7 +49,6 @@ pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (St
|
|||
}
|
||||
|
||||
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
|
||||
#[allow(dead_code)]
|
||||
pub fn desc<T>(
|
||||
description: &str,
|
||||
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|
||||
|
|
|
|||
107
src/api/mod.rs
107
src/api/mod.rs
|
|
@ -1,31 +1,59 @@
|
|||
//! HTTP layer: routing and OpenAPI generation (aide).
|
||||
//! HTTP layer: routing, session handling and OpenAPI generation (aide).
|
||||
//!
|
||||
//! Handlers stay thin: they extract the controller, call it, and map
|
||||
//! `ControllerError` to a status code. No business logic here.
|
||||
//! Authorization is carried by the extractor a handler asks for:
|
||||
//! - `AnonAppController` always succeeds;
|
||||
//! - `AppController` resolves the session and answers **401** without one, so
|
||||
//! a route cannot accidentally be left open.
|
||||
//!
|
||||
//! Handlers stay thin: extract the controller, call it, map `ControllerError`
|
||||
//! to a status code. No business logic here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use aide::{
|
||||
OperationInput, OperationOutput,
|
||||
axum::{ApiRouter, routing::get_with},
|
||||
openapi::OpenApi,
|
||||
generate::GenContext,
|
||||
openapi::{OpenApi, Operation, Response},
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts},
|
||||
};
|
||||
use axum_login::{AuthManagerLayerBuilder, AuthSession, tower_sessions::SessionManagerLayer};
|
||||
use indexmap::IndexMap;
|
||||
use tower_sessions::{Expiry, MemoryStore, cookie::SameSite, cookie::time::Duration};
|
||||
use tracing::error;
|
||||
|
||||
use crate::core::controller::AppController;
|
||||
use crate::{
|
||||
core::controller::{AnonAppController, AppController},
|
||||
utils,
|
||||
};
|
||||
|
||||
mod auth;
|
||||
mod bikes;
|
||||
mod docs;
|
||||
mod helpers;
|
||||
|
||||
pub fn get_router(controller: AppController) -> Router {
|
||||
pub fn get_router(aac: AnonAppController) -> Router {
|
||||
aide::generate::on_error(|err| error!("aide generated error: {err}"));
|
||||
aide::generate::extract_schemas(true);
|
||||
|
||||
let config = utils::config::get();
|
||||
|
||||
// Sessions live in memory: everybody is logged out when the backend
|
||||
// restarts. Swap the store for a persistent one if that becomes a problem.
|
||||
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||
// Over plain http in development the cookie cannot be `Secure`
|
||||
.with_secure(config.get_base_url().starts_with("https://"))
|
||||
// The provider sends the browser back with a top level navigation
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::minutes(
|
||||
config.get_session_lifetime(),
|
||||
)));
|
||||
let auth_layer = AuthManagerLayerBuilder::new(aac.clone(), session_layer).build();
|
||||
|
||||
let mut api = OpenApi::default();
|
||||
ApiRouter::new()
|
||||
.api_route(
|
||||
|
|
@ -35,14 +63,18 @@ pub fn get_router(controller: AppController) -> Router {
|
|||
|op| op.tag("misc").summary("Get app version"),
|
||||
),
|
||||
)
|
||||
// `auth` carries its own `/api/...` paths, so it is merged, not nested
|
||||
.merge(auth::routes())
|
||||
.nest_api_service("/api/bikes", bikes::routes())
|
||||
.nest_api_service("/api/docs", docs::routes())
|
||||
.finish_api_with(&mut api, docs::api_docs_metadata)
|
||||
.layer(Extension(controller))
|
||||
.layer(Extension(aac))
|
||||
.layer(Extension(Arc::new(api)))
|
||||
.layer(auth_layer)
|
||||
}
|
||||
|
||||
/// Lets a handler take an `AppController` as an argument
|
||||
impl<S> FromRequestParts<S> for AppController
|
||||
/// Lets a handler take an `AnonAppController`: always available.
|
||||
impl<S> FromRequestParts<S> for AnonAppController
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
|
|
@ -51,14 +83,65 @@ where
|
|||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<AppController>()
|
||||
.get::<AnonAppController>()
|
||||
.cloned()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller is not part of the request/response bodies: nothing to document
|
||||
/// Lets a handler take an `AppController`, which requires a session: asking for
|
||||
/// it *is* the authentication check.
|
||||
impl<S> FromRequestParts<S> for AppController
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
use axum::RequestPartsExt;
|
||||
|
||||
let aac = parts
|
||||
.extensions
|
||||
.get::<AnonAppController>()
|
||||
.cloned()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let session = parts
|
||||
.extract::<AuthSession<AnonAppController>>()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let user = session.user.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
Ok(aac.auth(user))
|
||||
}
|
||||
}
|
||||
|
||||
// The controllers are not part of the request/response bodies, but asking for
|
||||
// an `AppController` documents the 401 and the cookie requirement.
|
||||
impl OperationOutput for AnonAppController {
|
||||
type Inner = Self;
|
||||
}
|
||||
impl OperationInput for AnonAppController {}
|
||||
|
||||
impl OperationOutput for AppController {
|
||||
type Inner = Self;
|
||||
}
|
||||
impl OperationInput for AppController {}
|
||||
|
||||
impl OperationInput for AppController {
|
||||
fn inferred_early_responses(
|
||||
_: &mut GenContext,
|
||||
op: &mut Operation,
|
||||
) -> Vec<(Option<u16>, Response)> {
|
||||
let mut session_cookie = IndexMap::new();
|
||||
session_cookie.insert("session_cookie".to_owned(), Vec::new());
|
||||
op.security = vec![session_cookie];
|
||||
vec![(
|
||||
Some(401),
|
||||
Response {
|
||||
description: "Unauthenticated - a session is required".to_owned(),
|
||||
content: IndexMap::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,42 @@
|
|||
//! Authentication.
|
||||
//!
|
||||
//! Two ways in, both ending on the same `User` row and the same session:
|
||||
//! - Whiskey (OIDC), the real one;
|
||||
//! - a user taken from `dev_users` in the configuration, debug builds only.
|
||||
//!
|
||||
//! `AppController` is also the `axum-login` backend: the session stores a user
|
||||
//! id, and `get_user` loads it back on every request.
|
||||
|
||||
use axum_login::{AuthUser, AuthnBackend};
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
controller::{AnonAppController, ControllerError},
|
||||
models::user::{User, UserNoId},
|
||||
models::user::{NewUser, User, UserId},
|
||||
},
|
||||
utils::whiskey::{WhiskeyError, authorize, callback},
|
||||
utils::whiskey::{UserInfoData, WhiskeyError, authorize, callback},
|
||||
};
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i32;
|
||||
type Id = UserId;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Invalidates the sessions of a user whose provider identity changed
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
&self.oidc_sub.as_bytes()
|
||||
self.oidc_sub.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthnBackend for AnonAppController {
|
||||
type User = User;
|
||||
/// Login always goes through `whiskey_callback` or `get_dev_user`, which
|
||||
/// hand a `User` straight to `AuthSession::login`. There are no credentials
|
||||
/// to verify here.
|
||||
type Credentials = ();
|
||||
type Error = ControllerError;
|
||||
|
||||
|
|
@ -37,94 +51,139 @@ impl AuthnBackend for AnonAppController {
|
|||
&self,
|
||||
user_id: &axum_login::UserId<Self>,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
Ok(Some(self.db.get_user(user_id.clone()).await?))
|
||||
match self.db.get_user(*user_id).await {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
// The session outlived the user: treat it as logged out
|
||||
Err(crate::core::repositories::RepositoryError::NotFound(_)) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::AnonAppController {
|
||||
impl AnonAppController {
|
||||
/// Step 1 of the login: builds the provider url the browser must go to, and
|
||||
/// remembers the pkce verifier and the nonce under the csrf token.
|
||||
pub async fn whiskey_authorize(&self) -> Result<String, ControllerError> {
|
||||
// Cheap enough to piggyback on the login, and keeps the table small
|
||||
if let Ok(count) = self.db.delete_expired_whiskey_data().await
|
||||
&& count > 0
|
||||
{
|
||||
debug!("swept {count} expired oidc states");
|
||||
}
|
||||
|
||||
match authorize().await {
|
||||
Ok((redirect_to, authorize_backend_data)) => {
|
||||
self.db.save_whiskey_data(authorize_backend_data).await?;
|
||||
Ok(redirect_to)
|
||||
}
|
||||
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
|
||||
AuthnControllerError::OIDCProtocolError,
|
||||
)),
|
||||
Err(WhiskeyError::ProtocolError) => {
|
||||
Err(AuthnControllerError::OIDCProtocolError.into())
|
||||
}
|
||||
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
||||
"Originated from Whiskey".to_string(),
|
||||
"Originated from Whiskey".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_create_user_oidc(
|
||||
&self,
|
||||
sciper: String,
|
||||
firstname: String,
|
||||
name: String,
|
||||
sub: String,
|
||||
email: String,
|
||||
) -> Result<User, ControllerError> {
|
||||
self.db
|
||||
.upsert_user(UserNoId {
|
||||
external_id: Some(sciper),
|
||||
firstname,
|
||||
name,
|
||||
email,
|
||||
oidc_sub: sub,
|
||||
units: vec![], //TODO use real units
|
||||
admin: false,
|
||||
})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Step 2: the browser comes back with a code, we exchange it and upsert
|
||||
/// the user the provider describes.
|
||||
pub async fn whiskey_callback(
|
||||
&self,
|
||||
code: String,
|
||||
state: String,
|
||||
) -> Result<User, ControllerError> {
|
||||
let backend_data = self.db.get_whiskey_data(state.clone()).await?;
|
||||
// Consumes the state: a callback can never be replayed
|
||||
let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| {
|
||||
debug!("unknown, already used or expired oidc state");
|
||||
AuthnControllerError::OIDCProtocolError
|
||||
})?;
|
||||
|
||||
match callback(code, state, backend_data).await {
|
||||
Ok(user_info) => {
|
||||
self.get_or_create_user_oidc(
|
||||
user_info.sciper,
|
||||
user_info.firstname,
|
||||
user_info.name,
|
||||
user_info.sub,
|
||||
user_info.email,
|
||||
)
|
||||
.await
|
||||
Ok(user_info) => self.upsert_oidc_user(user_info).await,
|
||||
Err(WhiskeyError::ProtocolError) => {
|
||||
Err(AuthnControllerError::OIDCProtocolError.into())
|
||||
}
|
||||
Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn(
|
||||
AuthnControllerError::OIDCProtocolError,
|
||||
)),
|
||||
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
|
||||
"originated from Whiskey".to_string(),
|
||||
"originated from Whiskey".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub async fn get_dev_user(&self, username: String) -> Result<User, ControllerError> {
|
||||
use crate::core::repositories::RepositoryError;
|
||||
async fn upsert_oidc_user(&self, info: UserInfoData) -> Result<User, ControllerError> {
|
||||
let user = self
|
||||
.db
|
||||
.upsert_user(NewUser {
|
||||
external_id: Some(info.sciper),
|
||||
firstname: info.firstname,
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
oidc_sub: info.sub,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let user = self.db.get_user_external_id(username.clone()).await;
|
||||
let user = if let Err(RepositoryError::NotFound(_)) = user {
|
||||
use crate::utils::config;
|
||||
|
||||
let user = config::get()
|
||||
.get_dev_users()
|
||||
.into_iter()
|
||||
.find(|u| u.external_id.clone().is_some_and(|u| u == username))
|
||||
.ok_or(AuthnControllerError::DevUserNotFound)?;
|
||||
|
||||
self.db.upsert_user(user).await?
|
||||
} else {
|
||||
user?
|
||||
// Whiskey is authoritative on the units: a user removed from a group
|
||||
// there must lose it here too. But only when it actually told us —
|
||||
// `None` means the claim was absent, and wiping the units on that would
|
||||
// silently strip everyone's access.
|
||||
let Some(groups) = info.groups else {
|
||||
debug!(
|
||||
"no groups from Whiskey for user {}, units left untouched",
|
||||
user.id
|
||||
);
|
||||
return Ok(user);
|
||||
};
|
||||
|
||||
Ok(user)
|
||||
// Resolves the group names to units, creating the ones we have never
|
||||
// seen: a brand new group in Whiskey must not break the login.
|
||||
let units = self.db.upsert_units(&groups).await?;
|
||||
|
||||
if units == user.units {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"units of user {}: {:?} -> {:?}",
|
||||
user.id,
|
||||
user.units.iter().map(|u| &u.name).collect::<Vec<_>>(),
|
||||
units.iter().map(|u| &u.name).collect::<Vec<_>>()
|
||||
);
|
||||
let ids = units.iter().map(|u| u.id).collect();
|
||||
self.db.set_user_units(user.id, ids).await?;
|
||||
self.db.get_user(user.id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Logs in one of the `dev_users` of the configuration, picked by email.
|
||||
/// The row is created on first use, then kept in sync with the config.
|
||||
#[cfg(debug_assertions)]
|
||||
pub async fn get_dev_user(&self, email: String) -> Result<User, ControllerError> {
|
||||
use crate::utils::config;
|
||||
|
||||
let dev_user = config::get()
|
||||
.dev_users
|
||||
.iter()
|
||||
.find(|user| user.email == email)
|
||||
.ok_or(AuthnControllerError::DevUserNotFound)?
|
||||
.clone();
|
||||
|
||||
// Namespaced so a dev user can never collide with a real Whiskey subject
|
||||
let user = self
|
||||
.db
|
||||
.upsert_user(NewUser {
|
||||
external_id: dev_user.external_id.clone(),
|
||||
firstname: dev_user.firstname.clone(),
|
||||
name: dev_user.name.clone(),
|
||||
email: dev_user.email.clone(),
|
||||
oidc_sub: format!("dev:{}", dev_user.email),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Unlike the Whiskey path, the configuration is authoritative here
|
||||
let units = self.db.upsert_units(&dev_user.units).await?;
|
||||
let ids = units.iter().map(|u| u.id).collect();
|
||||
self.db.set_user_units(user.id, ids).await?;
|
||||
self.db.set_user_admin(user.id, dev_user.admin).await?;
|
||||
|
||||
self.db.get_user(user.id).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +195,6 @@ pub enum AuthnControllerError {
|
|||
NotAuthenticated,
|
||||
#[error("Not authorized")]
|
||||
NotAuthorized,
|
||||
#[error("Dev user does not exists")]
|
||||
#[error("Dev user does not exist")]
|
||||
DevUserNotFound,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
controller::ControllerError,
|
||||
controller::{AdminAppController, AnonAppController, ControllerError},
|
||||
models::bike::{Bike, BikeId, BikeStatus, NewBike},
|
||||
};
|
||||
|
||||
impl super::AppController {
|
||||
/// Reading the fleet needs no session: the reservation form shows it before
|
||||
/// anybody logs in.
|
||||
impl AnonAppController {
|
||||
pub async fn get_bikes(&self) -> Result<Vec<Bike>, ControllerError> {
|
||||
self.db.get_bikes().await.map_err(Into::into)
|
||||
}
|
||||
|
|
@ -14,6 +16,10 @@ impl super::AppController {
|
|||
self.db.get_bike(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Changing the fleet is an admin action
|
||||
impl AdminAppController {
|
||||
pub async fn create_bike(&self, bike: NewBike) -> Result<Bike, ControllerError> {
|
||||
if bike.key_quantity < 0 || bike.name.trim().is_empty() {
|
||||
return Err(BikesControllerError::BikeInvalid.into());
|
||||
|
|
|
|||
|
|
@ -1,32 +1,126 @@
|
|||
//! The controller holds the business logic: it is the only place that knows the
|
||||
//! rules of the app. It talks to the outside world through the repository traits,
|
||||
//! so it depends neither on axum nor on sqlx.
|
||||
//! rules of the app. It talks to the outside world through the repository
|
||||
//! traits, so it depends neither on axum nor on sqlx.
|
||||
//!
|
||||
//! One file per domain area, each one adding methods to `AppController` through
|
||||
//! `impl super::AppController`.
|
||||
//! Authorization is carried by the **type**, not by a check inside the
|
||||
//! handlers. Each level derefs into the one below, so a manager can do
|
||||
//! everything a logged in user can:
|
||||
//!
|
||||
//! ```text
|
||||
//! AnonAppController anybody, logged in or not
|
||||
//! └─ AppController a logged in user (the api extractor answers 401 without a session)
|
||||
//! ├─ ManagerAppController a member of one unit, for that unit
|
||||
//! └─ AdminAppController an admin
|
||||
//! ```
|
||||
//!
|
||||
//! A handler that takes an `AppController` cannot be reached anonymously: there
|
||||
//! is no way to forget the check.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
controller::{bikes::BikesControllerError, reservations::ReservationsControllerError},
|
||||
controller::{
|
||||
authn::AuthnControllerError, bikes::BikesControllerError,
|
||||
reservations::ReservationsControllerError,
|
||||
},
|
||||
models::{unit::UnitId, user::User},
|
||||
repositories::{DatabaseRepository, RepositoryError},
|
||||
};
|
||||
|
||||
pub mod authn;
|
||||
pub mod bikes;
|
||||
pub mod reservations;
|
||||
pub mod users;
|
||||
|
||||
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
|
||||
/// Entry point of the business logic, for anybody. Cheap to clone: handlers get
|
||||
/// one per request.
|
||||
#[derive(Clone)]
|
||||
pub struct AnonAppController {
|
||||
pub(crate) db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl AnonAppController {
|
||||
pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Called by the api extractor once the session has been resolved
|
||||
pub fn auth(self, user: User) -> AppController {
|
||||
AppController { inner: self, user }
|
||||
}
|
||||
}
|
||||
|
||||
/// A logged in user.
|
||||
#[derive(Clone)]
|
||||
pub struct AppController {
|
||||
db: Arc<Box<dyn DatabaseRepository + Send + Sync>>,
|
||||
inner: AnonAppController,
|
||||
user: User,
|
||||
}
|
||||
|
||||
impl Deref for AppController {
|
||||
type Target = AnonAppController;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl AppController {
|
||||
pub fn new(db: Arc<Box<dyn DatabaseRepository + Send + Sync>>) -> Self {
|
||||
Self { db }
|
||||
pub fn user(&self) -> &User {
|
||||
&self.user
|
||||
}
|
||||
|
||||
/// An admin manages every unit: refusing them here would only produce
|
||||
/// surprising 403s on routes they are otherwise allowed to use.
|
||||
pub fn try_into_manager(self, unit: UnitId) -> Result<ManagerAppController, ControllerError> {
|
||||
if self.user.admin || self.user.units.iter().any(|u| u.id == unit) {
|
||||
Ok(ManagerAppController { inner: self, unit })
|
||||
} else {
|
||||
Err(ControllerError::ManagerAuthorizationError(unit))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_into_admin(self) -> Result<AdminAppController, ControllerError> {
|
||||
if self.user.admin {
|
||||
Ok(AdminAppController { inner: self })
|
||||
} else {
|
||||
Err(ControllerError::AdminAuthorizationError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of `unit`, acting for that unit
|
||||
#[derive(Clone)]
|
||||
pub struct ManagerAppController {
|
||||
inner: AppController,
|
||||
pub(crate) unit: UnitId,
|
||||
}
|
||||
|
||||
impl Deref for ManagerAppController {
|
||||
type Target = AppController;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdminAppController {
|
||||
inner: AppController,
|
||||
}
|
||||
|
||||
impl Deref for AdminAppController {
|
||||
type Target = AppController;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminAppController {
|
||||
pub fn into_manager(self, unit: UnitId) -> ManagerAppController {
|
||||
ManagerAppController {
|
||||
inner: self.inner,
|
||||
unit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +132,15 @@ pub enum ControllerError {
|
|||
InternalError(String),
|
||||
#[error("Generic repository error")]
|
||||
RepositoryError(#[from] RepositoryError),
|
||||
#[error("Authorization denied: the user is not part of the unit {0:?}")]
|
||||
ManagerAuthorizationError(UnitId),
|
||||
#[error("Authorization denied: the user is not an admin")]
|
||||
AdminAuthorizationError,
|
||||
#[error("Object's unit modification is not allowed")]
|
||||
ImmutableUnitModificationError,
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
Authn(#[from] AuthnControllerError),
|
||||
#[error("Bike specific error: {0}")]
|
||||
Bike(#[from] BikesControllerError),
|
||||
#[error("Reservation specific error: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::core::{
|
||||
controller::ControllerError,
|
||||
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
|
||||
models::{
|
||||
bike::BikeStatus,
|
||||
reservation::{
|
||||
|
|
@ -11,7 +11,8 @@ use crate::core::{
|
|||
},
|
||||
};
|
||||
|
||||
impl super::AppController {
|
||||
/// Reading the reservations needs no session: the calendar is public.
|
||||
impl AnonAppController {
|
||||
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
|
||||
self.db.get_reservations().await.map_err(Into::into)
|
||||
}
|
||||
|
|
@ -30,6 +31,11 @@ impl super::AppController {
|
|||
self.db.get_reservation(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Filing a request is done in one's own name: the requester is the session
|
||||
/// user, never something the client gets to choose.
|
||||
impl AppController {
|
||||
pub async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
|
|
@ -44,11 +50,14 @@ impl super::AppController {
|
|||
}
|
||||
}
|
||||
self.db
|
||||
.create_reservation(reservation)
|
||||
.create_reservation(reservation, self.user().id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// Touching an existing reservation is reserved to its unit (or an admin)
|
||||
impl ManagerAppController {
|
||||
pub async fn update_reservation(
|
||||
&self,
|
||||
reservation: ReservationEdit,
|
||||
|
|
@ -58,6 +67,9 @@ impl super::AppController {
|
|||
}
|
||||
|
||||
let current = self.db.get_reservation(reservation.id).await?;
|
||||
if current.unit.id != self.unit {
|
||||
return Err(ControllerError::ImmutableUnitModificationError);
|
||||
}
|
||||
if current.status.is_final() {
|
||||
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
|
||||
}
|
||||
|
|
@ -73,7 +85,11 @@ impl super::AppController {
|
|||
id: ReservationId,
|
||||
status: ReservationStatus,
|
||||
) -> Result<(), ControllerError> {
|
||||
let current = self.db.get_reservation(id).await?.status;
|
||||
let current = self.db.get_reservation(id).await?;
|
||||
if current.unit.id != self.unit {
|
||||
return Err(ControllerError::ImmutableUnitModificationError);
|
||||
}
|
||||
let current = current.status;
|
||||
if current == status {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -87,6 +103,9 @@ impl super::AppController {
|
|||
}
|
||||
|
||||
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
|
||||
if self.db.get_reservation(id).await?.unit.id != self.unit {
|
||||
return Err(ControllerError::ImmutableUnitModificationError);
|
||||
}
|
||||
self.db.delete_reservation(id).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@
|
|||
//! provider on login. The only decision that belongs to us is `admin`.
|
||||
|
||||
use crate::core::{
|
||||
controller::ControllerError,
|
||||
models::user::{NewUser, User, UserId},
|
||||
controller::{AdminAppController, AnonAppController, ControllerError},
|
||||
models::user::{User, UserId},
|
||||
};
|
||||
|
||||
impl super::AppController {
|
||||
pub async fn login(&self, user: NewUser) -> Result<User, ControllerError> {
|
||||
self.db.upsert_user(user).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
impl AnonAppController {
|
||||
pub async fn get_user(&self, id: UserId) -> Result<User, ControllerError> {
|
||||
self.db.get_user(id).await.map_err(Into::into)
|
||||
}
|
||||
|
|
@ -26,6 +22,9 @@ impl super::AppController {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl AdminAppController {
|
||||
pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> {
|
||||
self.db.set_user_admin(id, admin).await.map_err(Into::into)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ use chrono::{DateTime, Utc};
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::models::{bike::BikeId, unit::UnitId, user::UserId};
|
||||
use crate::core::models::{
|
||||
bike::BikeId,
|
||||
unit::{Unit, UnitId},
|
||||
user::{UserId, UserSummary},
|
||||
};
|
||||
|
||||
pub type ReservationId = i32;
|
||||
|
||||
|
|
@ -42,11 +46,11 @@ impl ReservationStatus {
|
|||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct Reservation {
|
||||
pub id: ReservationId,
|
||||
pub unit: UnitId,
|
||||
pub unit: Unit,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester: UserId,
|
||||
pub users: Vec<UserId>,
|
||||
pub users: Vec<UserSummary>,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
|
|
@ -58,7 +62,6 @@ pub struct NewReservation {
|
|||
pub unit: UnitId,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester: UserId,
|
||||
pub users: Vec<UserId>,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
|
|
@ -79,9 +82,7 @@ pub struct ReservationEdit {
|
|||
|
||||
impl NewReservation {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.end_time > self.start_time
|
||||
&& !self.bikes.is_empty()
|
||||
&& self.users.contains(&self.requester)
|
||||
self.end_time > self.start_time && !self.bikes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,11 @@
|
|||
pub type UnitId = String;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type UnitId = i32;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct Unit {
|
||||
pub id: UnitId,
|
||||
/// The Whiskey group name
|
||||
pub name: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::models::unit::UnitId;
|
||||
use crate::core::models::unit::Unit;
|
||||
|
||||
pub type UserId = i32;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ pub struct User {
|
|||
pub name: String,
|
||||
pub email: String,
|
||||
pub oidc_sub: String,
|
||||
pub units: Vec<UnitId>,
|
||||
pub units: Vec<Unit>,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
|
|
@ -24,5 +24,14 @@ pub struct NewUser {
|
|||
pub name: String,
|
||||
pub email: String,
|
||||
pub oidc_sub: String,
|
||||
pub units: Vec<UnitId>,
|
||||
}
|
||||
|
||||
/// A user as they appear inside another object (a reservation, ...): enough to
|
||||
/// show who they are, without dragging their units along.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct UserSummary {
|
||||
pub id: UserId,
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,27 @@ use async_trait::async_trait;
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::core::repositories::{
|
||||
bikes_repository::BikesRepository, reservations_repository::ReservationsRepository,
|
||||
bikes_repository::BikesRepository, oidc_states_repository::OidcStatesRepository,
|
||||
reservations_repository::ReservationsRepository, units_repository::UnitsRepository,
|
||||
users_repository::UsersRepository,
|
||||
};
|
||||
|
||||
pub mod bikes_repository;
|
||||
pub mod oidc_states_repository;
|
||||
pub mod reservations_repository;
|
||||
pub mod units_repository;
|
||||
pub mod users_repository;
|
||||
|
||||
/// Add every new repository trait here so the controller can use it through `db`
|
||||
#[async_trait]
|
||||
pub trait DatabaseRepository:
|
||||
UsersRepository + BikesRepository + ReservationsRepository + Send + Sync
|
||||
UsersRepository
|
||||
+ BikesRepository
|
||||
+ ReservationsRepository
|
||||
+ UnitsRepository
|
||||
+ OidcStatesRepository
|
||||
+ Send
|
||||
+ Sync
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,19 @@ use crate::{core::repositories::RepositoryError, utils::whiskey};
|
|||
|
||||
#[async_trait]
|
||||
pub trait OidcStatesRepository {
|
||||
async fn get_whiskey_data(
|
||||
/// Reads the state back **and consumes it**: an authorization state is
|
||||
/// single use, otherwise the callback could be replayed. Returns
|
||||
/// `NotFound` when the state is unknown, already used, or expired.
|
||||
async fn take_whiskey_data(
|
||||
&self,
|
||||
csrf_token: String,
|
||||
) -> Result<whiskey::AuthorizeBackendData, RepositoryError>;
|
||||
|
||||
async fn save_whiskey_data(
|
||||
&self,
|
||||
data: whiskey::AuthorizeBackendData,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Drops the states nobody came back for
|
||||
async fn delete_expired_whiskey_data(&self) -> Result<u64, RepositoryError>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use crate::core::{
|
|||
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
|
||||
},
|
||||
unit::UnitId,
|
||||
user::UserId,
|
||||
},
|
||||
repositories::RepositoryError,
|
||||
};
|
||||
|
|
@ -19,9 +20,12 @@ pub trait ReservationsRepository {
|
|||
) -> Result<Vec<Reservation>, RepositoryError>;
|
||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
|
||||
|
||||
/// Always stored as `Requested`: the state machine starts here. The
|
||||
/// requester comes from the session, not from the request body.
|
||||
async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
requester: UserId,
|
||||
) -> Result<Reservation, RepositoryError>;
|
||||
|
||||
async fn update_reservation(&self, reservation: ReservationEdit)
|
||||
|
|
|
|||
12
src/core/repositories/units_repository.rs
Normal file
12
src/core/repositories/units_repository.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::core::{models::unit::Unit, repositories::RepositoryError};
|
||||
|
||||
#[async_trait]
|
||||
pub trait UnitsRepository {
|
||||
async fn get_units(&self) -> Result<Vec<Unit>, RepositoryError>;
|
||||
|
||||
/// Resolves group names to units, creating the ones we have never seen.
|
||||
/// This is what keeps a brand new Whiskey group from breaking a login.
|
||||
async fn upsert_units(&self, names: &[String]) -> Result<Vec<Unit>, RepositoryError>;
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::core::{
|
||||
models::user::{NewUser, User, UserId},
|
||||
models::{
|
||||
unit::UnitId,
|
||||
user::{NewUser, User, UserId},
|
||||
},
|
||||
repositories::RepositoryError,
|
||||
};
|
||||
|
||||
|
|
@ -12,7 +15,14 @@ pub trait UsersRepository {
|
|||
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError>;
|
||||
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
|
||||
|
||||
/// Creates the user, or refreshes the row from the provider claims.
|
||||
/// `oidc_sub` is the identity. `admin` and the units are ours and are left
|
||||
/// untouched, so a login never demotes anybody nor loses their units.
|
||||
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
|
||||
|
||||
/// Replaces the whole set of units the user belongs to
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>)
|
||||
-> Result<(), RepositoryError>;
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
|
|
|||
14
src/main.rs
14
src/main.rs
|
|
@ -9,7 +9,7 @@ use tower_http::services::{ServeDir, ServeFile};
|
|||
use tracing::info;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::{core::controller::AppController, services::database::SqlxDatabase};
|
||||
use crate::{core::controller::AnonAppController, services::database::SqlxDatabase};
|
||||
|
||||
mod api;
|
||||
mod core;
|
||||
|
|
@ -32,15 +32,17 @@ async fn main() {
|
|||
.await
|
||||
.expect("Unable to connect to database");
|
||||
|
||||
let controller = AppController::new(Arc::new(Box::new(db)));
|
||||
let aac = AnonAppController::new(Arc::new(Box::new(db)));
|
||||
|
||||
// Anything that is not an api route is served from the built frontend,
|
||||
// falling back on index.html so the vue router can handle the path.
|
||||
// (`fallback` and not `not_found_service`, which would force a 404 status)
|
||||
let app =
|
||||
api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
|
||||
ServeFile::new(format!("{}/index.html", config.frontend_dir)),
|
||||
));
|
||||
let app = api::get_router(aac).fallback_service(
|
||||
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!(
|
||||
"{}/index.html",
|
||||
config.frontend_dir
|
||||
))),
|
||||
);
|
||||
|
||||
let bind_address = config.get_bind_address();
|
||||
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
//! data must be present (`cargo sqlx prepare`).
|
||||
|
||||
mod bikes;
|
||||
mod oidc_states;
|
||||
mod reservations;
|
||||
mod units;
|
||||
mod users;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
//! Short lived state of an in-flight OIDC authorization.
|
||||
//!
|
||||
//! The whole `AuthorizeBackendData` is stored as json under the csrf token: it
|
||||
//! is opaque to the database, and nothing else ever reads it.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{prelude::FromRow, query, query_as};
|
||||
use chrono::{Duration, Utc};
|
||||
use sqlx::{query, query_as};
|
||||
|
||||
use crate::{
|
||||
core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository},
|
||||
|
|
@ -7,66 +13,66 @@ use crate::{
|
|||
utils::whiskey,
|
||||
};
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct DBOidcStateData {
|
||||
/// A user has this long to come back from the provider
|
||||
const STATE_LIFETIME_MINUTES: i64 = 15;
|
||||
|
||||
struct OidcStateDB {
|
||||
key: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
impl TryFrom<whiskey::AuthorizeBackendData> for DBOidcStateData {
|
||||
impl TryFrom<whiskey::AuthorizeBackendData> for OidcStateDB {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> {
|
||||
Ok(DBOidcStateData {
|
||||
Ok(OidcStateDB {
|
||||
key: value.csrf_token(),
|
||||
data: serde_json::to_string(&value)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<whiskey::AuthorizeBackendData> for DBOidcStateData {
|
||||
impl TryFrom<OidcStateDB> for whiskey::AuthorizeBackendData {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_into(self) -> Result<whiskey::AuthorizeBackendData, Self::Error> {
|
||||
let value: whiskey::AuthorizeBackendData = serde_json::from_str(&self.data)?;
|
||||
if value.csrf_token() != self.key {
|
||||
fn try_from(value: OidcStateDB) -> Result<Self, Self::Error> {
|
||||
let data: whiskey::AuthorizeBackendData = serde_json::from_str(&value.data)?;
|
||||
if data.csrf_token() != value.key {
|
||||
return Err(RepositoryError::TypeConversion(
|
||||
"key of whiskey authorize backend data doesn't match".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OidcStatesRepository for SqlxDatabase {
|
||||
async fn get_whiskey_data(
|
||||
async fn take_whiskey_data(
|
||||
&self,
|
||||
csrf_token: String,
|
||||
) -> Result<whiskey::AuthorizeBackendData, RepositoryError> {
|
||||
let cutoff = Utc::now() - Duration::minutes(STATE_LIFETIME_MINUTES);
|
||||
query_as!(
|
||||
DBOidcStateData,
|
||||
r#"SELECT
|
||||
key,
|
||||
data
|
||||
FROM oidc_states
|
||||
WHERE key = $1"#,
|
||||
csrf_token
|
||||
OidcStateDB,
|
||||
r#"DELETE FROM oidc_states
|
||||
WHERE key = $1 AND created_at > $2
|
||||
RETURNING key, data"#,
|
||||
csrf_token,
|
||||
cutoff
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.try_into()
|
||||
}
|
||||
|
||||
// TODO: Expire the data and remove it periodically
|
||||
async fn save_whiskey_data(
|
||||
&self,
|
||||
data: whiskey::AuthorizeBackendData,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let data: DBOidcStateData = data.try_into()?;
|
||||
let data: OidcStateDB = data.try_into()?;
|
||||
query!(
|
||||
r#"INSERT INTO oidc_states
|
||||
(key, data)
|
||||
r#"INSERT INTO oidc_states (key, data)
|
||||
VALUES ($1, $2)"#,
|
||||
data.key,
|
||||
data.data
|
||||
|
|
@ -75,4 +81,14 @@ impl OidcStatesRepository for SqlxDatabase {
|
|||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired_whiskey_data(&self) -> Result<u64, RepositoryError> {
|
||||
let cutoff = Utc::now() - Duration::minutes(STATE_LIFETIME_MINUTES);
|
||||
Ok(
|
||||
query!(r#"DELETE FROM oidc_states WHERE created_at <= $1"#, cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::{query, query_as};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -13,7 +14,8 @@ use crate::{
|
|||
reservation::{
|
||||
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
|
||||
},
|
||||
unit::UnitId,
|
||||
unit::{Unit, UnitId},
|
||||
user::UserId,
|
||||
},
|
||||
repositories::{RepositoryError, reservations_repository::ReservationsRepository},
|
||||
},
|
||||
|
|
@ -59,31 +61,37 @@ impl From<ReservationStatus> for ReservationStatusDB {
|
|||
|
||||
struct ReservationDB {
|
||||
pub id: i32,
|
||||
pub unit: String,
|
||||
pub unit_id: i32,
|
||||
pub unit_name: String,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester_id: i32,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub status: ReservationStatusDB,
|
||||
pub users: Vec<i32>,
|
||||
pub users: Value,
|
||||
pub bikes: Vec<i32>,
|
||||
}
|
||||
|
||||
impl From<ReservationDB> for Reservation {
|
||||
fn from(value: ReservationDB) -> Self {
|
||||
Reservation {
|
||||
impl TryFrom<ReservationDB> for Reservation {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
|
||||
Ok(Reservation {
|
||||
id: value.id,
|
||||
unit: value.unit,
|
||||
unit: Unit {
|
||||
id: value.unit_id,
|
||||
name: value.unit_name,
|
||||
},
|
||||
start_time: value.start_time,
|
||||
end_time: value.end_time,
|
||||
requester: value.requester_id,
|
||||
users: value.users,
|
||||
users: serde_json::from_value(value.users)?,
|
||||
telegram: value.telegram,
|
||||
description: value.description,
|
||||
bikes: value.bikes,
|
||||
status: value.status.into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,29 +143,38 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
ReservationDB,
|
||||
r#"SELECT
|
||||
r.id,
|
||||
r.unit,
|
||||
r.unit_id,
|
||||
un."name" AS unit_name,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.requester_id,
|
||||
r.telegram,
|
||||
r."description",
|
||||
r.status AS "status: ReservationStatusDB",
|
||||
ARRAY(
|
||||
SELECT user_id FROM reservations_users
|
||||
WHERE reservation_id = r.id ORDER BY user_id
|
||||
) AS "users!",
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', u.id,
|
||||
'firstname', u.firstname,
|
||||
'name', u."name",
|
||||
'email', u.email
|
||||
) ORDER BY u."name", u.firstname)
|
||||
FROM reservations_users ru
|
||||
JOIN users u ON u.id = ru.user_id
|
||||
WHERE ru.reservation_id = r.id
|
||||
), '[]'::json) AS "users!",
|
||||
ARRAY(
|
||||
SELECT bike_id FROM reservations_bikes
|
||||
WHERE reservation_id = r.id ORDER BY bike_id
|
||||
) AS "bikes!"
|
||||
FROM reservations r
|
||||
JOIN units un ON un.id = r.unit_id
|
||||
ORDER BY r.start_time DESC"#
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
async fn get_unit_reservations(
|
||||
|
|
@ -168,31 +185,40 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
ReservationDB,
|
||||
r#"SELECT
|
||||
r.id,
|
||||
r.unit,
|
||||
r.unit_id,
|
||||
un."name" AS unit_name,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.requester_id,
|
||||
r.telegram,
|
||||
r."description",
|
||||
r.status AS "status: ReservationStatusDB",
|
||||
ARRAY(
|
||||
SELECT user_id FROM reservations_users
|
||||
WHERE reservation_id = r.id ORDER BY user_id
|
||||
) AS "users!",
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', u.id,
|
||||
'firstname', u.firstname,
|
||||
'name', u."name",
|
||||
'email', u.email
|
||||
) ORDER BY u."name", u.firstname)
|
||||
FROM reservations_users ru
|
||||
JOIN users u ON u.id = ru.user_id
|
||||
WHERE ru.reservation_id = r.id
|
||||
), '[]'::json) AS "users!",
|
||||
ARRAY(
|
||||
SELECT bike_id FROM reservations_bikes
|
||||
WHERE reservation_id = r.id ORDER BY bike_id
|
||||
) AS "bikes!"
|
||||
FROM reservations r
|
||||
WHERE r.unit = $1
|
||||
JOIN units un ON un.id = r.unit_id
|
||||
WHERE r.unit_id = $1
|
||||
ORDER BY r.start_time DESC"#,
|
||||
unit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
.map(TryInto::try_into)
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
|
||||
|
|
@ -200,46 +226,56 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
ReservationDB,
|
||||
r#"SELECT
|
||||
r.id,
|
||||
r.unit,
|
||||
r.unit_id,
|
||||
un."name" AS unit_name,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.requester_id,
|
||||
r.telegram,
|
||||
r."description",
|
||||
r.status AS "status: ReservationStatusDB",
|
||||
ARRAY(
|
||||
SELECT user_id FROM reservations_users
|
||||
WHERE reservation_id = r.id ORDER BY user_id
|
||||
) AS "users!",
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', u.id,
|
||||
'firstname', u.firstname,
|
||||
'name', u."name",
|
||||
'email', u.email
|
||||
) ORDER BY u."name", u.firstname)
|
||||
FROM reservations_users ru
|
||||
JOIN users u ON u.id = ru.user_id
|
||||
WHERE ru.reservation_id = r.id
|
||||
), '[]'::json) AS "users!",
|
||||
ARRAY(
|
||||
SELECT bike_id FROM reservations_bikes
|
||||
WHERE reservation_id = r.id ORDER BY bike_id
|
||||
) AS "bikes!"
|
||||
FROM reservations r
|
||||
JOIN units un ON un.id = r.unit_id
|
||||
WHERE r.id = $1"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.into())
|
||||
.try_into()?)
|
||||
}
|
||||
|
||||
async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
requester: UserId,
|
||||
) -> Result<Reservation, RepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// No status here: the column defaults to 'requested', the start of the
|
||||
// state machine.
|
||||
let id = query!(
|
||||
r#"INSERT INTO reservations (unit, start_time, end_time, requester_id, telegram, "description")
|
||||
r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description")
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id"#,
|
||||
reservation.unit,
|
||||
reservation.start_time,
|
||||
reservation.end_time,
|
||||
reservation.requester,
|
||||
requester,
|
||||
reservation.telegram,
|
||||
reservation.description
|
||||
)
|
||||
|
|
@ -247,22 +283,19 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
.await?
|
||||
.id;
|
||||
|
||||
Self::set_reservation_links(&mut tx, id, &reservation.users, &reservation.bikes).await?;
|
||||
// The requester is always allowed to pick the bikes up
|
||||
let mut users = reservation.users.clone();
|
||||
if !users.contains(&requester) {
|
||||
users.push(requester);
|
||||
}
|
||||
Self::set_reservation_links(&mut tx, id, &users, &reservation.bikes).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Reservation {
|
||||
id,
|
||||
unit: reservation.unit,
|
||||
start_time: reservation.start_time,
|
||||
end_time: reservation.end_time,
|
||||
requester: reservation.requester,
|
||||
users: reservation.users,
|
||||
telegram: reservation.telegram,
|
||||
description: reservation.description,
|
||||
bikes: reservation.bikes,
|
||||
status: ReservationStatus::Requested,
|
||||
})
|
||||
// Read it back through the normal query rather than rebuilding it by
|
||||
// hand: the caller gets the unit and the users exactly as any other read
|
||||
// would return them.
|
||||
self.get_reservation(id).await
|
||||
}
|
||||
|
||||
async fn update_reservation(
|
||||
|
|
@ -273,7 +306,7 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
|
||||
let result = query!(
|
||||
r#"UPDATE reservations
|
||||
SET unit = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
|
||||
SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
|
||||
WHERE id = $1"#,
|
||||
reservation.id,
|
||||
reservation.unit,
|
||||
|
|
|
|||
48
src/services/database/units.rs
Normal file
48
src/services/database/units.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Units. Rows are created on demand, from the group names Whiskey hands out.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{query, query_as};
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
models::unit::Unit,
|
||||
repositories::{RepositoryError, units_repository::UnitsRepository},
|
||||
},
|
||||
services::database::SqlxDatabase,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
impl UnitsRepository for SqlxDatabase {
|
||||
async fn get_units(&self) -> Result<Vec<Unit>, RepositoryError> {
|
||||
Ok(
|
||||
query_as!(Unit, r#"SELECT id, "name" FROM units ORDER BY "name""#)
|
||||
.fetch_all(&self.pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
async fn upsert_units(&self, names: &[String]) -> Result<Vec<Unit>, RepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
query!(
|
||||
r#"INSERT INTO units ("name")
|
||||
SELECT DISTINCT UNNEST($1::text[])
|
||||
ON CONFLICT ("name") DO NOTHING"#,
|
||||
names
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Read back after the insert: the ones that already existed are in here too
|
||||
let units = query_as!(
|
||||
Unit,
|
||||
r#"SELECT id, "name" FROM units WHERE "name" = ANY($1::text[]) ORDER BY "name""#,
|
||||
names
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(units)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use sqlx::{Executor, Postgres, query, query_as};
|
|||
use crate::{
|
||||
core::{
|
||||
models::{
|
||||
unit::UnitId,
|
||||
unit::{Unit, UnitId},
|
||||
user::{NewUser, User, UserId},
|
||||
},
|
||||
repositories::{RepositoryError, users_repository::UsersRepository},
|
||||
|
|
@ -23,7 +23,7 @@ struct UserDB {
|
|||
}
|
||||
|
||||
impl UserDB {
|
||||
fn into_user(self, units: Vec<UnitIdDB>) -> User {
|
||||
fn into_user(self, units: Vec<Unit>) -> User {
|
||||
User {
|
||||
id: self.id,
|
||||
external_id: self.external_id,
|
||||
|
|
@ -32,23 +32,23 @@ impl UserDB {
|
|||
email: self.email,
|
||||
oidc_sub: self.oidc_sub,
|
||||
admin: self.admin,
|
||||
units: units.into_iter().map(|u| u.name).collect(),
|
||||
units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnitIdDB {
|
||||
pub name: UnitId,
|
||||
}
|
||||
|
||||
impl SqlxDatabase {
|
||||
async fn user_with_units<'a, E>(user: UserDB, executor: E) -> Result<User, RepositoryError>
|
||||
where
|
||||
E: Executor<'a, Database = Postgres>,
|
||||
{
|
||||
let units = query_as!(
|
||||
UnitIdDB,
|
||||
r#"SELECT unit_name AS "name!" FROM units_users WHERE user_id = $1 ORDER BY unit_name"#,
|
||||
Unit,
|
||||
r#"SELECT u.id, u."name"
|
||||
FROM units u
|
||||
JOIN units_users uu ON uu.unit_id = u.id
|
||||
WHERE uu.user_id = $1
|
||||
ORDER BY u."name""#,
|
||||
user.id
|
||||
)
|
||||
.fetch_all(executor)
|
||||
|
|
@ -130,30 +130,27 @@ impl UsersRepository for SqlxDatabase {
|
|||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
query!(r#"DELETE FROM units_users WHERE user_id = $1"#, user_db.id)
|
||||
let user = Self::user_with_units(user_db, &mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
query!(r#"DELETE FROM units_users WHERE user_id = $1"#, id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
query!(
|
||||
r#"INSERT INTO units_users (user_id, unit_name)
|
||||
SELECT $1, UNNEST($2::text[])"#,
|
||||
user_db.id,
|
||||
&user.units
|
||||
r#"INSERT INTO units_users (user_id, unit_id)
|
||||
SELECT $1, UNNEST($2::integer[])
|
||||
ON CONFLICT DO NOTHING"#,
|
||||
id,
|
||||
&units
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(User {
|
||||
id: user_db.id,
|
||||
external_id: user_db.external_id,
|
||||
firstname: user_db.firstname,
|
||||
name: user_db.name,
|
||||
email: user_db.email,
|
||||
oidc_sub: user_db.oidc_sub,
|
||||
units: user.units,
|
||||
admin: user_db.admin,
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ use serde::Deserialize;
|
|||
pub struct ServerConfig {
|
||||
pub address: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
/// Public origin the browser reaches the app on. The OIDC redirect uri is
|
||||
/// built from it, so it must match what is registered on the provider.
|
||||
/// In development this is the vite dev server, not the backend.
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
|
|
@ -18,10 +22,38 @@ pub struct PostgresConfig {
|
|||
pub name: String,
|
||||
}
|
||||
|
||||
/// Whiskey, the AGEPoly OIDC provider
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct OidcConfig {
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// How long a session stays valid, in minutes
|
||||
pub session_lifetime: Option<i64>,
|
||||
}
|
||||
|
||||
/// A user that can be logged in without going through the provider.
|
||||
/// Only usable in debug builds, see `POST /api/login`.
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DevUserConfig {
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
/// Also the handle used to pick the user at login
|
||||
pub email: String,
|
||||
pub external_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub units: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub postgres: PostgresConfig,
|
||||
pub oidc: OidcConfig,
|
||||
#[serde(default)]
|
||||
pub dev_users: Vec<DevUserConfig>,
|
||||
/// Directory containing the built frontend
|
||||
pub frontend_dir: String,
|
||||
}
|
||||
|
|
@ -33,6 +65,20 @@ impl AppConfig {
|
|||
format!("{}:{}", address, port)
|
||||
}
|
||||
|
||||
/// Origin used to build the OIDC redirect uri. Defaults to the vite dev
|
||||
/// server, which is what a developer reaches the app on.
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.server
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or("http://localhost:5000".to_owned())
|
||||
}
|
||||
|
||||
/// Session lifetime in minutes
|
||||
pub fn get_session_lifetime(&self) -> i64 {
|
||||
self.oidc.session_lifetime.unwrap_or(60)
|
||||
}
|
||||
|
||||
pub fn get_postgresql_url(&self) -> String {
|
||||
let host = self.postgres.host.clone().unwrap_or("localhost".to_owned());
|
||||
let port = self.postgres.port.unwrap_or(5432);
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
pub mod config;
|
||||
pub mod whiskey;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ async fn get_client() -> &'static Client {
|
|||
CLIENT
|
||||
.get_or_init(|| async {
|
||||
let http_client = get_http_client();
|
||||
let config = config::get().clone();
|
||||
let config = config::get();
|
||||
let redirect_url = format!("{}/whiskey/callback", config.get_base_url());
|
||||
let config = config.clone();
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(config.oidc.issuer_url).unwrap(),
|
||||
http_client,
|
||||
|
|
@ -54,7 +56,7 @@ async fn get_client() -> &'static Client {
|
|||
Some(ClientSecret::new(config.oidc.client_secret)),
|
||||
)
|
||||
.set_redirect_uri(
|
||||
RedirectUrl::new(format!("{}/whiskey/callback", config.server.base_url)).unwrap(),
|
||||
RedirectUrl::new(redirect_url).unwrap(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
|
|
@ -83,7 +85,6 @@ pub async fn authorize() -> Result<(String, AuthorizeBackendData), WhiskeyError>
|
|||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("openid".to_owned()))
|
||||
.add_scope(Scope::new("profile".to_owned()))
|
||||
.add_scope(Scope::new("email".to_owned()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
|
|
@ -106,6 +107,7 @@ pub struct UserInfoData {
|
|||
pub name: String,
|
||||
pub firstname: String,
|
||||
pub email: String,
|
||||
pub groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
|
|
@ -214,18 +216,56 @@ pub async fn callback(
|
|||
|
||||
let sciper = claims.additional_claims().sciper.clone();
|
||||
|
||||
let groups = match claims.additional_claims().groups.clone() {
|
||||
Some(groups) => Some(groups),
|
||||
None => {
|
||||
debug!("no groups claim in the id_token, trying the userinfo endpoint");
|
||||
groups_from_userinfo(client, token_response.access_token()).await
|
||||
}
|
||||
};
|
||||
debug!("Whiskey groups for {sciper}: {groups:?}");
|
||||
|
||||
Ok(UserInfoData {
|
||||
firstname,
|
||||
name,
|
||||
sub,
|
||||
sciper,
|
||||
email,
|
||||
groups,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
async fn groups_from_userinfo(
|
||||
client: &Client,
|
||||
access_token: &openidconnect::AccessToken,
|
||||
) -> Option<Vec<String>> {
|
||||
let request = match client.user_info(access_token.to_owned(), None) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
debug!("no userinfo endpoint advertised: {err:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match request
|
||||
.request_async(get_http_client())
|
||||
.await
|
||||
.map(|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
|
||||
claims.additional_claims().groups.clone()
|
||||
}) {
|
||||
Ok(groups) => groups,
|
||||
Err(err) => {
|
||||
debug!("userinfo request failed: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||
pub struct WhiskeyClaims {
|
||||
pub sciper: String,
|
||||
#[serde(default)]
|
||||
pub groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl openidconnect::AdditionalClaims for WhiskeyClaims {}
|
||||
|
|
@ -259,3 +299,51 @@ pub type Client = openidconnect::Client<
|
|||
openidconnect::EndpointMaybeSet,
|
||||
openidconnect::EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WhiskeyClaims;
|
||||
|
||||
fn parse(json: &str) -> WhiskeyClaims {
|
||||
serde_json::from_str(json).expect("claims should parse")
|
||||
}
|
||||
|
||||
/// An absent claim must not be read as "belongs to no group": that would
|
||||
/// wipe the units of every user at every login.
|
||||
#[test]
|
||||
fn absent_groups_claim_is_none() {
|
||||
assert_eq!(parse(r#"{"sciper":"123456"}"#).groups, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_are_read() {
|
||||
assert_eq!(
|
||||
parse(r#"{"sciper":"123456","groups":["agepoly","balelec"]}"#).groups,
|
||||
Some(vec!["agepoly".to_owned(), "balelec".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
/// Distinct from the absent case: here Whiskey did answer, and the answer
|
||||
/// is that the user is in nothing.
|
||||
#[test]
|
||||
fn empty_groups_claim_is_some_empty() {
|
||||
assert_eq!(
|
||||
parse(r#"{"sciper":"123456","groups":[]}"#).groups,
|
||||
Some(vec![])
|
||||
);
|
||||
}
|
||||
|
||||
/// The id_token carries plenty of claims we do not model
|
||||
#[test]
|
||||
fn unknown_claims_are_ignored() {
|
||||
assert_eq!(
|
||||
parse(r#"{"sciper":"123456","groups":["agepoly"],"uid":"x","other":42}"#).groups,
|
||||
Some(vec!["agepoly".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_groups_claim_is_none() {
|
||||
assert_eq!(parse(r#"{"sciper":"123456","groups":null}"#).groups, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue