//! Pages of the `{{ app }}` app: sign-in and sign-up, a dashboard of the
//! caller's teams, invitations and apps (super admins give each app an owner
//! team), and each team's members and invitations with their roles and
//! modules. Anyone signed in can create a team; owners and admins invite
//! people by email, who accept after signing in with that email.
//! Handlers call MCP tools and render a view from `templates/` (see
//! `views.rs`); every rule (who may do what) is enforced by the backend.
//!
//! Sign-in goes through the OpenID Connect provider (Authorization Code flow
//! with PKCE): `/login` (and `/register`, its sign-up page) redirects there,
//! `/callback` receives the result and keeps the verified ID token in an
//! HttpOnly cookie, which is sent to the MCP backend as the bearer token.
//! This app never sees passwords. Forms post with that cookie (`SameSite=Lax`,
//! so other sites cannot post them on the user's behalf).
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{AppendHeaders, Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use serde_json::json;
use slave_framework::oidc::PendingSignIn;
use crate::mcp::ToolError;
use crate::views::Views;
use crate::{AppState, TITLE};
/// Cookie holding the signed-in user's ID token (the MCP bearer token).
const TOKEN_COOKIE: &str = "slave_token";
/// Cookie holding a sign-in in progress (state, PKCE verifier, nonce).
const SIGN_IN_COOKIE: &str = "slave_sign_in";
/// Extra parameters sent to the provider by `/register`, as a query string.
/// `prompt=create` is the OpenID Connect way to open the sign-up page; some
/// providers use another (Auth0: `screen_hint=signup`).
const SIGNUP_PARAMS_ENV: &str = "{{ env }}_OIDC_SIGNUP_PARAMS";
const DEFAULT_SIGNUP_PARAMS: &str = "prompt=create";
/// The app's pages, under its base path (see [`AppState::url`]).
pub fn router(state: AppState) -> Router {
let mut router = Router::new()
.route(&state.url("/"), get(dashboard))
.route(&state.url("/teams"), post(create_team))
.route(&state.url("/apps/{app}"), post(assign_app))
.route(&state.url("/teams/{team_id}"), get(team))
.route(&state.url("/teams/{team_id}/invitations"), post(invite))
.route(
&state.url("/teams/{team_id}/invitations/{invitation_id}/revoke"),
post(revoke_invitation),
)
.route(
&state.url("/invitations/{invitation_id}/accept"),
post(accept_invitation),
)
.route(
&state.url("/invitations/{invitation_id}/decline"),
post(decline_invitation),
)
.route(
&state.url("/teams/{team_id}/members/{user_id}"),
post(update_member),
)
.route(
&state.url("/teams/{team_id}/members/{user_id}/remove"),
post(remove_member),
)
.route(&state.url("/login"), get(login))
.route(&state.url("/register"), get(register))
.route(&state.url("/callback"), get(callback))
.route(&state.url("/logout"), post(logout))
.route("/health", get(health));
if !state.base.is_empty() {
// `/admin/` as well as `/admin`.
router = router.route(&format!("{}/", state.base), get(dashboard));
}
router.with_state(state)
}
/// Result of the backend's `auth_me` tool.
#[derive(Deserialize, Serialize)]
struct Me {
name: String,
email: Option<String>,
roles: Vec<String>,
}
#[derive(Deserialize, Serialize)]
struct TeamView {
id: i64,
name: String,
slug: String,
role: String,
modules: Vec<String>,
manages_members: bool,
}
#[derive(Deserialize)]
struct TeamList {
teams: Vec<TeamView>,
}
#[derive(Deserialize, Serialize)]
struct MemberView {
user_id: i64,
name: String,
email: Option<String>,
role: String,
modules: Vec<String>,
}
#[derive(Deserialize, Serialize)]
struct OwnerTeam {
id: i64,
name: String,
}
#[derive(Deserialize, Serialize)]
struct AppView {
name: String,
owner: Option<OwnerTeam>,
}
#[derive(Deserialize)]
struct AppList {
apps: Vec<AppView>,
}
#[derive(Deserialize)]
struct MemberList {
members: Vec<MemberView>,
}
#[derive(Deserialize, Serialize)]
struct InvitationView {
id: i64,
team_id: i64,
team_name: Option<String>,
email: String,
role: String,
modules: Vec<String>,
}
#[derive(Deserialize)]
struct InvitationList {
invitations: Vec<InvitationView>,
}
#[derive(Deserialize)]
struct Choices {
roles: Vec<String>,
modules: Vec<String>,
}
/// What the provider sends back to `/callback`.
#[derive(Deserialize)]
struct Callback {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
/// `?error=...` after a form that failed.
#[derive(Deserialize)]
struct Flash {
error: Option<String>,
}
/// What every view receives: the app's name, the page title and the links of
/// the layout, plus the page's own data (flattened).
#[derive(Serialize)]
struct Page<'a, T: Serialize> {
app: &'static str,
title: &'a str,
home_url: String,
logout_url: String,
#[serde(flatten)]
data: T,
}
#[derive(Serialize)]
struct SignIn {
login_url: String,
register_url: String,
error: Option<String>,
}
#[derive(Serialize)]
struct Dashboard {
signed_in: bool,
me: Me,
super_admin: bool,
teams: Vec<TeamView>,
invitations: Vec<InvitationView>,
invitations_url: String,
apps: Vec<AppView>,
teams_url: String,
apps_url: String,
create_url: String,
error: Option<String>,
}
#[derive(Serialize)]
struct TeamPage {
signed_in: bool,
team: TeamView,
apps: Vec<AppView>,
members: Vec<MemberView>,
invitations: Vec<InvitationView>,
invite_url: String,
roles: Vec<String>,
modules: Vec<String>,
members_url: String,
error: Option<String>,
}
#[derive(Serialize)]
struct Message {
message: String,
login_url: String,
}
/// Renders view `name`. Debug builds reload the templates on every request,
/// so edits show without restarting, and show template errors in the page.
fn render<T: Serialize>(
app: &AppState,
status: StatusCode,
name: &str,
title: &str,
data: T,
) -> Response {
let page = Page {
app: TITLE,
title,
home_url: app.url("/"),
logout_url: app.url("/logout"),
data,
};
let rendered = if cfg!(debug_assertions) {
Views::load(&app.templates_dir).and_then(|views| views.render(name, &page))
} else {
app.views.render(name, &page)
};
match rendered {
Ok(html) => (status, Html(html)).into_response(),
Err(err) => {
tracing::error!("view `{name}`: {err}");
let detail = if cfg!(debug_assertions) {
err
} else {
String::new()
};
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("template error {detail}"),
)
.into_response()
}
}
}
/// The sign-in page, or the caller's teams once signed in.
async fn dashboard(
State(app): State<AppState>,
headers: HeaderMap,
Query(flash): Query<Flash>,
) -> Response {
let Some(token) = token(&headers) else {
return sign_in_page(&app, flash.error);
};
let me = match app.mcp.call::<Me>("auth_me", json!({}), Some(&token)).await {
Ok(me) => me,
Err(err) if err.code == "unauthenticated" => return sign_in_page(&app, None),
Err(err) => return sign_in_page(&app, Some(err.message)),
};
let teams = match app
.mcp
.call::<TeamList>("team_list", json!({}), Some(&token))
.await
{
Ok(list) => list.teams,
Err(err) => return failure(&app, err),
};
let apps = match app
.mcp
.call::<AppList>("app_list", json!({}), Some(&token))
.await
{
Ok(list) => list.apps,
Err(err) => return failure(&app, err),
};
let invitations = match app
.mcp
.call::<InvitationList>("invite_mine", json!({}), Some(&token))
.await
{
Ok(list) => list.invitations,
Err(err) => return failure(&app, err),
};
let data = Dashboard {
signed_in: true,
teams_url: app.url("/teams"),
apps_url: app.url("/apps"),
invitations_url: app.url("/invitations"),
invitations,
apps,
super_admin: me.roles.iter().any(|role| role == "super_admin"),
me,
teams,
create_url: app.url("/teams"),
error: flash.error,
};
render(&app, StatusCode::OK, "dashboard", "Teams", data)
}
fn sign_in_page(app: &AppState, error: Option<String>) -> Response {
let data = SignIn {
login_url: app.url("/login"),
register_url: app.url("/register"),
error,
};
render(app, StatusCode::OK, "login", "Sign in", data)
}
/// `POST /teams` (`name`, `slug`): creates a team owned by the caller.
async fn create_team(State(app): State<AppState>, headers: HeaderMap, body: String) -> Response {
let Some(token) = token(&headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let form = Form::parse(&body);
let created = app
.mcp
.call::<TeamView>(
"team_create",
json!({"name": form.one("name"), "slug": form.one("slug")}),
Some(&token),
)
.await;
match created {
Ok(team) => Redirect::to(&app.url(&format!("/teams/{}", team.id))).into_response(),
Err(err) => back(&app.url("/"), &err),
}
}
/// `POST /apps/{app}` (`team_id`, empty for none): gives an app to a team
/// (super admins).
async fn assign_app(
State(app): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
body: String,
) -> Response {
let Some(token) = token(&headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let form = Form::parse(&body);
let team_id = form.one("team_id").parse::<i64>().ok();
match app
.mcp
.call::<serde_json::Value>(
"app_assign",
json!({"app": name, "team_id": team_id}),
Some(&token),
)
.await
{
Ok(_) => Redirect::to(&app.url("/")).into_response(),
Err(err) => back(&app.url("/"), &err),
}
}
/// A team: its members, and forms to manage them for owners and admins.
async fn team(
State(app): State<AppState>,
headers: HeaderMap,
Path(team_id): Path<i64>,
Query(flash): Query<Flash>,
) -> Response {
let Some(token) = token(&headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let call = |tool: &'static str, args| {
let app = app.clone();
let token = token.clone();
async move {
app.mcp
.call::<serde_json::Value>(tool, args, Some(&token))
.await
}
};
let loaded = async {
let team: TeamView = decode(call("team_get", json!({"team_id": team_id})).await?)?;
let members: MemberList = decode(call("member_list", json!({"team_id": team_id})).await?)?;
let choices: Choices = decode(call("team_modules", json!({})).await?)?;
let apps: AppList = decode(call("app_list", json!({})).await?)?;
let invitations: Vec<InvitationView> = if team.manages_members {
let list: InvitationList =
decode(call("invite_list", json!({"team_id": team_id})).await?)?;
list.invitations
} else {
Vec::new()
};
Ok::<_, ToolError>((team, members.members, choices, apps.apps, invitations))
};
let (team, members, choices, apps, invitations) = match loaded.await {
Ok(loaded) => loaded,
Err(err) => return failure(&app, err),
};
let data = TeamPage {
signed_in: true,
members_url: app.url(&format!("/teams/{team_id}/members")),
invite_url: app.url(&format!("/teams/{team_id}/invitations")),
invitations,
apps: apps
.into_iter()
.filter(|a| a.owner.as_ref().is_some_and(|owner| owner.id == team_id))
.collect(),
team,
members,
roles: choices.roles,
modules: choices.modules,
error: flash.error,
};
let title = data.team.name.clone();
render(&app, StatusCode::OK, "team", &title, data)
}
/// `POST /teams/{id}/invitations` (`email`, `role`, `modules`...).
async fn invite(
State(app): State<AppState>,
headers: HeaderMap,
Path(team_id): Path<i64>,
body: String,
) -> Response {
let form = Form::parse(&body);
let args = json!({
"team_id": team_id,
"email": form.one("email"),
"role": form.one("role"),
"modules": form.all("modules"),
});
member_change(&app, &headers, team_id, "invite_create", args).await
}
/// `POST /teams/{id}/invitations/{invitation_id}/revoke`.
async fn revoke_invitation(
State(app): State<AppState>,
headers: HeaderMap,
Path((team_id, invitation_id)): Path<(i64, i64)>,
) -> Response {
let args = json!({"team_id": team_id, "invitation_id": invitation_id});
member_change(&app, &headers, team_id, "invite_revoke", args).await
}
/// `POST /invitations/{id}/accept`: joins the team and opens it.
async fn accept_invitation(
State(app): State<AppState>,
headers: HeaderMap,
Path(invitation_id): Path<i64>,
) -> Response {
let Some(token) = token(&headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let args = json!({"invitation_id": invitation_id});
match app
.mcp
.call::<TeamView>("invite_accept", args, Some(&token))
.await
{
Ok(team) => Redirect::to(&app.url(&format!("/teams/{}", team.id))).into_response(),
Err(err) => back(&app.url("/"), &err),
}
}
/// `POST /invitations/{id}/decline`.
async fn decline_invitation(
State(app): State<AppState>,
headers: HeaderMap,
Path(invitation_id): Path<i64>,
) -> Response {
let Some(token) = token(&headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let args = json!({"invitation_id": invitation_id});
match app
.mcp
.call::<serde_json::Value>("invite_decline", args, Some(&token))
.await
{
Ok(_) => Redirect::to(&app.url("/")).into_response(),
Err(err) => back(&app.url("/"), &err),
}
}
/// `POST /teams/{id}/members/{user_id}` (`role`, `modules`...).
async fn update_member(
State(app): State<AppState>,
headers: HeaderMap,
Path((team_id, user_id)): Path<(i64, i64)>,
body: String,
) -> Response {
let form = Form::parse(&body);
let args = json!({
"team_id": team_id,
"user_id": user_id,
"role": form.one("role"),
"modules": form.all("modules"),
});
member_change(&app, &headers, team_id, "member_update", args).await
}
/// `POST /teams/{id}/members/{user_id}/remove`.
async fn remove_member(
State(app): State<AppState>,
headers: HeaderMap,
Path((team_id, user_id)): Path<(i64, i64)>,
) -> Response {
let args = json!({"team_id": team_id, "user_id": user_id});
member_change(&app, &headers, team_id, "member_remove", args).await
}
/// Calls a member tool, then goes back to the team page (with the error, if any).
async fn member_change(
app: &AppState,
headers: &HeaderMap,
team_id: i64,
tool: &str,
args: serde_json::Value,
) -> Response {
let Some(token) = token(headers) else {
return Redirect::to(&app.url("/")).into_response();
};
let page = app.url(&format!("/teams/{team_id}"));
match app
.mcp
.call::<serde_json::Value>(tool, args, Some(&token))
.await
{
Ok(_) => Redirect::to(&page).into_response(),
Err(err) => back(&page, &err),
}
}
/// Redirects to `page` with the error as `?error=...`.
fn back(page: &str, err: &ToolError) -> Response {
let message: String = form_urlencoded::byte_serialize(err.message.as_bytes()).collect();
Redirect::to(&format!("{page}?error={message}")).into_response()
}
/// A page for a failed tool call: sign in again, no access, or an error.
fn failure(app: &AppState, err: ToolError) -> Response {
let status = match err.code.as_str() {
"unauthenticated" => return sign_in_page(app, None),
"forbidden" => StatusCode::FORBIDDEN,
"not_found" => StatusCode::NOT_FOUND,
_ => StatusCode::BAD_GATEWAY,
};
problem(app, status, &err.message)
}
fn decode<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T, ToolError> {
serde_json::from_value(value).map_err(|err| ToolError {
code: "protocol_error".to_owned(),
message: format!("unexpected result: {err}"),
})
}
/// The fields of an `application/x-www-form-urlencoded` body.
struct Form(Vec<(String, String)>);
impl Form {
fn parse(body: &str) -> Self {
Self(
form_urlencoded::parse(body.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect(),
)
}
/// The first value of field `name`, or `""`.
fn one(&self, name: &str) -> &str {
self.0
.iter()
.find(|(key, _)| key == name)
.map_or("", |(_, value)| value.as_str())
}
/// Every value of field `name` (checkboxes).
fn all(&self, name: &str) -> Vec<&str> {
self.0
.iter()
.filter(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
.collect()
}
}
/// Starts signing in: redirects to the provider.
async fn login(State(app): State<AppState>, headers: HeaderMap) -> Response {
start_sign_in(&app, &headers, &[]).await
}
/// Starts signing up: the provider's sign-up page (`{{ env }}_OIDC_SIGNUP_PARAMS`,
/// default `prompt=create`). The account is created here on first sign-in.
async fn register(State(app): State<AppState>, headers: HeaderMap) -> Response {
let params = std::env::var(SIGNUP_PARAMS_ENV).unwrap_or_else(|_| DEFAULT_SIGNUP_PARAMS.into());
let params: Vec<(String, String)> = form_urlencoded::parse(params.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let extra: Vec<(&str, &str)> = params
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
start_sign_in(&app, &headers, &extra).await
}
async fn start_sign_in(app: &AppState, headers: &HeaderMap, extra: &[(&str, &str)]) -> Response {
let client = match app.oidc().await {
Ok(client) => client,
Err(message) => return problem(app, StatusCode::SERVICE_UNAVAILABLE, &message),
};
let (url, pending) = match client.authorize_url_with(&callback_url(app, headers), extra) {
Ok(started) => started,
Err(err) => return problem(app, StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()),
};
let cookie = format!(
"{SIGN_IN_COOKIE}={}; Path={}; HttpOnly; SameSite=Lax; Max-Age=600",
pending.encode(),
app.url("/")
);
([(header::SET_COOKIE, cookie)], Redirect::to(&url)).into_response()
}
/// Finishes signing in: verifies the provider's answer and keeps the ID token.
async fn callback(
State(app): State<AppState>,
headers: HeaderMap,
Query(answer): Query<Callback>,
) -> Response {
if let Some(error) = answer.error {
let reason = answer.error_description.unwrap_or(error);
return problem(
&app,
StatusCode::UNAUTHORIZED,
&format!("Sign-in failed: {reason}"),
);
}
let (Some(code), Some(state)) = (answer.code, answer.state) else {
return problem(
&app,
StatusCode::BAD_REQUEST,
"The sign-in response is incomplete.",
);
};
let Some(pending) = cookie(&headers, SIGN_IN_COOKIE).and_then(|v| PendingSignIn::decode(&v))
else {
return problem(
&app,
StatusCode::BAD_REQUEST,
"This sign-in expired; please try again.",
);
};
let client = match app.oidc().await {
Ok(client) => client,
Err(message) => return problem(&app, StatusCode::SERVICE_UNAVAILABLE, &message),
};
let id_token = match client
.exchange(&callback_url(&app, &headers), &code, &state, &pending)
.await
{
Ok(id_token) => id_token,
Err(err) => {
tracing::warn!("sign-in failed: {err}");
return problem(
&app,
StatusCode::UNAUTHORIZED,
"Sign-in failed; please try again.",
);
}
};
let path = app.url("/");
(
AppendHeaders([
(
header::SET_COOKIE,
format!("{TOKEN_COOKIE}={id_token}; Path={path}; HttpOnly; SameSite=Lax"),
),
(
header::SET_COOKIE,
format!("{SIGN_IN_COOKIE}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0"),
),
]),
Redirect::to(&path),
)
.into_response()
}
/// Signs out of this app (the provider's own session is left alone).
async fn logout(State(app): State<AppState>) -> Response {
let cookie = format!(
"{TOKEN_COOKIE}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
app.url("/")
);
([(header::SET_COOKIE, cookie)], Redirect::to(&app.url("/"))).into_response()
}
/// Where the provider sends the browser back: `<public URL><base>/callback`.
/// Register it at the provider as this app's redirect URI.
fn callback_url(app: &AppState, headers: &HeaderMap) -> String {
let origin = app.sign_in.public_url.clone().unwrap_or_else(|| {
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let scheme = header("x-forwarded-proto").unwrap_or("http");
let host = header("x-forwarded-host")
.or_else(|| header("host"))
.unwrap_or("127.0.0.1");
format!("{scheme}://{host}")
});
format!("{}{}", origin.trim_end_matches('/'), app.url("/callback"))
}
fn problem(app: &AppState, status: StatusCode, message: &str) -> Response {
let data = Message {
message: message.to_owned(),
login_url: app.url("/login"),
};
render(app, status, "message", "Sign in", data)
}
/// Liveness probe for orchestrators.
async fn health() -> &'static str {
"ok"
}
/// The bearer token (ID token) from the session cookie, if any.
fn token(headers: &HeaderMap) -> Option<String> {
cookie(headers, TOKEN_COOKIE)
}
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(key, value)| *key == name && !value.is_empty())
.map(|(_, value)| value.to_owned())
}