use super::signin::SESSION;
use super::{
Accounts, SignInManager, SignInResult, TwoFactorKind, User, UserManager, denied, invalid, scope,
};
use crate::prelude::*;
use crate::render::{Render, escape};
use crate::screen::Component;
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
pub const USERS_READ: &str = "accounts.users.read";
pub const USERS_WRITE: &str = "accounts.users.write";
pub const ROLES_WRITE: &str = "accounts.roles.write";
const CODES: &str = "zeyn_account_codes";
pub struct AccountsUi {
registration: bool,
admin: bool,
off: Vec<&'static str>,
}
pub fn ui() -> AccountsUi {
AccountsUi {
registration: true,
admin: false,
off: Vec::new(),
}
}
pub fn feature() -> Feature {
ui().build()
}
impl AccountsUi {
pub fn registration(mut self, on: bool) -> Self {
self.registration = on;
self
}
pub fn admin(mut self, on: bool) -> Self {
self.admin = on;
self
}
pub fn without(mut self, screen: &'static str) -> Self {
self.off.push(screen);
self
}
pub fn build(self) -> Feature {
let on = |name: &str| !self.off.contains(&name);
let mut f = Feature::new("accounts");
macro_rules! add {
($($s:ident),*) => { $( if on(stringify!($s)) { f = f.screen($s); } )* };
}
add!(
account_login,
account_logout,
account_forgot_password,
account_reset_password,
account_confirm_email,
account_resend_confirmation
);
add!(
account_check_email,
account_login_2fa,
account_login_recovery,
account_lockout,
account_access_denied
);
add!(
account_manage,
account_email,
account_password,
account_two_factor,
account_recovery_codes,
account_passkeys,
account_logins,
account_personal_data
);
if self.registration && on("account_register") {
f = f.screen(account_register);
}
#[cfg(feature = "oidc")]
if on("account_external_login") {
f = f.screen(account_external_login);
}
if self.admin {
add!(
account_admin_users,
account_admin_user_roles,
account_admin_roles
);
f = f.menu(|m| {
m.group(t!("zeyn.accounts.admin.menu"), Icon::Table)
.item(t!("zeyn.accounts.admin.users"), account_admin_users)
.item(t!("zeyn.accounts.admin.roles"), account_admin_roles)
});
}
f
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Next {
pub url: String,
pub frame: bool,
}
fn go(url: String, frame: bool) -> Result<Next> {
Ok(Next { url, frame })
}
fn path(p: &str) -> String {
format!("{}{p}", scope::prefix())
}
fn back(return_to: &str) -> String {
crate::auth::safe_return(return_to)
.map(str::to_string)
.unwrap_or_else(|| path("/"))
}
fn with_params(p: &str, params: Json) -> String {
format!("{}?params={}", path(p), super::pct(¶ms.to_string()))
}
struct Markup(Box<dyn Fn(&Render) -> String + Send + Sync>);
impl Component for Markup {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&(self.0)(r));
}
fn schema(&self) -> Json {
json!({ "type": "markup" })
}
}
fn markup(f: impl Fn(&Render) -> String + Send + Sync + 'static) -> Node {
Node::new(Markup(Box::new(f)))
}
fn tx(r: &Render, key: &str) -> String {
r.text(&Text::key(format!("zeyn.accounts.{key}")))
}
fn link(key: &'static str, to: &'static str) -> Node {
let href = path(to);
markup(move |r| format!(r#"<p><a href="{}">{}</a></p>"#, escape(&href), tx(r, key)))
}
async fn me(accounts: &Accounts) -> Result<User> {
let id = scope::cookie(SESSION)
.and_then(|v| accounts.sign_in().open_session(&v))
.map(|s| s.id)
.ok_or(Error::Forbidden)?;
accounts
.users()
.find_by_id(&id)
.await?
.ok_or(Error::Forbidden)
}
fn page(title: &'static str) -> Screen {
Screen::new(Text::key(format!("zeyn.accounts.{title}")))
}
fn manage_nav(current: &'static str) -> Node {
const ITEMS: [(&str, &str); 7] = [
("manage.profile", "/account/manage"),
("manage.email", "/account/manage/email"),
("manage.password", "/account/manage/password"),
("manage.two_factor", "/account/manage/two-factor"),
("manage.passkeys", "/account/manage/passkeys"),
("manage.logins", "/account/manage/logins"),
("manage.personal_data", "/account/manage/personal-data"),
];
let prefix = scope::prefix();
markup(move |r| {
let items: String = ITEMS
.iter()
.map(|(key, to)| {
let mark = if *to == current {
r#" aria-current="page""#
} else {
""
};
format!(
r#"<li><a href="{}{}"{mark}>{}</a></li>"#,
escape(&prefix),
to,
tx(r, key)
)
})
.collect();
format!(
r#"<nav class="z-account-nav" aria-label="{}"><ul>{items}</ul></nav>"#,
tx(r, "manage.title")
)
})
}
fn notice(code: Option<&str>) -> Option<Node> {
let key = match code? {
"confirmed" => "notice.confirmed",
"reset" => "notice.reset",
"signed_out" => "notice.signed_out",
"deleted" => "notice.deleted",
"external" => "error.external",
"check_email" => "notice.check_email",
_ => return None,
};
Some(Node::new(Alert::info(Text::key(format!(
"zeyn.accounts.{key}"
)))))
}
macro_rules! optional_params {
($(#[$m:meta])* pub struct $name:ident { $($(#[$fm:meta])* pub $f:ident: $t:ty,)* }) => {
$(#[$m])*
#[derive(Clone, Debug, Default, Serialize)]
pub struct $name { $($(#[$fm])* pub $f: $t,)* }
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize, Default)]
#[serde(default)]
struct Raw { $($f: $t,)* }
let raw = Option::<Raw>::deserialize(d)?.unwrap_or_default();
Ok($name { $($f: raw.$f,)* })
}
}
impl crate::screen::Params for $name {}
impl crate::api::ApiInput for $name {
fn schema() -> Json {
json!({ "type": "object", "title": stringify!($name) })
}
fn from_json(value: Json) -> Result<Self> {
if value.is_null() {
return Ok(Self::default());
}
serde_json::from_value(value).map_err(|_| Error::NotFound)
}
}
};
}
#[model]
pub struct AccountLogin {
#[rule(required, max_len = 254)]
pub user_name: String,
#[rule(required, max_len = 1024)]
pub password: String,
pub remember_me: bool,
pub return_to: String,
}
optional_params! {
pub struct LoginParams {
pub return_to: Option<String>,
pub notice: Option<String>,
pub error: Option<String>,
}
}
#[action(public, ai = false)]
pub async fn account_sign_in(input: Valid<AccountLogin>, sign_in: SignInManager) -> Result<Next> {
match sign_in
.password_sign_in(&input.user_name, &input.password, input.remember_me, true)
.await?
{
SignInResult::Succeeded => go(back(&input.return_to), true),
SignInResult::RequiresTwoFactor => go(
with_params(
"/account/login-2fa",
json!({ "return_to": input.return_to, "remember": input.remember_me }),
),
false,
),
SignInResult::LockedOut => go(path("/account/lockout"), false),
SignInResult::NotAllowed => Err(denied("not_allowed")),
SignInResult::Failed => Err(denied("invalid_login")),
}
}
fn next_effect(n: &Result<Next>, ui: &mut UiCtx) {
if let Ok(n) = n {
ui.effect(Effect::go(&n.url, n.frame));
}
}
fn para(key: &'static str) -> Node {
markup(move |r| format!("<p>{}</p>", r.text(&Text::key(key))))
}
#[screen(route = "/account/login", public)]
pub async fn account_login(p: LoginParams, accounts: Accounts) -> Result<Screen> {
let return_to = p.return_to.clone().unwrap_or_default();
let mut body = Stack::new();
if let Some(n) = notice(p.notice.as_deref()).or_else(|| notice(p.error.as_deref())) {
body = body.add(n);
}
body = body.add(
Form::of(AccountLogin {
return_to: return_to.clone(),
..AccountLogin::default()
})
.field(AccountLogin::user_name)
.field(AccountLogin::password)
.control(AccountLogin::password, Control::Password)
.field(AccountLogin::remember_me)
.submit(t!("zeyn.accounts.login.submit"), account_sign_in)
.on_success(next_effect),
);
if accounts.settings().passkeys.enabled && accounts.0.public_url.is_some() {
let back = back(&return_to);
body = body.add(markup(move |r| {
format!(
r#"<p><button type="button" class="z-button" data-zeyn-passkey="login" data-return="{}" data-failed="{}" data-unsupported="{}">{}</button></p><p class="z-field-error" data-zeyn-passkey-error aria-live="polite"></p>"#,
escape(&back),
tx(r, "passkeys.failed"),
tx(r, "passkeys.unsupported"),
tx(r, "login.passkey")
)
}));
}
let providers = accounts.external_names();
if !providers.is_empty() {
let prefix = scope::prefix();
let rt = super::pct(&return_to);
body = body.add(markup(move |r| {
let buttons: String = providers
.iter()
.map(|p| format!(r#"<li><a class="z-button" href="{}/_zeyn/account/external/{}?return_to={rt}">{} {}</a></li>"#, escape(&prefix), escape(p), tx(r, "login.with"), escape(p)))
.collect();
format!(r#"<section aria-labelledby="z-external-title"><h2 id="z-external-title">{}</h2><ul class="z-account-providers">{buttons}</ul></section>"#, tx(r, "login.external"))
}));
}
body = body
.add(link("login.forgot", "/account/forgot-password"))
.add(link("login.resend", "/account/resend-confirmation"));
if accounts.settings().registration {
body = body.add(link("login.register", "/account/register"));
}
Ok(page("login.title").body(body))
}
#[model]
pub struct AccountRegister {
#[rule(required, email, max_len = 254)]
pub email: String,
#[rule(required, max_len = 1024)]
pub password: String,
#[rule(required, max_len = 1024)]
pub confirm_password: String,
}
#[action(public, ai = false)]
pub async fn account_sign_up(
input: Valid<AccountRegister>,
accounts: Accounts,
ctx: Ctx,
) -> Result<Next> {
if input.password != input.confirm_password {
return Err(invalid("confirm_password", "password_confirm_mismatch"));
}
crate::server::accounts::register_user(&ctx, &accounts, &input.email, &input.password).await?;
go(path("/account/check-email"), false)
}
#[screen(route = "/account/register", public)]
pub fn account_register(_: ()) -> Screen {
page("register.title").body(
Stack::new()
.add(
Form::of(AccountRegister::default())
.field(AccountRegister::email)
.field(AccountRegister::password)
.control(AccountRegister::password, Control::NewPassword)
.field(AccountRegister::confirm_password)
.control(AccountRegister::confirm_password, Control::NewPassword)
.submit(t!("zeyn.accounts.register.submit"), account_sign_up)
.on_success(next_effect),
)
.add(link("register.login", "/account/login")),
)
}
#[screen(route = "/account/check-email", public)]
pub fn account_check_email(_: ()) -> Screen {
page("check_email.title").body(
Stack::new()
.add(Alert::info(t!("zeyn.accounts.check_email.body")))
.add(link("register.login", "/account/login")),
)
}
#[model]
pub struct AccountEmailOnly {
#[rule(required, email, max_len = 254)]
pub email: String,
}
#[action(public, ai = false)]
pub async fn account_forgot(
input: Valid<AccountEmailOnly>,
accounts: Accounts,
ctx: Ctx,
) -> Result<Next> {
crate::server::accounts::forgot_password(&ctx, &accounts, &input.email).await?;
go(path("/account/check-email"), false)
}
#[screen(route = "/account/forgot-password", public)]
pub fn account_forgot_password(_: ()) -> Screen {
page("forgot.title").body(
Stack::new()
.add(para("zeyn.accounts.forgot.intro"))
.add(
Form::of(AccountEmailOnly::default())
.field(AccountEmailOnly::email)
.submit(t!("zeyn.accounts.forgot.submit"), account_forgot)
.on_success(next_effect),
),
)
}
#[action(public, ai = false)]
pub async fn account_resend(
input: Valid<AccountEmailOnly>,
accounts: Accounts,
ctx: Ctx,
) -> Result<Next> {
crate::server::accounts::resend_confirmation(&ctx, &accounts, &input.email).await?;
go(path("/account/check-email"), false)
}
#[screen(route = "/account/resend-confirmation", public)]
pub fn account_resend_confirmation(_: ()) -> Screen {
page("resend.title").body(
Form::of(AccountEmailOnly::default())
.field(AccountEmailOnly::email)
.submit(t!("zeyn.accounts.resend.submit"), account_resend)
.on_success(next_effect),
)
}
optional_params! {
pub struct LinkParams {
pub user: Option<String>,
pub code: Option<String>,
pub email: Option<String>,
}
}
#[model]
pub struct AccountReset {
#[rule(required, max_len = 1024)]
pub password: String,
#[rule(required, max_len = 1024)]
pub confirm_password: String,
pub user: String,
pub code: String,
}
#[action(public, ai = false)]
pub async fn account_reset(
input: Valid<AccountReset>,
users: UserManager,
sign_in: SignInManager,
) -> Result<Next> {
sign_in.throttle_ip().await?;
if input.password != input.confirm_password {
return Err(invalid("confirm_password", "password_confirm_mismatch"));
}
let Some(mut user) = users.find_by_id(&input.user).await? else {
return Err(denied("invalid_token"));
};
users
.reset_password(&mut user, &input.code, &input.password)
.await?;
go(
with_params("/account/login", json!({ "notice": "reset" })),
false,
)
}
#[screen(route = "/account/reset-password", public)]
pub fn account_reset_password(p: LinkParams) -> Screen {
let model = AccountReset {
user: p.user.unwrap_or_default(),
code: p.code.unwrap_or_default(),
..AccountReset::default()
};
page("reset.title").body(
Form::of(model)
.field(AccountReset::password)
.control(AccountReset::password, Control::NewPassword)
.field(AccountReset::confirm_password)
.control(AccountReset::confirm_password, Control::NewPassword)
.submit(t!("zeyn.accounts.reset.submit"), account_reset)
.on_success(next_effect),
)
}
#[screen(route = "/account/confirm-email", public)]
pub async fn account_confirm_email(
p: LinkParams,
users: UserManager,
sign_in: SignInManager,
) -> Result<Screen> {
sign_in.throttle_ip().await?;
let found = match &p.user {
Some(id) => users.find_by_id(id).await?,
None => None,
};
let done = match (found, p.code.as_deref()) {
(Some(mut user), Some(code)) => match p.email.as_deref() {
Some(new) => {
let same = user.email.as_deref() == Some(user.user_name.as_str());
users
.change_email(&mut user, new, code, same)
.await
.is_ok()
}
None => users
.confirm_email(&mut user, code)
.await
.is_ok(),
},
_ => false,
};
let alert = if done {
Alert::success(t!("zeyn.accounts.confirm.done"))
} else {
Alert::danger(t!("zeyn.accounts.confirm.failed"))
};
Ok(page("confirm.title").body(
Stack::new()
.add(alert)
.add(link("register.login", "/account/login")),
))
}
optional_params! {
pub struct TwoFactorParams {
pub return_to: Option<String>,
pub remember: Option<bool>,
}
}
#[model]
pub struct AccountCode {
#[rule(required, max_len = 16)]
pub code: String,
pub remember_machine: bool,
pub return_to: String,
}
#[action(public, ai = false)]
pub async fn account_verify_code(
input: Valid<AccountCode>,
sign_in: SignInManager,
) -> Result<Next> {
match sign_in
.two_factor_sign_in(
TwoFactorKind::Authenticator,
&input.code,
input.remember_machine,
)
.await?
{
SignInResult::Succeeded => go(back(&input.return_to), true),
SignInResult::LockedOut => go(path("/account/lockout"), false),
_ => Err(invalid("code", "invalid_code")),
}
}
#[model]
pub struct AccountEmailCode {
#[rule(required, max_len = 16)]
pub code: String,
pub remember_machine: bool,
pub return_to: String,
}
#[action(public, ai = false)]
pub async fn account_verify_email_code(
input: Valid<AccountEmailCode>,
sign_in: SignInManager,
) -> Result<Next> {
match sign_in
.two_factor_sign_in(TwoFactorKind::Email, &input.code, input.remember_machine)
.await?
{
SignInResult::Succeeded => go(back(&input.return_to), true),
SignInResult::LockedOut => go(path("/account/lockout"), false),
_ => Err(invalid("code", "invalid_code")),
}
}
#[action(public, ai = false)]
pub async fn account_send_email_code(_input: (), sign_in: SignInManager, ctx: Ctx) -> Result<()> {
sign_in
.send_two_factor_code(&ctx, TwoFactorKind::Email)
.await
}
#[screen(route = "/account/login-2fa", public)]
pub async fn account_login_2fa(p: TwoFactorParams, sign_in: SignInManager) -> Result<Screen> {
let Some(user) = sign_in.two_factor_user().await? else {
return Ok(page("two_factor.title").body(
Stack::new()
.add(Alert::warning(t!("zeyn.accounts.error.two_factor_expired")))
.add(link("register.login", "/account/login")),
));
};
let kinds = sign_in
.0
.users()
.two_factor_kinds(&user)
.await?;
let return_to = p.return_to.unwrap_or_default();
let model = AccountCode {
return_to: return_to.clone(),
..AccountCode::default()
};
let mut body = Stack::new();
if kinds.contains(&TwoFactorKind::Authenticator) {
body = body
.add(para("zeyn.accounts.two_factor.intro"))
.add(
Form::of(model.clone())
.field(AccountCode::code)
.control(AccountCode::code, Control::OneTimeCode)
.field(AccountCode::remember_machine)
.submit(t!("zeyn.accounts.two_factor.submit"), account_verify_code)
.on_success(next_effect),
);
}
if kinds.contains(&TwoFactorKind::Email) {
body = body
.add(para("zeyn.accounts.two_factor.email_intro"))
.add(
ActionButton::new(
t!("zeyn.accounts.two_factor.send_email"),
account_send_email_code,
(),
)
.on_success(|_, ui| ui.toast(t!("zeyn.accounts.two_factor.sent"))),
)
.add(
Form::of(AccountEmailCode {
return_to: model.return_to.clone(),
..AccountEmailCode::default()
})
.field(AccountEmailCode::code)
.control(AccountEmailCode::code, Control::OneTimeCode)
.field(AccountEmailCode::remember_machine)
.submit(
t!("zeyn.accounts.two_factor.submit_email"),
account_verify_email_code,
)
.on_success(next_effect),
);
}
let recovery = with_params("/account/login-recovery", json!({ "return_to": return_to }));
body = body.add(markup(move |r| {
format!(
r#"<p><a href="{}">{}</a></p>"#,
escape(&recovery),
tx(r, "two_factor.use_recovery")
)
}));
Ok(page("two_factor.title").body(body))
}
#[model]
pub struct AccountRecovery {
#[rule(required, max_len = 32)]
pub code: String,
pub return_to: String,
}
#[action(public, ai = false)]
pub async fn account_use_recovery(
input: Valid<AccountRecovery>,
sign_in: SignInManager,
) -> Result<Next> {
match sign_in
.recovery_code_sign_in(&input.code)
.await?
{
SignInResult::Succeeded => go(back(&input.return_to), true),
SignInResult::LockedOut => go(path("/account/lockout"), false),
_ => Err(invalid("code", "invalid_recovery_code")),
}
}
#[screen(route = "/account/login-recovery", public)]
pub fn account_login_recovery(p: TwoFactorParams) -> Screen {
page("recovery.title").body(
Stack::new()
.add(para("zeyn.accounts.recovery.intro"))
.add(
Form::of(AccountRecovery {
return_to: p.return_to.unwrap_or_default(),
..AccountRecovery::default()
})
.field(AccountRecovery::code)
.control(AccountRecovery::code, Control::OneTimeCode)
.submit(t!("zeyn.accounts.recovery.submit"), account_use_recovery)
.on_success(next_effect),
),
)
}
#[screen(route = "/account/lockout", public)]
pub fn account_lockout(_: ()) -> Screen {
page("lockout.title").body(
Stack::new()
.add(Alert::danger(t!("zeyn.accounts.lockout.body")))
.add(link("login.forgot", "/account/forgot-password")),
)
}
#[screen(route = "/account/access-denied", public)]
pub fn account_access_denied(_: ()) -> Screen {
page("denied.title").body(Alert::warning(t!("zeyn.accounts.denied.body")))
}
#[action(public, ai = false)]
pub async fn account_sign_out(_input: (), accounts: Accounts) -> Result<Next> {
let user = me(&accounts).await.ok();
accounts
.sign_in()
.sign_out(user.as_ref().map(|u| u.id.as_str()));
go(
with_params("/account/login", json!({ "notice": "signed_out" })),
true,
)
}
#[screen(route = "/account/logout", public)]
pub fn account_logout(_: ()) -> Screen {
page("logout.title").body(
Stack::new()
.add(para("zeyn.accounts.logout.body"))
.add(
ActionButton::new(t!("zeyn.accounts.logout.submit"), account_sign_out, ())
.on_success(next_effect),
),
)
}
#[model]
pub struct AccountProfile {
#[readonly]
pub user_name: String,
#[rule(max_len = 32)]
pub phone_number: Option<String>,
}
#[action(public, ai = false)]
pub async fn account_save_profile(input: Valid<AccountProfile>, accounts: Accounts) -> Result<()> {
let mut user = me(&accounts).await?;
let phone = input
.phone_number
.clone()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
if phone != user.phone_number {
if phone.as_deref().is_some_and(|p| {
!p.chars()
.all(|c| c.is_ascii_digit() || " +-()".contains(c))
}) {
return Err(invalid("phone_number", "invalid_phone"));
}
user.phone_number = phone;
user.phone_number_confirmed = false;
accounts
.users()
.update(&mut user)
.await?;
}
Ok(())
}
#[screen(route = "/account/manage", public)]
pub async fn account_manage(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
Ok(page("manage.profile").body(
Stack::new()
.add(manage_nav("/account/manage"))
.add(
Form::of(AccountProfile {
user_name: user.user_name.clone(),
phone_number: user.phone_number.clone(),
})
.field(AccountProfile::user_name)
.field(AccountProfile::phone_number)
.submit(t!("zeyn.accounts.save"), account_save_profile)
.on_success(|_, ui| ui.toast(t!("zeyn.accounts.saved"))),
),
))
}
#[model]
pub struct AccountNewEmail {
#[rule(required, email, max_len = 254)]
pub new_email: String,
}
#[action(public, ai = false)]
pub async fn account_change_email(
input: Valid<AccountNewEmail>,
accounts: Accounts,
ctx: Ctx,
) -> Result<()> {
let user = me(&accounts).await?;
accounts.sign_in().throttle_ip().await?;
if Some(input.new_email.as_str()) == user.email.as_deref() {
return Err(invalid("new_email", "email_unchanged"));
}
crate::server::accounts::send_confirmation(&ctx, &accounts, &user, Some(&input.new_email)).await
}
#[action(public, ai = false)]
pub async fn account_send_verification(_input: (), accounts: Accounts, ctx: Ctx) -> Result<()> {
let user = me(&accounts).await?;
accounts.sign_in().throttle_ip().await?;
crate::server::accounts::send_confirmation(&ctx, &accounts, &user, None).await
}
#[screen(route = "/account/manage/email", public)]
pub async fn account_email(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let current = user.email.clone().unwrap_or_default();
let confirmed = user.email_confirmed;
let mut body = Stack::new()
.add(manage_nav("/account/manage/email"))
.add(markup(move |r| {
let state = if confirmed {
tx(r, "email.confirmed")
} else {
tx(r, "email.unconfirmed")
};
format!(
r#"<p>{} <strong>{}</strong> ({state})</p>"#,
tx(r, "email.current"),
escape(¤t)
)
}));
if !confirmed {
body = body.add(
ActionButton::new(
t!("zeyn.accounts.email.send_verification"),
account_send_verification,
(),
)
.on_success(|_, ui| ui.toast(t!("zeyn.accounts.email.sent"))),
);
}
body = body.add(
Form::of(AccountNewEmail::default())
.field(AccountNewEmail::new_email)
.submit(t!("zeyn.accounts.email.change"), account_change_email)
.on_success(|_, ui| ui.toast(t!("zeyn.accounts.email.sent"))),
);
Ok(page("manage.email").body(body))
}
#[model]
pub struct AccountPassword {
#[rule(max_len = 1024)]
pub old_password: String,
#[rule(required, max_len = 1024)]
pub new_password: String,
#[rule(required, max_len = 1024)]
pub confirm_password: String,
}
#[action(public, ai = false)]
pub async fn account_change_password(
input: Valid<AccountPassword>,
accounts: Accounts,
) -> Result<()> {
let mut user = me(&accounts).await?;
accounts.sign_in().throttle_ip().await?;
if input.new_password != input.confirm_password {
return Err(invalid("confirm_password", "password_confirm_mismatch"));
}
if user.password_hash.is_some() {
accounts
.users()
.change_password(&mut user, &input.old_password, &input.new_password)
.await?;
} else {
accounts
.users()
.add_password(&mut user, &input.new_password)
.await?;
}
accounts
.sign_in()
.refresh_sign_in(&user)
.await
}
#[screen(route = "/account/manage/password", public)]
pub async fn account_password(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let mut form = Form::of(AccountPassword::default());
if user.password_hash.is_some() {
form = form
.field(AccountPassword::old_password)
.control(AccountPassword::old_password, Control::Password);
}
Ok(page("manage.password").body(
Stack::new()
.add(manage_nav("/account/manage/password"))
.add(
form.field(AccountPassword::new_password)
.control(AccountPassword::new_password, Control::NewPassword)
.field(AccountPassword::confirm_password)
.control(AccountPassword::confirm_password, Control::NewPassword)
.submit(t!("zeyn.accounts.password.submit"), account_change_password)
.on_success(|_, ui| {
ui.toast(t!("zeyn.accounts.password.changed"));
ui.effect(Effect::go(&path("/account/manage/password"), false));
}),
),
))
}
#[derive(Serialize, Deserialize)]
struct FreshCodes {
#[serde(rename = "u")]
user: String,
#[serde(rename = "c")]
codes: Vec<String>,
#[serde(rename = "e")]
expires: i64,
}
async fn new_codes(accounts: &Accounts, user: &User) -> Result<Next> {
let codes = accounts
.users()
.generate_recovery_codes(user, 10)
.await?;
let sealer = accounts
.0
.sealer
.as_ref()
.ok_or_else(|| Error::Internal("accounts key too short".into()))?;
let fresh = FreshCodes {
user: user.id.clone(),
codes,
expires: super::now_secs() + 300,
};
scope::set_cookie(CODES, Some(&sealer.seal(CODES, &fresh)), Some(300));
go(path("/account/manage/recovery-codes"), false)
}
#[action(public, reauth, ai = false)]
pub async fn account_enable_authenticator(
input: Valid<AccountCode>,
accounts: Accounts,
) -> Result<Next> {
let mut user = me(&accounts).await?;
accounts.sign_in().throttle_ip().await?;
if !accounts
.users()
.verify_authenticator_code(&user, &input.code)
.await?
{
return Err(invalid("code", "invalid_code"));
}
accounts
.users()
.set_two_factor_enabled(&mut user, true)
.await?;
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
if accounts
.users()
.count_recovery_codes(&user)
.await?
== 0
{
return new_codes(&accounts, &user).await;
}
go(path("/account/manage/two-factor"), false)
}
#[action(public, reauth, ai = false)]
pub async fn account_disable_two_factor(_input: (), accounts: Accounts) -> Result<Next> {
let mut user = me(&accounts).await?;
accounts
.users()
.set_two_factor_enabled(&mut user, false)
.await?;
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
go(path("/account/manage/two-factor"), false)
}
#[action(public, reauth, ai = false)]
pub async fn account_reset_authenticator(_input: (), accounts: Accounts) -> Result<Next> {
let mut user = me(&accounts).await?;
accounts
.users()
.set_two_factor_enabled(&mut user, false)
.await?;
accounts
.users()
.reset_authenticator_key(&mut user)
.await?;
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
go(path("/account/manage/two-factor"), false)
}
#[action(public, ai = false)]
pub async fn account_forget_browser(_input: (), accounts: Accounts) -> Result<()> {
me(&accounts).await?;
accounts
.sign_in()
.forget_two_factor_client();
Ok(())
}
#[action(public, reauth, ai = false)]
pub async fn account_new_recovery_codes(_input: (), accounts: Accounts) -> Result<Next> {
let user = me(&accounts).await?;
new_codes(&accounts, &user).await
}
#[screen(route = "/account/manage/two-factor", public)]
pub async fn account_two_factor(_: (), accounts: Accounts, ctx: Ctx) -> Result<Screen> {
let mut user = me(&accounts).await?;
let users = accounts.users();
let mut body = Stack::new().add(manage_nav("/account/manage/two-factor"));
if user.two_factor_enabled {
let left = users
.count_recovery_codes(&user)
.await?;
body = body
.add(Alert::success(t!("zeyn.accounts.two_factor.on")))
.add(markup(move |r| {
format!(
"<p>{}</p>",
r.text(
&Text::key("zeyn.accounts.two_factor.codes_left")
.arg("n", left.to_string())
)
)
}))
.add(
Row::new()
.col(
ActionButton::new(
t!("zeyn.accounts.two_factor.new_codes"),
account_new_recovery_codes,
(),
)
.confirm(t!("zeyn.accounts.two_factor.new_codes_confirm"))
.on_success(next_effect),
)
.col(
ActionButton::new(
t!("zeyn.accounts.two_factor.forget"),
account_forget_browser,
(),
)
.on_success(|_, ui| ui.toast(t!("zeyn.accounts.saved"))),
)
.col(
ActionButton::new(
t!("zeyn.accounts.two_factor.disable"),
account_disable_two_factor,
(),
)
.confirm(t!("zeyn.accounts.two_factor.disable_confirm"))
.tone(Tone::Danger)
.on_success(next_effect),
)
.col(
ActionButton::new(
t!("zeyn.accounts.two_factor.reset_key"),
account_reset_authenticator,
(),
)
.confirm(t!("zeyn.accounts.two_factor.reset_key_confirm"))
.on_success(next_effect),
),
);
} else if !ctx.signed_in_recently() {
let again = path("/account/login?return_to=%2Faccount%2Fmanage%2Ftwo-factor");
body = body
.add(Alert::info(t!("zeyn.accounts.two_factor.reauth")))
.add(markup(move |r| {
format!(
r#"<p><a href="{}">{}</a></p>"#,
escape(&again),
tx(r, "two_factor.reauth_link")
)
}));
} else {
let key = match users.authenticator_key(&user).await? {
Some(k) => k,
None => {
users
.reset_authenticator_key(&mut user)
.await?
}
};
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
let uri = users.authenticator_uri(&user, &key);
let grouped: Vec<String> = key
.to_lowercase()
.as_bytes()
.chunks(4)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect();
body = body
.add(para("zeyn.accounts.two_factor.setup"))
.add(markup(move |r| {
format!(
r#"<div class="z-account-qr">{}</div><p>{} <code class="z-totp-key">{}</code></p>"#,
crate::auth::totp::qr_svg(&uri, &tx(r, "two_factor.qr")).unwrap_or_default(),
tx(r, "two_factor.manual"),
escape(&grouped.join(" "))
)
}))
.add(
Form::of(AccountCode::default())
.field(AccountCode::code)
.control(AccountCode::code, Control::OneTimeCode)
.submit(t!("zeyn.accounts.two_factor.enable"), account_enable_authenticator)
.on_success(next_effect),
);
}
Ok(page("manage.two_factor").body(body))
}
#[screen(route = "/account/manage/recovery-codes", public)]
pub async fn account_recovery_codes(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let fresh = scope::cookie(CODES)
.and_then(|v| {
accounts
.0
.sealer
.as_ref()?
.open::<FreshCodes>(CODES, &v)
})
.filter(|f| f.user == user.id && f.expires > super::now_secs());
scope::set_cookie(CODES, None, None);
let mut body = Stack::new().add(manage_nav("/account/manage/two-factor"));
match fresh {
Some(f) => {
body = body
.add(Alert::warning(t!("zeyn.accounts.codes.keep")))
.add(markup(move |_| {
let items: String = f
.codes
.iter()
.map(|c| format!("<li><code>{}</code></li>", escape(c)))
.collect();
format!(r#"<ul class="z-totp-codes">{items}</ul>"#)
}));
}
None => body = body.add(Alert::info(t!("zeyn.accounts.codes.gone"))),
}
Ok(page("codes.title").body(body.add(link("codes.back", "/account/manage/two-factor"))))
}
#[action(public, reauth, ai = false)]
pub async fn account_remove_passkey(id: String, accounts: Accounts) -> Result<Next> {
let mut user = me(&accounts).await?;
let raw = crate::auth::b64url_decode(&id).ok_or(Error::NotFound)?;
accounts
.users()
.remove_passkey(&mut user, &raw)
.await?;
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
go(path("/account/manage/passkeys"), false)
}
#[screen(route = "/account/manage/passkeys", public)]
pub async fn account_passkeys(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let list = accounts.users().passkeys(&user).await?;
let mut body = Stack::new()
.add(manage_nav("/account/manage/passkeys"))
.add(para("zeyn.accounts.passkeys.intro"));
if list.is_empty() {
body = body.add(EmptyState::new(t!("zeyn.accounts.passkeys.none")));
}
for p in list {
let name = p
.name
.clone()
.unwrap_or_else(|| "Passkey".into());
let created = crate::time::DateTime::from_unix_ms(p.created_at.saturating_mul(1000));
body = body.add(
Row::new()
.col(markup(move |r| {
format!(
"<p><strong>{}</strong> {}</p>",
escape(&name),
r.value(
&crate::value::Value::DateTime(created),
&crate::value::FieldKind::DateTime
)
)
}))
.col(
ActionButton::new(
t!("zeyn.accounts.passkeys.remove"),
account_remove_passkey,
crate::auth::b64url(&p.credential_id),
)
.confirm(t!("zeyn.accounts.passkeys.remove_confirm"))
.tone(Tone::Danger)
.on_success(next_effect),
),
);
}
if accounts.0.public_url.is_some() {
body = body.add(markup(|r| {
format!(
r#"<div class="z-field"><label for="z-passkey-name">{}</label><input id="z-passkey-name" type="text" maxlength="100" data-zeyn-passkey-name></div><p><button type="button" class="z-button z-primary" data-zeyn-passkey="add" data-failed="{}" data-unsupported="{}">{}</button></p><p class="z-field-error" data-zeyn-passkey-error aria-live="polite"></p>"#,
tx(r, "passkeys.name"),
tx(r, "passkeys.failed"),
tx(r, "passkeys.unsupported"),
tx(r, "passkeys.add")
)
}));
}
Ok(page("manage.passkeys").body(body))
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct LoginRef {
pub provider: String,
pub key: String,
}
#[action(public, reauth, ai = false)]
pub async fn account_remove_login(login: LoginRef, accounts: Accounts) -> Result<Next> {
let mut user = me(&accounts).await?;
accounts
.users()
.remove_login(&mut user, &login.provider, &login.key)
.await?;
accounts
.sign_in()
.refresh_sign_in(&user)
.await?;
go(path("/account/manage/logins"), false)
}
#[screen(route = "/account/manage/logins", public)]
pub async fn account_logins(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let logins = accounts.users().logins(&user).await?;
let mut body = Stack::new().add(manage_nav("/account/manage/logins"));
if logins.is_empty() {
body = body.add(EmptyState::new(t!("zeyn.accounts.logins.none")));
}
let linked: Vec<String> = logins
.iter()
.map(|l| l.provider.clone())
.collect();
for l in logins {
let label = format!(
"{} {}",
l.provider,
l.display_name
.clone()
.unwrap_or_default()
);
body = body.add(
Row::new()
.col(markup(move |_| format!("<p>{}</p>", escape(&label))))
.col(
ActionButton::new(
t!("zeyn.accounts.logins.remove"),
account_remove_login,
LoginRef {
provider: l.provider,
key: l.provider_key,
},
)
.confirm(t!("zeyn.accounts.logins.remove_confirm"))
.on_success(next_effect),
),
);
}
let available: Vec<String> = accounts
.external_names()
.into_iter()
.filter(|p| !linked.contains(p))
.collect();
if !available.is_empty() {
let prefix = scope::prefix();
body = body.add(markup(move |r| {
let items: String = available
.iter()
.map(|p| format!(r#"<li><a class="z-button" href="{0}/_zeyn/account/external/{1}?link=1&return_to={2}">{3} {1}</a></li>"#, escape(&prefix), escape(p), super::pct(&format!("{prefix}/account/manage/logins")), tx(r, "logins.add")))
.collect();
format!(r#"<ul class="z-account-providers">{items}</ul>"#)
}));
}
Ok(page("manage.logins").body(body))
}
#[model]
pub struct AccountDelete {
#[rule(max_len = 1024)]
pub password: String,
}
#[action(public, ai = false)]
pub async fn account_delete(input: Valid<AccountDelete>, accounts: Accounts) -> Result<Next> {
let mut user = me(&accounts).await?;
accounts.sign_in().throttle_ip().await?;
if user.password_hash.is_some()
&& !accounts
.users()
.check_password(&mut user, &input.password)
.await?
{
return Err(invalid("password", "password_mismatch"));
}
accounts.users().delete(&user).await?;
accounts
.sign_in()
.sign_out(Some(&user.id));
go(
with_params("/account/login", json!({ "notice": "deleted" })),
true,
)
}
#[screen(route = "/account/manage/personal-data", public)]
pub async fn account_personal_data(_: (), accounts: Accounts) -> Result<Screen> {
let user = me(&accounts).await?;
let download = path("/_zeyn/account/manage/personalData");
let mut form = Form::of(AccountDelete::default());
if user.password_hash.is_some() {
form = form
.field(AccountDelete::password)
.control(AccountDelete::password, Control::Password);
}
Ok(page("manage.personal_data").body(
Stack::new()
.add(manage_nav("/account/manage/personal-data"))
.add(para("zeyn.accounts.data.intro"))
.add(markup(move |r| {
format!(
r#"<p><a class="z-button" href="{}" download>{}</a></p>"#,
escape(&download),
tx(r, "data.download")
)
}))
.add(Alert::danger(t!("zeyn.accounts.data.delete_warning")))
.add(
form.submit(t!("zeyn.accounts.data.delete"), account_delete)
.on_success(next_effect),
),
))
}
#[model]
pub struct AccountUserRow {
#[readonly]
pub id: String,
pub user_name: String,
pub email: String,
pub email_confirmed: bool,
pub two_factor: bool,
pub locked: bool,
pub roles: String,
}
#[action(permission = "accounts.users.read", ai = false)]
pub async fn account_admin_list(q: DataQuery, accounts: Accounts) -> Result<Page<AccountUserRow>> {
let (offset, limit) = match q.page {
crate::action::PageRequest::Offset { offset, limit } => (offset, limit),
crate::action::PageRequest::After { limit, .. } => (0, limit),
};
let search = q
.filters
.iter()
.find(|f| !f.contains.is_empty())
.map(|f| f.contains.clone());
let (users, total) = accounts
.users()
.list(search.as_deref(), offset, limit.min(crate::MAX_PAGE_SIZE))
.await?;
let mut rows = Vec::with_capacity(users.len());
for u in users {
let roles = accounts
.users()
.roles(&u)
.await?
.join(", ");
rows.push(AccountUserRow {
locked: u.is_locked_out(super::now_secs()),
id: u.id,
user_name: u.user_name,
email: u.email.unwrap_or_default(),
email_confirmed: u.email_confirmed,
two_factor: u.two_factor_enabled,
roles,
});
}
Ok(Page::new(rows).total(total))
}
async fn admin_user(accounts: &Accounts, id: &str) -> Result<User> {
accounts
.users()
.find_by_id(id)
.await?
.ok_or(Error::NotFound)
}
#[action(permission = "accounts.users.write", audit, ai = false)]
pub async fn account_admin_lock(id: String, accounts: Accounts) -> Result<()> {
let mut user = admin_user(&accounts, &id).await?;
user.lockout_enabled = true;
accounts
.users()
.set_lockout_end(&mut user, Some(super::now_secs() + 100 * 365 * 86_400))
.await?;
accounts
.users()
.update_security_stamp(&mut user)
.await
}
#[action(permission = "accounts.users.write", audit, ai = false)]
pub async fn account_admin_unlock(id: String, accounts: Accounts) -> Result<()> {
let mut user = admin_user(&accounts, &id).await?;
user.lockout_enabled = true;
accounts
.users()
.set_lockout_end(&mut user, None)
.await
}
#[action(permission = "accounts.users.write", audit, ai = false)]
pub async fn account_admin_reset(id: String, accounts: Accounts, ctx: Ctx) -> Result<()> {
let user = admin_user(&accounts, &id).await?;
let to = user
.email
.clone()
.ok_or_else(|| invalid("email", "invalid_email"))?;
let code = accounts
.users()
.generate_password_reset_token(&user);
let link = crate::server::accounts::screen_link(
&accounts,
"/account/reset-password",
&json!({ "user": user.id, "code": code }),
);
accounts
.0
.sender
.send(&ctx, &to, super::AccountMessage::ResetPassword { link })
.await
}
#[screen(route = "/account/admin/users", permission = "accounts.users.read")]
pub fn account_admin_users(_: ()) -> Screen {
page("admin.users").body(
DataGrid::of(account_admin_list)
.mode(DataMode::Server)
.column(AccountUserRow::user_name)
.filter()
.column(AccountUserRow::email)
.column(AccountUserRow::email_confirmed)
.column(AccountUserRow::two_factor)
.column(AccountUserRow::locked)
.column(AccountUserRow::roles)
.row_action(
t!("zeyn.accounts.admin.lock"),
account_admin_lock,
|u: &AccountUserRow| u.id.clone(),
)
.row_action(
t!("zeyn.accounts.admin.unlock"),
account_admin_unlock,
|u: &AccountUserRow| u.id.clone(),
)
.row_action(
t!("zeyn.accounts.admin.reset"),
account_admin_reset,
|u: &AccountUserRow| u.id.clone(),
)
.on_open(|u| Window::open(account_admin_user_roles, UserParams { id: u.id.clone() })),
)
}
#[params]
pub struct UserParams {
pub id: String,
}
#[model]
pub struct AccountUserRoles {
#[readonly]
pub id: String,
#[rule(max_len = 2000)]
pub roles: String,
}
#[action(permission = "accounts.users.write", audit, ai = false)]
pub async fn account_admin_save_roles(
input: Valid<AccountUserRoles>,
accounts: Accounts,
) -> Result<()> {
let mut user = admin_user(&accounts, &input.id).await?;
let wanted: Vec<String> = input
.roles
.split(',')
.map(str::trim)
.filter(|r| !r.is_empty())
.map(str::to_string)
.collect();
for name in &wanted {
if !accounts.roles().exists(name).await? {
return Err(invalid("roles", "unknown_role"));
}
}
let current = accounts.users().roles(&user).await?;
for r in ¤t {
if !wanted
.iter()
.any(|w| super::normalize(w) == super::normalize(r))
{
accounts
.users()
.remove_from_role(&mut user, r)
.await?;
}
}
for w in &wanted {
if !current
.iter()
.any(|r| super::normalize(w) == super::normalize(r))
{
accounts
.users()
.add_to_role(&mut user, w)
.await?;
}
}
Ok(())
}
#[screen(permission = "accounts.users.write")]
pub async fn account_admin_user_roles(p: UserParams, accounts: Accounts) -> Result<Screen> {
let user = admin_user(&accounts, &p.id).await?;
let roles = accounts
.users()
.roles(&user)
.await?
.join(", ");
Ok(
Screen::window(Text::Literal(user.user_name.clone().into())).body(
Form::of(AccountUserRoles { id: user.id, roles })
.field(AccountUserRoles::roles)
.submit(t!("zeyn.accounts.save"), account_admin_save_roles)
.on_success(|_, ui| ui.close_and_refresh()),
),
)
}
#[model]
pub struct AccountRoleRow {
#[readonly]
pub id: String,
#[rule(required, max_len = 254)]
pub name: String,
pub permissions: String,
}
#[action(permission = "accounts.roles.write", ai = false)]
pub async fn account_admin_role_list(
q: DataQuery,
accounts: Accounts,
) -> Result<Page<AccountRoleRow>> {
let (offset, limit) = match q.page {
crate::action::PageRequest::Offset { offset, limit } => (offset, limit),
crate::action::PageRequest::After { limit, .. } => (0, limit),
};
let (roles, total) = accounts
.roles()
.list(offset, limit)
.await?;
let mut rows = Vec::with_capacity(roles.len());
for r in roles {
let claims = accounts.roles().claims(&r).await?;
let permissions = claims
.into_iter()
.filter(|c| c.kind == "permission")
.map(|c| c.value)
.collect::<Vec<_>>()
.join(", ");
rows.push(AccountRoleRow {
id: r.id,
name: r.name,
permissions,
});
}
Ok(Page::new(rows).total(total))
}
#[action(permission = "accounts.roles.write", audit, ai = false)]
pub async fn account_admin_create_role(
input: Valid<AccountRoleRow>,
accounts: Accounts,
) -> Result<()> {
let role = accounts
.roles()
.create(&input.name)
.await?;
for p in input
.permissions
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
{
accounts
.roles()
.add_claim(&role, super::Claim::new("permission", p))
.await?;
}
Ok(())
}
#[action(permission = "accounts.roles.write", audit, ai = false)]
pub async fn account_admin_delete_role(id: String, accounts: Accounts) -> Result<()> {
let role = accounts
.roles()
.find_by_id(&id)
.await?
.ok_or(Error::NotFound)?;
accounts.roles().delete(&role).await
}
#[screen(route = "/account/admin/roles", permission = "accounts.roles.write")]
pub fn account_admin_roles(_: ()) -> Screen {
page("admin.roles").body(
Stack::new()
.add(
DataGrid::of(account_admin_role_list)
.mode(DataMode::Server)
.column(AccountRoleRow::name)
.column(AccountRoleRow::permissions)
.row_action(
t!("zeyn.accounts.admin.delete_role"),
account_admin_delete_role,
|r: &AccountRoleRow| r.id.clone(),
),
)
.add(
Form::of(AccountRoleRow::default())
.field(AccountRoleRow::name)
.field(AccountRoleRow::permissions)
.submit(
t!("zeyn.accounts.admin.create_role"),
account_admin_create_role,
)
.on_success(|_, ui| {
ui.effect(Effect::go(&path("/account/admin/roles"), false))
}),
),
)
}
#[cfg(feature = "oidc")]
#[model]
pub struct AccountExternal {
#[rule(required, email, max_len = 254)]
pub email: String,
}
#[cfg(feature = "oidc")]
#[action(public, ai = false)]
pub async fn account_external_create(
input: Valid<AccountExternal>,
accounts: Accounts,
) -> Result<Next> {
use crate::server::accounts::{EXTERNAL_PENDING, External};
let pending = scope::cookie(EXTERNAL_PENDING)
.and_then(|v| {
accounts
.0
.sealer
.as_ref()?
.open::<External>(EXTERNAL_PENDING, &v)
})
.filter(|e| e.expires > super::now_secs());
let Some(p) = pending else {
return Err(denied("external_expired"));
};
if accounts
.users()
.find_by_name_or_email(&input.email)
.await?
.is_some()
{
return Err(invalid("email", "external_email_taken"));
}
let mut user = User::new(&input.email).with_email(&input.email);
user.email_confirmed = p.verified
&& p.email
.as_deref()
.is_some_and(|e| super::normalize(e) == super::normalize(&input.email));
let mut user = accounts
.users()
.create(user, None)
.await?;
accounts
.users()
.add_login(
&mut user,
super::UserLogin {
provider: p.provider.clone(),
provider_key: p.key,
display_name: p.email.clone(),
},
)
.await?;
scope::set_cookie(EXTERNAL_PENDING, None, None);
accounts.event(super::AccountEventKind::Registered, Some(&user.id));
if !accounts.sign_in().can_sign_in(&user) {
return go(path("/account/check-email"), false);
}
accounts
.sign_in()
.sign_in(&user, false, &p.provider, false)
.await?;
go(back(&p.return_to), true)
}
#[cfg(feature = "oidc")]
#[screen(route = "/account/external-login", public)]
pub fn account_external_login(_: (), accounts: Accounts) -> Screen {
use crate::server::accounts::{EXTERNAL_PENDING, External};
let pending = scope::cookie(EXTERNAL_PENDING)
.and_then(|v| {
accounts
.0
.sealer
.as_ref()?
.open::<External>(EXTERNAL_PENDING, &v)
})
.filter(|e| e.expires > super::now_secs());
let Some(p) = pending else {
return page("external.title").body(
Stack::new()
.add(Alert::warning(t!("zeyn.accounts.error.external_expired")))
.add(link("register.login", "/account/login")),
);
};
let provider = p.provider.clone();
page("external.title").body(
Stack::new()
.add(markup(move |r| {
format!(
"<p>{} <strong>{}</strong></p>",
tx(r, "external.intro"),
escape(&provider)
)
}))
.add(
Form::of(AccountExternal {
email: p.email.unwrap_or_default(),
})
.field(AccountExternal::email)
.submit(t!("zeyn.accounts.external.submit"), account_external_create)
.on_success(next_effect),
),
)
}