//! 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.
//!
//! Signing in (a code by email, or a passkey) lives in `sign_in.rs`. Posts
//! from other origins are refused (see `same_origin_posts` in lib.rs), so
//! other sites cannot post forms on the user's behalf.
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::mcp::ToolError;
use crate::sign_in::{Login, token};
use crate::views::Views;
use crate::{AppState, TITLE};
/// 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("/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>,
}
/// `?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 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,
passkey_url: String,
script_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.
pub(crate) 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"),
passkey_url: app.url("/passkey"),
script_url: app.url("/sign-in.js"),
error: flash.error,
};
render(&app, StatusCode::OK, "dashboard", "Teams", data)
}
fn sign_in_page(app: &AppState, error: Option<String>) -> Response {
let mut login = Login::new(app);
login.error = error;
crate::sign_in::page(app, login)
}
/// `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()
}
}
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"
}