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