With single sign-on there is no first-start code and no /setup form: the identity provider says who may sign in and who is admin, and the first allowed sign-in makes the organisation, named after ARCHIFY_SYNC_NAME. Server 0.1.1, chart 0.1.2.
578 lines
21 KiB
Rust
578 lines
21 KiB
Rust
//! The few pages people open in a browser: first-start setup, sign-in for
|
|
//! the editor (with sign-up through an invite), and the join page an invite
|
|
//! link opens.
|
|
|
|
use archify_sync_protocol::SetupRequest;
|
|
use archify_sync_store::{AccountRow, InviteOutcome};
|
|
use axum::Form;
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::{HeaderValue, StatusCode, header};
|
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::api;
|
|
use crate::auth;
|
|
use crate::error::ApiErr;
|
|
use crate::metrics::{METRICS, Metrics};
|
|
use crate::oidc::{self, Person};
|
|
use crate::state::AppState;
|
|
|
|
const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' vscode: vscode-insiders: vscodium: cursor: windsurf: http://127.0.0.1:* http://localhost:*; frame-ancestors 'none'; base-uri 'none'";
|
|
|
|
fn escape(text: &str) -> String {
|
|
text.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
fn page(state: &AppState, title: &str, body: &str) -> Response {
|
|
let html = format!(
|
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{title} · {server}</title><style>
|
|
:root {{ color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #f6f8fa; --card: #fff; --line: #d1d9e0; --accent: #0969da; --bad: #d1242f; }}
|
|
@media (prefers-color-scheme: dark) {{ :root {{ --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --card: #151b23; --line: #3d444d; --accent: #4493f8; --bad: #f85149; }} }}
|
|
body {{ margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }}
|
|
main {{ max-width: 420px; margin: 48px auto; padding: 0 16px; }}
|
|
.card {{ background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 24px; margin-bottom: 16px; }}
|
|
h1 {{ font-size: 20px; margin: 0 0 4px; }} h2 {{ font-size: 16px; margin: 0 0 12px; }}
|
|
p.muted {{ color: var(--muted); margin: 0 0 16px; }}
|
|
label {{ display: block; margin: 12px 0 4px; font-weight: 600; }}
|
|
input {{ width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }}
|
|
button, a.button {{ display: inline-block; margin-top: 16px; padding: 8px 16px; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }}
|
|
.error {{ color: var(--bad); margin: 0 0 12px; }} code {{ font-size: 14px; }}
|
|
</style></head><body><main><h1>{server}</h1><p class="muted">Archify Sync</p>{body}</main></body></html>"#,
|
|
title = escape(title),
|
|
server = escape(&state.config.server_name),
|
|
);
|
|
let mut response = Html(html).into_response();
|
|
let headers = response.headers_mut();
|
|
headers.insert(
|
|
header::CONTENT_SECURITY_POLICY,
|
|
HeaderValue::from_static(CSP),
|
|
);
|
|
headers.insert(
|
|
header::X_CONTENT_TYPE_OPTIONS,
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
headers.insert(
|
|
header::REFERRER_POLICY,
|
|
HeaderValue::from_static("no-referrer"),
|
|
);
|
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response
|
|
}
|
|
|
|
fn error_line(error: Option<&str>) -> String {
|
|
error
|
|
.map(|e| format!(r#"<p class="error">{}</p>"#, escape(e)))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn hidden(name: &str, value: &str) -> String {
|
|
format!(
|
|
r#"<input type="hidden" name="{name}" value="{}">"#,
|
|
escape(value)
|
|
)
|
|
}
|
|
|
|
// ------------------------------------------------------------------- setup
|
|
|
|
pub async fn setup_page(State(state): State<AppState>) -> Response {
|
|
setup_form(&state, None).await
|
|
}
|
|
|
|
async fn setup_form(state: &AppState, error: Option<&str>) -> Response {
|
|
if state.first_start.lock().await.is_none() {
|
|
return page(
|
|
state,
|
|
"Set up",
|
|
r#"<div class="card"><h2>Set up</h2><p>This server is set up. Sign in from VS Code with <b>Archify: Sign in to Sync Server…</b></p></div>"#,
|
|
);
|
|
}
|
|
let body = format!(
|
|
r#"<div class="card"><h2>Set up the first admin</h2><p class="muted">The first-start code is in the server's log.</p>{}
|
|
<form method="post" action="/setup">
|
|
<label for="code">First-start code</label><input id="code" name="code" autocomplete="off" required>
|
|
<label for="organisation">Organisation</label><input id="organisation" name="organisation" required>
|
|
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
|
|
<label for="display_name">Your name</label><input id="display_name" name="display_name">
|
|
<label for="password">Password (10 characters or more)</label><input id="password" name="password" type="password" autocomplete="new-password" required>
|
|
<button type="submit">Set up</button></form></div>"#,
|
|
error_line(error)
|
|
);
|
|
page(state, "Set up", &body)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetupForm {
|
|
code: String,
|
|
organisation: String,
|
|
name: String,
|
|
display_name: Option<String>,
|
|
password: String,
|
|
}
|
|
|
|
pub async fn setup_submit(State(state): State<AppState>, Form(form): Form<SetupForm>) -> Response {
|
|
let req = SetupRequest {
|
|
code: form.code,
|
|
organisation: form.organisation,
|
|
name: form.name,
|
|
display_name: form.display_name,
|
|
password: form.password,
|
|
};
|
|
match api::setup_admin(&state, &req).await {
|
|
Ok(account) => page(
|
|
&state,
|
|
"Set up",
|
|
&format!(
|
|
r#"<div class="card"><h2>Done</h2><p>{} is the admin. In VS Code, run <b>Archify: Sign in to Sync Server…</b> with <code>{}</code>.</p></div>"#,
|
|
escape(&account.display_name),
|
|
escape(&state.config.public_url)
|
|
),
|
|
),
|
|
Err(e) => setup_form(&state, Some(&e.message)).await,
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------- sign-in
|
|
|
|
/// The editor's sign-in request, carried through the password form or the identity provider.
|
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
pub struct AuthorizeQuery {
|
|
response_type: String,
|
|
redirect_uri: String,
|
|
state: String,
|
|
code_challenge: String,
|
|
code_challenge_method: String,
|
|
invite: Option<String>,
|
|
}
|
|
|
|
impl AuthorizeQuery {
|
|
fn to_query_string(&self) -> String {
|
|
let mut q = url::form_urlencoded::Serializer::new(String::new());
|
|
q.append_pair("response_type", &self.response_type)
|
|
.append_pair("redirect_uri", &self.redirect_uri)
|
|
.append_pair("state", &self.state)
|
|
.append_pair("code_challenge", &self.code_challenge)
|
|
.append_pair("code_challenge_method", &self.code_challenge_method);
|
|
if let Some(invite) = &self.invite {
|
|
q.append_pair("invite", invite);
|
|
}
|
|
q.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AuthorizeForm {
|
|
#[serde(flatten)]
|
|
query: AuthorizeQuery,
|
|
/// `signin` or `signup`.
|
|
action: String,
|
|
name: String,
|
|
password: String,
|
|
display_name: Option<String>,
|
|
}
|
|
|
|
fn check_authorize(state: &AppState, q: &AuthorizeQuery) -> Result<(), &'static str> {
|
|
if q.response_type != "code" || q.code_challenge_method != "S256" || q.code_challenge.len() < 43
|
|
{
|
|
return Err("This sign-in request is not valid. Start again from VS Code.");
|
|
}
|
|
if !auth::redirect_allowed(state, &q.redirect_uri) {
|
|
return Err("This sign-in request does not come from the Archify extension.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn authorize_page(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<AuthorizeQuery>,
|
|
) -> Response {
|
|
authorize_form(&state, &q, None).await
|
|
}
|
|
|
|
async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str>) -> Response {
|
|
if let Err(message) = check_authorize(state, q) {
|
|
let mut response = page(
|
|
state,
|
|
"Sign in",
|
|
&format!(r#"<div class="card">{}</div>"#, error_line(Some(message))),
|
|
);
|
|
*response.status_mut() = StatusCode::BAD_REQUEST;
|
|
return response;
|
|
}
|
|
let fields = [
|
|
hidden("response_type", &q.response_type),
|
|
hidden("redirect_uri", &q.redirect_uri),
|
|
hidden("state", &q.state),
|
|
hidden("code_challenge", &q.code_challenge),
|
|
hidden("code_challenge_method", &q.code_challenge_method),
|
|
q.invite
|
|
.as_deref()
|
|
.map(|i| hidden("invite", i))
|
|
.unwrap_or_default(),
|
|
]
|
|
.concat();
|
|
// Single sign-on first when the server has it; the password form stays for local accounts.
|
|
let mut body = state
|
|
.oidc
|
|
.as_ref()
|
|
.map(|o| {
|
|
format!(
|
|
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
|
|
error_line(error),
|
|
escape(&q.to_query_string()),
|
|
escape(&o.config.label),
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
let (title, error) = if body.is_empty() {
|
|
("Sign in for VS Code", error)
|
|
} else {
|
|
("Or with a password", None)
|
|
};
|
|
body.push_str(&format!(
|
|
r#"<div class="card"><h2>{title}</h2>{}
|
|
<form method="post" action="/authorize">{fields}{}
|
|
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
|
|
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
<button type="submit">Sign in</button></form></div>"#,
|
|
error_line(error),
|
|
hidden("action", "signin"),
|
|
));
|
|
// A valid invite lets someone new make an account in the space's organisation.
|
|
if let Some(invite) = &q.invite
|
|
&& let Ok(Some((_, space_name))) =
|
|
state.store.invite_organisation(&auth::sha256(invite)).await
|
|
{
|
|
body.push_str(&format!(
|
|
r#"<div class="card"><h2>New here?</h2><p class="muted">You were invited to <b>{}</b>. Make an account.</p>
|
|
<form method="post" action="/authorize">{fields}{}
|
|
<label for="new-name">Sign-in name</label><input id="new-name" name="name" autocomplete="username" required>
|
|
<label for="new-display">Your name</label><input id="new-display" name="display_name">
|
|
<label for="new-password">Password (10 characters or more)</label><input id="new-password" name="password" type="password" autocomplete="new-password" required>
|
|
<button type="submit">Make account</button></form></div>"#,
|
|
escape(&space_name),
|
|
hidden("action", "signup"),
|
|
));
|
|
}
|
|
page(state, "Sign in", &body)
|
|
}
|
|
|
|
pub async fn authorize_submit(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<AuthorizeForm>,
|
|
) -> Response {
|
|
let q = form.query.clone();
|
|
if check_authorize(&state, &q).is_err() {
|
|
return authorize_form(&state, &q, None).await;
|
|
}
|
|
let account = match form.action.as_str() {
|
|
"signup" => sign_up(&state, &form).await,
|
|
_ => sign_in(&state, &form).await,
|
|
};
|
|
match account {
|
|
Ok(account) => complete(&state, &q, &account).await,
|
|
Err(e) => authorize_form(&state, &q, Some(&e.message)).await,
|
|
}
|
|
}
|
|
|
|
/// Signed in: joins the invite's space, if any, and sends the editor its one-time code.
|
|
async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) -> Response {
|
|
if let Some(invite) = &q.invite
|
|
&& let Ok(InviteOutcome::Invalid) = state
|
|
.store
|
|
.accept_invite(&auth::sha256(invite), account)
|
|
.await
|
|
{
|
|
tracing::info!(account = %account.id, "signed in with an invite that is no longer valid");
|
|
}
|
|
let code = state
|
|
.codes
|
|
.insert(account.id, q.code_challenge.clone(), q.redirect_uri.clone())
|
|
.await;
|
|
let Ok(mut target) = url::Url::parse(&q.redirect_uri) else {
|
|
return authorize_form(state, q, None).await;
|
|
};
|
|
target
|
|
.query_pairs_mut()
|
|
.append_pair("code", &code)
|
|
.append_pair("state", &q.state);
|
|
Redirect::to(target.as_str()).into_response()
|
|
}
|
|
|
|
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
|
|
let name = form.name.trim();
|
|
if state.sign_in_limits.blocked(name).await {
|
|
Metrics::inc(&METRICS.sign_in_blocked_total);
|
|
return Err(ApiErr::too_many(
|
|
"Too many wrong passwords for this name. Try again in 15 minutes.",
|
|
));
|
|
}
|
|
let account = state.store.account_by_name(name).await?;
|
|
match account {
|
|
Some(a)
|
|
if a.password_hash
|
|
.as_deref()
|
|
.is_some_and(|h| auth::verify_password(&form.password, h)) =>
|
|
{
|
|
state.sign_in_limits.succeeded(name).await;
|
|
Ok(a)
|
|
}
|
|
_ => {
|
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
|
state.sign_in_limits.failed(name).await;
|
|
// The same work whether or not the name exists.
|
|
if account_missing(&account) {
|
|
let _ = auth::hash_password(&form.password);
|
|
}
|
|
Err(ApiErr::unauthorized("The name or the password is wrong."))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn account_missing(account: &Option<AccountRow>) -> bool {
|
|
account
|
|
.as_ref()
|
|
.and_then(|a| a.password_hash.as_ref())
|
|
.is_none()
|
|
}
|
|
|
|
async fn sign_up(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
|
|
let invite = form
|
|
.query
|
|
.invite
|
|
.as_deref()
|
|
.ok_or_else(|| ApiErr::forbidden("New accounts need an invite link."))?;
|
|
let (organisation, _) = state
|
|
.store
|
|
.invite_organisation(&auth::sha256(invite))
|
|
.await?
|
|
.ok_or_else(|| ApiErr::forbidden("This invite link has expired or was used up."))?;
|
|
let name = form.name.trim();
|
|
auth::check_name(name)?;
|
|
auth::check_new_password(&form.password)?;
|
|
let hash = auth::hash_password(&form.password)?;
|
|
let display = form
|
|
.display_name
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(name);
|
|
Ok(state
|
|
.store
|
|
.create_account(organisation, name, display, &hash)
|
|
.await?)
|
|
}
|
|
|
|
// ------------------------------------------------------------------- join
|
|
|
|
pub async fn join_page(State(state): State<AppState>, Path(token): Path<String>) -> Response {
|
|
let space = state
|
|
.store
|
|
.invite_organisation(&auth::sha256(&token))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some((_, space_name)) = space else {
|
|
let mut response = page(
|
|
&state,
|
|
"Join",
|
|
r#"<div class="card"><h2>Join</h2><p class="error">This invite link has expired or was used up. Ask for a new one.</p></div>"#,
|
|
);
|
|
*response.status_mut() = StatusCode::NOT_FOUND;
|
|
return response;
|
|
};
|
|
let link = |scheme: &str| {
|
|
let mut url = url::Url::parse(&format!("{scheme}://{}/join", state.config.extension_id))
|
|
.expect("valid base");
|
|
url.query_pairs_mut()
|
|
.append_pair("server", &state.config.public_url)
|
|
.append_pair("invite", &token);
|
|
escape(url.as_str())
|
|
};
|
|
let body = format!(
|
|
r#"<div class="card"><h2>Join {space}</h2><p>You were invited to work on the model <b>{space}</b> together.</p>
|
|
<p class="muted">You need the Archify Diagram Viewer extension.</p>
|
|
<a class="button" href="{vscode}">Open in VS Code</a></div>
|
|
<div class="card"><p class="muted">Other editors: <a href="{codium}">VSCodium</a> · <a href="{cursor}">Cursor</a></p></div>"#,
|
|
space = escape(&space_name),
|
|
vscode = link("vscode"),
|
|
codium = link("vscodium"),
|
|
cursor = link("cursor"),
|
|
);
|
|
page(&state, "Join", &body)
|
|
}
|
|
|
|
// --------------------------------------------------------- single sign-on
|
|
|
|
pub async fn oidc_start(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<AuthorizeQuery>,
|
|
) -> Response {
|
|
let Some(oidc) = &state.oidc else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
if check_authorize(&state, &q).is_err() {
|
|
return authorize_form(&state, &q, None).await;
|
|
}
|
|
match oidc.start(&state.config.public_url, q.clone()).await {
|
|
Ok(url) => Redirect::to(&url).into_response(),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "single sign-on did not start");
|
|
authorize_form(
|
|
&state,
|
|
&q,
|
|
Some(&format!("Single sign-on is not available: {error}.")),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CallbackQuery {
|
|
code: Option<String>,
|
|
state: Option<String>,
|
|
error: Option<String>,
|
|
error_description: Option<String>,
|
|
}
|
|
|
|
fn sso_problem(state: &AppState, message: &str) -> Response {
|
|
let mut response = page(
|
|
state,
|
|
"Sign in",
|
|
&format!(
|
|
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<p class="muted">Close this tab and sign in again from VS Code.</p></div>"#,
|
|
error_line(Some(message))
|
|
),
|
|
);
|
|
*response.status_mut() = StatusCode::BAD_REQUEST;
|
|
response
|
|
}
|
|
|
|
pub async fn oidc_callback(
|
|
State(state): State<AppState>,
|
|
Query(cb): Query<CallbackQuery>,
|
|
) -> Response {
|
|
let Some(oidc) = &state.oidc else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
if let Some(error) = cb.error {
|
|
let detail = cb.error_description.unwrap_or(error);
|
|
return sso_problem(
|
|
&state,
|
|
&format!("The identity provider did not sign you in: {detail}"),
|
|
);
|
|
}
|
|
let (Some(code), Some(sso_state)) = (cb.code, cb.state) else {
|
|
return sso_problem(&state, "The identity provider sent no sign-in.");
|
|
};
|
|
let (q, person) = match oidc
|
|
.finish(&state.config.public_url, &code, &sso_state)
|
|
.await
|
|
{
|
|
Ok(found) => found,
|
|
Err(error) => {
|
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
|
tracing::warn!(%error, "single sign-on failed");
|
|
return sso_problem(&state, &format!("Single sign-on failed: {error}."));
|
|
}
|
|
};
|
|
match sso_account(&state, &q, &person).await {
|
|
Ok(account) => {
|
|
tracing::info!(account = %account.id, name = %account.name, "signed in through single sign-on");
|
|
complete(&state, &q, &account).await
|
|
}
|
|
Err(e) => {
|
|
Metrics::inc(&METRICS.sign_in_failures_total);
|
|
authorize_form(&state, &q, Some(&e.message)).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The account of a person the identity provider signed in: found by the
|
|
/// provider's subject, or made on the first sign-in when an invite or the
|
|
/// allowed groups let them in.
|
|
async fn sso_account(
|
|
state: &AppState,
|
|
q: &AuthorizeQuery,
|
|
person: &Person,
|
|
) -> Result<AccountRow, ApiErr> {
|
|
let config = &state.oidc.as_ref().expect("checked by the caller").config;
|
|
if person.subject.is_empty() {
|
|
return Err(ApiErr::unauthorized(
|
|
"The identity provider did not say who you are.",
|
|
));
|
|
}
|
|
let admin = person.in_any(&config.admin_groups);
|
|
if !config.allowed_groups.is_empty() && !admin && !person.in_any(&config.allowed_groups) {
|
|
return Err(ApiErr::forbidden(format!(
|
|
"{} signed you in, but your account there is not in a group this server allows.",
|
|
config.label
|
|
)));
|
|
}
|
|
if let Some(mut account) = state
|
|
.store
|
|
.account_by_identity(&config.issuer, &person.subject)
|
|
.await?
|
|
{
|
|
if admin && account.role != "admin" {
|
|
state.store.set_role(account.id, "admin").await?;
|
|
account.role = "admin".into();
|
|
}
|
|
return Ok(account);
|
|
}
|
|
let organisation = match q.invite.as_deref() {
|
|
Some(invite) => state
|
|
.store
|
|
.invite_organisation(&auth::sha256(invite))
|
|
.await?
|
|
.map(|(org, _)| org),
|
|
None if !config.allowed_groups.is_empty() || admin => Some(
|
|
state
|
|
.store
|
|
.organisation_or_create(&state.config.server_name)
|
|
.await?,
|
|
),
|
|
None => None,
|
|
};
|
|
let Some(organisation) = organisation else {
|
|
return Err(ApiErr::forbidden(format!(
|
|
"{} signed you in, but you have no account on this server yet. Ask someone for an invite link and open it.",
|
|
config.label
|
|
)));
|
|
};
|
|
let name = oidc::account_name(person);
|
|
let display = person
|
|
.display_name
|
|
.clone()
|
|
.or_else(|| person.username.clone())
|
|
.unwrap_or_else(|| name.clone());
|
|
let role = if admin { "admin" } else { "member" };
|
|
match state
|
|
.store
|
|
.create_sso_account(
|
|
organisation,
|
|
&name,
|
|
&display,
|
|
role,
|
|
&config.issuer,
|
|
&person.subject,
|
|
person.email.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(account) => Ok(account),
|
|
// Two sign-ins of the same person at once: the other one made the account.
|
|
Err(archify_sync_store::StoreError::Conflict("identity")) => state
|
|
.store
|
|
.account_by_identity(&config.issuer, &person.subject)
|
|
.await?
|
|
.ok_or_else(|| ApiErr::unauthorized("Sign in again.")),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|