use topcoat::{
context::Cx,
router::{Body, RouteFuture, request::uri, response::IntoResponse},
session,
view::{BoxView, ViewExt},
};
use super::{
UNAVAILABLE_ERROR, infrastructure_failure, panel_root, resolved,
session::{delete_session, record, select_tenant, sweep_expired_sessions},
signed, unauthenticated_error,
};
use crate::panel::{Panel, panel_prefix, state::current};
pub(crate) const MAX_LOGIN_BYTES: usize = 64 * 1024;
pub const LOGIN_FIELD: &str = "email";
pub const PASSWORD_FIELD: &str = "password";
pub const NEXT_FIELD: &str = "next";
pub(super) const GENERIC_ERROR: &str = "Invalid email or password.";
pub const TENANT_FIELD: &str = "tenant";
pub(super) fn login_url(cx: &Cx) -> String {
format!("{}/login", panel_prefix(cx))
}
pub(crate) fn logout_url(cx: &Cx) -> String {
format!("{}/logout", panel_prefix(cx))
}
pub(super) fn login_url_with_next(cx: &Cx) -> String {
let mut url = login_url(cx);
let request = uri(cx);
let requested = match request.query() {
Some(query) => format!("{}?{query}", request.path()),
None => request.path().to_string(),
};
if let Some(next) = safe_next(&requested) {
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair(NEXT_FIELD, next);
url.push('?');
url.push_str(&serializer.finish());
}
url
}
pub(crate) fn safe_next(next: &str) -> Option<&str> {
let next = next.trim();
if !next.starts_with('/') || next.starts_with("//") {
return None;
}
if next.contains('\\') || next.chars().any(|c| c.is_control()) {
return None;
}
Some(next)
}
fn next_from_query(cx: &Cx) -> Option<String> {
let query = uri(cx).query()?;
form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == NEXT_FIELD)
.map(|(_, value)| value.into_owned())
.filter(|value| safe_next(value).is_some())
}
#[derive(Debug, Clone, Copy)]
enum LoginError {
Credentials,
Unavailable,
}
impl LoginError {
fn message(self) -> &'static str {
match self {
Self::Credentials => GENERIC_ERROR,
Self::Unavailable => UNAVAILABLE_ERROR,
}
}
fn status(self) -> http::StatusCode {
match self {
Self::Credentials => http::StatusCode::FORBIDDEN,
Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
}
}
}
async fn login_response(
cx: &Cx,
error: Option<LoginError>,
next: String,
) -> topcoat::Result<topcoat::router::response::Response> {
let page = render_login_page(cx, error, next).await?;
page.single().await?.into_response(cx)
}
pub(crate) fn login_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
let next = next_from_query(cx).unwrap_or_default();
Box::pin(login_response(cx, None, next))
}
pub(crate) fn login_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let values = crate::panel::parse_form_body(cx, body).await?.values;
crate::csrf::verify(cx, &values)?;
let next = values
.get(NEXT_FIELD)
.and_then(|value| safe_next(value))
.map(str::to_string)
.or_else(|| next_from_query(cx))
.unwrap_or_default();
let login = values.get(LOGIN_FIELD).map(|value| value.trim());
let password = values.get(PASSWORD_FIELD).map(String::as_str);
let panel = current(cx).ok_or_else(topcoat::router::error::not_found)?;
let verified = match (panel.auth.authenticator(), login, password) {
(Some(authenticator), Some(login), Some(password))
if !login.is_empty() && !password.is_empty() =>
{
match authenticator.verify(cx, login, password).await {
Ok(user) => user,
Err(error) => return failed(cx, error, next).await,
}
}
_ => None,
};
let Some(user) = verified.filter(|user| user.can_access_panel()) else {
return login_response(cx, Some(LoginError::Credentials), next).await;
};
if let Some(hash) = session::token_hash(cx).await? {
delete_session(cx, &hash).await?;
}
if let Err(error) = sweep_expired_sessions(cx).await {
tracing::error!(error = %error, "expired-session sweep failed");
}
let session = session::start(cx).await?;
if let Err(error) = record(cx, &session, &*user, panel).await {
return failed(cx, error, next).await;
}
let target = if next.is_empty() {
panel_root(cx)
} else {
next
};
topcoat::router::error::see_other(target).into_response(cx)
})
}
async fn failed(
cx: &Cx,
error: topcoat::Error,
next: String,
) -> topcoat::Result<topcoat::router::response::Response> {
let error = infrastructure_failure(error);
if crate::error::TabloError::is_infrastructure(&error) {
return login_response(cx, Some(LoginError::Unavailable), next).await;
}
Err(error)
}
pub(crate) fn logout_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
if resolved(cx).is_none() {
return Err(unauthenticated_error(cx));
}
let values = crate::panel::parse_form_body(cx, body).await?.values;
crate::csrf::verify(cx, &values)?;
if let Some(hash) = session::stop(cx).await? {
delete_session(cx, &hash).await?;
}
let target = login_url(cx);
topcoat::router::error::see_other(target).into_response(cx)
})
}
pub(crate) fn tenant_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let Some(signed) = signed(cx) else {
return Err(unauthenticated_error(cx));
};
let values = crate::panel::parse_form_body(cx, body).await?.values;
crate::csrf::verify(cx, &values)?;
let tenant = values
.get(TENANT_FIELD)
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.filter(|tenant| {
signed
.user
.tenants()
.iter()
.any(|membership| membership.tenant == *tenant)
})
.ok_or_else(topcoat::router::error::forbidden)?;
select_tenant(cx, tenant).await?;
topcoat::router::error::see_other(panel_root(cx)).into_response(cx)
})
}
pub(crate) fn tenant_url(cx: &Cx) -> String {
format!("{}/tenant", panel_prefix(cx))
}
async fn render_login_page<'a>(
cx: &'a Cx,
error: Option<LoginError>,
next: String,
) -> topcoat::Result<BoxView<'a>> {
let csrf = crate::csrf::ensure_token(cx);
let action = login_url(cx);
let brand = Panel::render_brand(cx).await?;
let hint = current(cx).and_then(|panel| panel.login_hint.clone());
let body = topcoat::view::view! {
cx =>
<div class="flex min-h-svh items-center justify-center bg-muted p-6">
<div
class="flex w-full max-w-sm flex-col gap-6 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-sm"
>
if let Some(error) = error {
(error.status())
}
<div class="flex flex-col items-center gap-2">
(brand)
<h1 class="text-lg font-semibold text-foreground">"Sign in"</h1>
</div>
<form method="post" action=(action) class="flex flex-col gap-4">
(crate::csrf::field(cx, &csrf))
<input type="hidden" name=(NEXT_FIELD) value=(next)>
if let Some(error) = error {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: topcoat::view::attributes! { role="alert" },
tablo_ui::alert_title((error.message()))
)
}
tablo_ui::field(
tablo_ui::field_label(
attrs: topcoat::view::attributes! { for="email" },
"Email or username"
)
tablo_ui::input(
attrs: topcoat::view::attributes! {
id="email"
name=(LOGIN_FIELD)
type="text"
required=""
autocomplete="username"
autofocus=""
}
)
)
tablo_ui::field(
tablo_ui::field_label(
attrs: topcoat::view::attributes! { for="password" },
"Password"
)
tablo_ui::input(
attrs: topcoat::view::attributes! {
id="password"
name=(PASSWORD_FIELD)
type="password"
required=""
autocomplete="current-password"
}
)
)
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Primary,
attrs: topcoat::view::attributes! { type="submit" class="w-full" },
"Sign in"
)
</form>
if let Some(hint) = hint {
<p class="text-center text-xs text-muted-foreground">(hint)</p>
}
</div>
</div>
}
.boxed();
Panel::render_document(cx, "Sign in".to_string(), body).await
}