Menu
Public repository / 3579c0413818863c25c30db0bc4ae7386c1fc69f
AkurAI-IDP / src/routes/account.rs
Latest change 26d50ee76eb4c6c0aaca914e91a507fae2a243a6 - feat: add tenant-safe organization identity by Ólafur Búi Ólafsson
use axum::{
Form, Router,
extract::{FromRequestParts, Path},
http::{HeaderMap, StatusCode, header, request::Parts},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use rusqlite::OptionalExtension;
use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config;
use crate::db::with_db;
use crate::lib::audit::{
SESSION_REVOKED, USER_MFA_DISABLED, USER_MFA_ENABLED, USER_PASSWORD_CHANGED, log_audit_event,
};
use crate::lib::crypto::{generate_secure_token, sha256};
use crate::lib::html::{Locale, account_page, esc_html, t};
use crate::lib::password::{hash_password, verify_password};
use crate::lib::totp::{generate_backup_codes, generate_totp_secret, verify_totp};
use crate::middleware::auth::{AuthSession, AuthUser};
pub fn router() -> Router {
Router::new()
.route("/account", get(account_overview))
.route(
"/account/password",
get(password_page).post(password_submit),
)
.route("/account/mfa", get(mfa_status))
.route(
"/account/mfa/setup",
get(mfa_setup_page).post(mfa_setup_submit),
)
.route("/account/mfa/disable", post(mfa_disable))
.route("/account/sessions", get(sessions_page))
.route("/account/sessions/{id}/revoke", post(session_revoke))
.route("/account/sessions/revoke-all", post(sessions_revoke_all))
.route("/account/organization", post(select_organization))
}
// ---------------------------------------------------------------------------
// Form structs
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct PasswordForm {
current_password: Option<String>,
new_password: Option<String>,
_csrf: Option<String>,
}
#[derive(Deserialize)]
struct MfaSetupForm {
code: Option<String>,
secret: Option<String>,
_csrf: Option<String>,
}
#[derive(Deserialize)]
struct MfaDisableForm {
password: Option<String>,
_csrf: Option<String>,
}
#[derive(Deserialize)]
struct OrganizationForm {
organization_id: Option<String>,
_csrf: Option<String>,
}
// ---------------------------------------------------------------------------
// Session context supplied by session_auth middleware.
// ---------------------------------------------------------------------------
struct SessionUser {
session_id: String,
user_id: String,
tenant_id: String,
email: String,
}
impl<S> FromRequestParts<S> for SessionUser
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let session = parts
.extensions
.get::<AuthSession>()
.ok_or_else(|| redirect("/login"))?;
let user = parts
.extensions
.get::<AuthUser>()
.ok_or_else(|| redirect("/login"))?;
Ok(Self {
session_id: session.session_id.clone(),
user_id: user.id.clone(),
tenant_id: user.tenant_id.clone(),
email: user.email.clone(),
})
}
}
// ---------------------------------------------------------------------------
// GET /account
// ---------------------------------------------------------------------------
async fn account_overview(headers: HeaderMap, user: SessionUser) -> Response {
let locale = locale_from_headers(&headers);
let (mfa_enabled, session_count, tenant_name, organizations) = with_db(|conn| {
let mfa: bool = conn
.query_row(
"SELECT mfa_enabled FROM users WHERE id = ?1",
rusqlite::params![user.user_id],
|row| row.get::<_, i64>(0),
)
.map(|v| v != 0)
.unwrap_or(false);
let sessions: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1 AND expires_at > ?2",
rusqlite::params![user.user_id, now_epoch()],
|row| row.get(0),
)
.unwrap_or(0);
let tenant: String = conn
.query_row(
"SELECT display_name FROM organizations WHERE id = ?1",
rusqlite::params![user.tenant_id],
|row| row.get(0),
)
.unwrap_or_else(|_| user.tenant_id.clone());
let organizations = conn
.prepare(
"SELECT o.id, o.display_name FROM organizations o
JOIN organization_memberships m ON m.organization_id = o.id
WHERE m.user_id = ?1 AND m.status = 'active'
ORDER BY o.display_name, o.id",
)
.and_then(|mut stmt| {
stmt.query_map([&user.user_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()
})
.unwrap_or_default();
(mfa, sessions, tenant, organizations)
});
let csrf = get_csrf_cookie(&headers).unwrap_or_default();
let organization_options = organizations
.iter()
.map(|(id, name)| {
format!(
r#"<form method="post" action="/account/organization" style="display:inline">
<input type="hidden" name="_csrf" value="{}">
<input type="hidden" name="organization_id" value="{}">
<button type="submit"{}>{}</button>
</form>"#,
esc_html(&csrf),
esc_html(id),
if id == &user.tenant_id {
" disabled"
} else {
""
},
esc_html(name),
)
})
.collect::<Vec<_>>()
.join("");
let lbl_enabled = t(locale, "Virkt", "Enabled");
let lbl_disabled = t(locale, "Óvirkt", "Disabled");
let mfa_badge = if mfa_enabled {
format!(r#"<span class="badge badge-ok">{lbl_enabled}</span>"#)
} else {
format!(r#"<span class="badge badge-warn">{lbl_disabled}</span>"#)
};
let agent_url = &config::get().agent_public_url;
let body = format!(
r#"<h2>{h_overview}</h2>
<dl>
<dt>{lbl_email}</dt><dd>{email}</dd>
<dt>{lbl_tenant}</dt><dd>{tenant}</dd>
<dt>{lbl_mfa}</dt><dd>{mfa_badge}</dd>
<dt>{lbl_sessions}</dt><dd>{sessions}</dd>
</dl>
<div>{organization_options}</div>
<nav>
<ul>
<li><a href="/account/password">{lbl_change_pw}</a></li>
<li><a href="/account/mfa">{lbl_2fa}</a></li>
<li><a href="/account/sessions">{lbl_active_sessions}</a></li>
<li><a href="{agent_url}">{lbl_agent}</a></li>
<li><a href="/logout">{lbl_signout}</a></li>
</ul>
</nav>"#,
h_overview = t(locale, "Yfirlit reiknings", "Account overview"),
lbl_email = t(locale, "Netfang", "Email"),
lbl_tenant = t(locale, "Leigjandi", "Tenant"),
lbl_mfa = t(locale, "Tvíþátta auðkenning", "Two-factor auth"),
lbl_sessions = t(locale, "Virkar lotur", "Active sessions"),
lbl_2fa = t(locale, "Tvíþátta auðkenning", "Two-factor authentication"),
lbl_active_sessions = t(locale, "Virkar lotur", "Active sessions"),
lbl_agent = t(locale, "Stjórnborð fulltrúa", "Agent console"),
lbl_signout = t(locale, "Skrá út", "Sign out"),
lbl_change_pw = t(locale, "Breyta lykilorði", "Change password"),
email = esc_html(&user.email),
tenant = esc_html(&tenant_name),
sessions = session_count,
mfa_badge = mfa_badge,
organization_options = organization_options,
agent_url = esc_html(agent_url),
);
Html(account_page(
locale,
t(locale, "Reikningur", "Account"),
&body,
))
.into_response()
}
// ---------------------------------------------------------------------------
// GET /account/password
// ---------------------------------------------------------------------------
async fn password_page(headers: HeaderMap, user: SessionUser) -> Response {
let locale = locale_from_headers(&headers);
let csrf = get_csrf_cookie(&headers).unwrap_or_default();
let _ = user; // session verified
let page_title = t(locale, "Breyta lykilorði", "Change password");
let body = format!(
r#"<h2>{page_title}</h2>
<form method="post" action="/account/password">
<input type="hidden" name="_csrf" value="{csrf}">
<label for="current_password">{lbl_current}</label>
<input type="password" id="current_password" name="current_password" required>
<label for="new_password">{lbl_new}</label>
<input type="password" id="new_password" name="new_password" required minlength="8">
<button type="submit">{lbl_submit}</button>
</form>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
csrf = esc_html(&csrf),
lbl_current = t(locale, "Núverandi lykilorð", "Current password"),
lbl_new = t(locale, "Nýtt lykilorð", "New password"),
lbl_submit = t(locale, "Breyta lykilorði", "Change password"),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// POST /account/password
// ---------------------------------------------------------------------------
async fn password_submit(
headers: HeaderMap,
user: SessionUser,
Form(form): Form<PasswordForm>,
) -> Response {
let locale = locale_from_headers(&headers);
let current = form.current_password.as_deref().unwrap_or("");
let new_pw = form.new_password.as_deref().unwrap_or("");
let ip = extract_ip(&headers);
if current.is_empty() || new_pw.is_empty() {
return account_error(locale, "Change password", "All fields are required.");
}
if new_pw.len() < 8 {
return account_error(
locale,
"Change password",
"New password must be at least 8 characters.",
);
}
// Load current hash
let stored_hash = with_db(|conn| {
conn.query_row(
"SELECT password_hash FROM users WHERE id = ?1",
rusqlite::params![user.user_id],
|row| row.get::<_, String>(0),
)
.optional()
});
let stored_hash = match stored_hash {
Ok(Some(h)) => h,
Ok(None) => return account_error(locale, "Change password", "User not found."),
Err(error) => {
tracing::error!(%error, "failed to read password hash");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
};
if !verify_password(&stored_hash, current).unwrap_or(false) {
return account_error(locale, "Change password", "Current password is incorrect.");
}
// Check new != old
if verify_password(&stored_hash, new_pw).unwrap_or(false) {
return account_error(
locale,
"Change password",
"New password must be different from current.",
);
}
let new_hash = match hash_password(new_pw) {
Ok(h) => h,
Err(_) => return account_error(locale, "Change password", "Failed to hash password."),
};
let updated = with_db(|conn| -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_hash, now_epoch(), user.user_id],
)?;
crate::lib::organizations::bump_authorization_version(conn, &user.user_id)?;
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.user_id),
USER_PASSWORD_CHANGED,
Some(&ip),
None,
);
Ok(())
});
if let Err(error) = updated {
tracing::error!(%error, "failed to update password");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
let page_title = t(locale, "Breyta lykilorði", "Change password");
let body = format!(
r#"<h2>{page_title}</h2>
<div class="success">{lbl_success}</div>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
lbl_success = t(
locale,
"Lykilorði hefur verið breytt.",
"Password changed successfully."
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// GET /account/mfa
// ---------------------------------------------------------------------------
async fn mfa_status(headers: HeaderMap, user: SessionUser) -> Response {
let locale = locale_from_headers(&headers);
let mfa_enabled = with_db(|conn| {
conn.query_row(
"SELECT mfa_enabled FROM users WHERE id = ?1",
rusqlite::params![user.user_id],
|row| row.get::<_, i64>(0),
)
.map(|v| v != 0)
.unwrap_or(false)
});
let csrf = get_csrf_cookie(&headers).unwrap_or_default();
let page_title = t(locale, "Tvíþátta auðkenning", "Two-factor authentication");
let body = if mfa_enabled {
format!(
r#"<h2>{page_title}</h2>
<p>{lbl_is_enabled}</p>
<form method="post" action="/account/mfa/disable">
<input type="hidden" name="_csrf" value="{csrf}">
<label for="password">{lbl_confirm_pw}</label>
<input type="password" id="password" name="password" required>
<button type="submit" class="danger">{lbl_disable}</button>
</form>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
csrf = esc_html(&csrf),
lbl_is_enabled = t(
locale,
"Tvíþátta auðkenning er <strong>virk</strong>.",
"Two-factor authentication is <strong>enabled</strong>."
),
lbl_confirm_pw = t(
locale,
"Staðfestu lykilorðið þitt til að afvirkja",
"Confirm your password to disable"
),
lbl_disable = t(
locale,
"Afvirkja tvíþátta auðkenningu",
"Disable two-factor auth"
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
)
} else {
format!(
r#"<h2>{page_title}</h2>
<p>{lbl_not_enabled}</p>
<p><a href="/account/mfa/setup">{lbl_setup}</a></p>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
lbl_not_enabled = t(
locale,
"Tvíþátta auðkenning er <strong>ekki virk</strong>.",
"Two-factor authentication is <strong>not enabled</strong>."
),
lbl_setup = t(
locale,
"Setja upp tvíþátta auðkenningu",
"Set up two-factor authentication"
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
)
};
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// GET /account/mfa/setup
// ---------------------------------------------------------------------------
async fn mfa_setup_page(headers: HeaderMap, user: SessionUser) -> Response {
let locale =
Locale::from_cookie_header(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
let csrf = get_csrf_cookie(&headers).unwrap_or_default();
let (secret, otpauth_uri) = generate_totp_secret(&user.email, "AkurAI");
let page_title = t(locale, "Setja upp 2FA", "Set up 2FA");
let body = format!(
r#"<h2>{h_setup}</h2>
<p>{lbl_scan}</p>
<p class="mono"><code>{secret}</code></p>
<p class="small">otpauth URI: <code>{uri}</code></p>
<form method="post" action="/account/mfa/setup">
<input type="hidden" name="_csrf" value="{csrf}">
<input type="hidden" name="secret" value="{secret}">
<label for="code">{lbl_code}</label>
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]{{6}}" autocomplete="one-time-code" required autofocus>
<button type="submit">{lbl_verify}</button>
</form>
<p><a href="/account/mfa">{lbl_cancel}</a></p>"#,
h_setup = t(
locale,
"Setja upp tvíþátta auðkenningu",
"Set up two-factor authentication"
),
lbl_scan = t(
locale,
"Skannaðu þennan kóða með auðkenningarforritinu þínu, eða sláðu inn lykilinn handvirkt:",
"Scan this code with your authenticator app, or enter the key manually:"
),
lbl_code = t(
locale,
"Sláðu inn staðfestingarkóða",
"Enter verification code"
),
lbl_verify = t(locale, "Staðfesta og virkja", "Verify and enable"),
lbl_cancel = t(locale, "Hætta við", "Cancel"),
csrf = esc_html(&csrf),
secret = esc_html(&secret),
uri = esc_html(&otpauth_uri),
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// POST /account/mfa/setup
// ---------------------------------------------------------------------------
async fn mfa_setup_submit(
headers: HeaderMap,
user: SessionUser,
Form(form): Form<MfaSetupForm>,
) -> Response {
let locale = locale_from_headers(&headers);
let code = form.code.as_deref().unwrap_or("").trim().to_string();
let secret = form.secret.as_deref().unwrap_or("").trim().to_string();
let ip = extract_ip(&headers);
if code.is_empty() || secret.is_empty() {
return account_error(locale, "Set up 2FA", "Code and secret are required.");
}
// Verify the code against the provided secret (±1 window configured in totp module)
let valid = verify_totp(&secret, &code);
if !valid {
return account_error(
locale,
"Set up 2FA",
"Invalid verification code. Please try again.",
);
}
let now = now_epoch();
let codes = generate_backup_codes(10);
let saved = with_db(|conn| -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET mfa_secret = ?1, mfa_enabled = 1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![secret, now, user.user_id],
)?;
conn.execute(
"DELETE FROM backup_codes WHERE user_id = ?1",
rusqlite::params![user.user_id],
)?;
for raw_code in &codes {
conn.execute(
"INSERT INTO backup_codes (id, user_id, code_hash, used) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![generate_secure_token(16), user.user_id, sha256(raw_code)],
)?;
}
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.user_id),
USER_MFA_ENABLED,
Some(&ip),
None,
);
Ok(())
});
if let Err(error) = saved {
tracing::error!(%error, "failed to enable MFA");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
let codes_html: String = codes
.iter()
.map(|c| format!("<li><code>{}</code></li>", esc_html(c)))
.collect::<Vec<_>>()
.join("\n");
let page_title = t(locale, "2FA virkt", "2FA enabled");
let body = format!(
r#"<h2>{h_enabled}</h2>
<div class="success">{lbl_success}</div>
<h3>{h_backup}</h3>
<p><strong>{lbl_save}</strong> {lbl_each}</p>
<ul class="backup-codes">
{codes_html}
</ul>
<p><a href="/account">{lbl_back}</a></p>"#,
h_enabled = t(
locale,
"Tvíþátta auðkenning virk",
"Two-factor authentication enabled"
),
lbl_success = t(
locale,
"Tvíþátta auðkenning hefur verið virkjuð.",
"Two-factor authentication has been enabled."
),
h_backup = t(locale, "Varakóðar", "Backup codes"),
lbl_save = t(
locale,
"Vistaðu þessa kóða á öruggum stað.",
"Save these codes somewhere safe."
),
lbl_each = t(
locale,
"Hægt er að nota hvern eitt sinni ef þú missir auðkenningartækið.",
"Each can be used once if you lose your authenticator."
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
codes_html = codes_html,
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// POST /account/mfa/disable
// ---------------------------------------------------------------------------
async fn mfa_disable(
headers: HeaderMap,
user: SessionUser,
Form(form): Form<MfaDisableForm>,
) -> Response {
let locale = locale_from_headers(&headers);
let password = form.password.as_deref().unwrap_or("");
let ip = extract_ip(&headers);
if password.is_empty() {
return account_error(locale, "Two-factor authentication", "Password is required.");
}
// Verify current password
let stored_hash = with_db(|conn| {
conn.query_row(
"SELECT password_hash FROM users WHERE id = ?1",
rusqlite::params![user.user_id],
|row| row.get::<_, String>(0),
)
.ok()
});
let stored_hash = match stored_hash {
Some(h) => h,
None => return account_error(locale, "Two-factor authentication", "User not found."),
};
if !verify_password(&stored_hash, password).unwrap_or(false) {
return account_error(
locale,
"Two-factor authentication",
"Password is incorrect.",
);
}
let disabled = with_db(|conn| -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET mfa_secret = NULL, mfa_enabled = 0, updated_at = ?1 WHERE id = ?2",
rusqlite::params![now_epoch(), user.user_id],
)?;
conn.execute(
"DELETE FROM backup_codes WHERE user_id = ?1",
rusqlite::params![user.user_id],
)?;
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.user_id),
USER_MFA_DISABLED,
Some(&ip),
None,
);
Ok(())
});
if let Err(error) = disabled {
tracing::error!(%error, "failed to disable MFA");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
let page_title = t(locale, "Tvíþátta auðkenning", "Two-factor authentication");
let body = format!(
r#"<h2>{page_title}</h2>
<div class="success">{lbl_disabled}</div>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
lbl_disabled = t(
locale,
"Tvíþátta auðkenning hefur verið afvirkjuð.",
"Two-factor authentication has been disabled."
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// GET /account/sessions
// ---------------------------------------------------------------------------
async fn sessions_page(headers: HeaderMap, user: SessionUser) -> Response {
let locale = locale_from_headers(&headers);
let csrf = get_csrf_cookie(&headers).unwrap_or_default();
let now = now_epoch();
let sessions = with_db(|conn| {
let mut stmt = conn
.prepare(
"SELECT id, ip, user_agent, created_at, expires_at
FROM sessions
WHERE user_id = ?1 AND expires_at > ?2
ORDER BY created_at DESC",
)
.unwrap();
stmt.query_map(rusqlite::params![user.user_id, now], |row| {
Ok(SessionRow {
id: row.get(0)?,
ip: row.get(1)?,
user_agent: row.get(2)?,
created_at: row.get(3)?,
expires_at: row.get(4)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect::<Vec<_>>()
});
let lbl_current = t(locale, " (núverandi)", " (current)");
let lbl_revoke = t(locale, "Afturkalla", "Revoke");
let mut rows_html = String::new();
for s in &sessions {
let is_current = s.id == user.session_id;
let id_display = if s.id.len() > 12 {
format!("{}...", &s.id[..12])
} else {
s.id.clone()
};
let current_label = if is_current { lbl_current } else { "" };
let revoke_btn = if is_current {
String::new()
} else {
format!(
r#"<form method="post" action="/account/sessions/{id}/revoke" style="display:inline">
<input type="hidden" name="_csrf" value="{csrf}">
<button type="submit" class="small danger">{lbl}</button>
</form>"#,
id = esc_html(&s.id),
csrf = esc_html(&csrf),
lbl = lbl_revoke,
)
};
rows_html.push_str(&format!(
r#"<tr{class}>
<td><code>{id_display}</code>{current_label}</td>
<td>{ip}</td>
<td>{ua}</td>
<td>{created}</td>
<td>{expires}</td>
<td>{revoke_btn}</td>
</tr>"#,
class = if is_current {
r#" class="current""#
} else {
""
},
id_display = esc_html(&id_display),
current_label = current_label,
ip = esc_html(&s.ip),
ua = esc_html(&truncate_ua(&s.user_agent)),
created = format_epoch(s.created_at),
expires = format_epoch(s.expires_at),
revoke_btn = revoke_btn,
));
}
let page_title = t(locale, "Virkar lotur", "Active sessions");
let body = format!(
r#"<h2>{page_title}</h2>
<table>
<thead>
<tr><th>{th_id}</th><th>{th_ip}</th><th>{th_ua}</th><th>{th_created}</th><th>{th_expires}</th><th></th></tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
<form method="post" action="/account/sessions/revoke-all">
<input type="hidden" name="_csrf" value="{csrf}">
<button type="submit" class="danger">{lbl_revoke_all}</button>
</form>
<p><a href="/account">{lbl_back}</a></p>"#,
page_title = page_title,
th_id = t(locale, "Auðkenni", "ID"),
th_ip = t(locale, "IP-tala", "IP"),
th_ua = t(locale, "Notendaforrit", "User agent"),
th_created = t(locale, "Stofnað", "Created"),
th_expires = t(locale, "Rennur út", "Expires"),
lbl_revoke_all = t(
locale,
"Afturkalla allar aðrar lotur",
"Revoke all other sessions"
),
lbl_back = t(locale, "Aftur í reikning", "Back to account"),
csrf = esc_html(&csrf),
rows_html = rows_html,
);
Html(account_page(locale, page_title, &body)).into_response()
}
// ---------------------------------------------------------------------------
// POST /account/sessions/:id/revoke
// ---------------------------------------------------------------------------
async fn session_revoke(
headers: HeaderMap,
Path(target_id): Path<String>,
user: SessionUser,
) -> Response {
let ip = extract_ip(&headers);
// Don't allow revoking current session via this endpoint
if target_id == user.session_id {
return redirect("/account/sessions");
}
let revoked = with_db(|conn| -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM sessions WHERE id = ?1 AND user_id = ?2",
rusqlite::params![target_id, user.user_id],
)?;
let meta = serde_json::json!({"revoked_session": target_id});
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.user_id),
SESSION_REVOKED,
Some(&ip),
Some(&meta),
);
Ok(())
});
if let Err(error) = revoked {
tracing::error!(%error, "failed to revoke session");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
redirect("/account/sessions")
}
// ---------------------------------------------------------------------------
// POST /account/sessions/revoke-all
// ---------------------------------------------------------------------------
async fn sessions_revoke_all(headers: HeaderMap, user: SessionUser) -> Response {
let ip = extract_ip(&headers);
let revoked = with_db(|conn| -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM sessions WHERE user_id = ?1 AND id != ?2",
rusqlite::params![user.user_id, user.session_id],
)?;
let meta = serde_json::json!({"action": "revoke_all_other"});
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.user_id),
SESSION_REVOKED,
Some(&ip),
Some(&meta),
);
Ok(())
});
if let Err(error) = revoked {
tracing::error!(%error, "failed to revoke sessions");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
}
redirect("/account/sessions")
}
async fn select_organization(user: SessionUser, Form(form): Form<OrganizationForm>) -> Response {
let organization_id = form.organization_id.as_deref().unwrap_or("").trim();
if organization_id.is_empty() {
return redirect("/account");
}
let result: Result<bool, rusqlite::Error> = with_db(|conn| {
if crate::lib::organizations::active_membership(conn, &user.user_id, organization_id)?
.is_none()
{
return Ok(false);
}
let changed = conn.execute(
"UPDATE sessions SET active_organization_id = ?1
WHERE id = ?2 AND user_id = ?3",
rusqlite::params![organization_id, user.session_id, user.user_id],
)?;
Ok(changed == 1)
});
match result {
Ok(true) => redirect("/account"),
Ok(false) => redirect("/account"),
Err(error) => {
tracing::error!(%error, "failed to select active organization");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
struct SessionRow {
id: String,
ip: String,
user_agent: String,
created_at: i64,
expires_at: i64,
}
fn now_epoch() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn get_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let cookies = headers.get(header::COOKIE)?.to_str().ok()?;
let prefix = format!("{name}=");
for part in cookies.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix(&prefix) {
return Some(value.to_string());
}
}
None
}
fn get_csrf_cookie(headers: &HeaderMap) -> Option<String> {
get_cookie(headers, "_csrf")
}
fn extract_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|s| s.trim().to_string())
.or_else(|| {
headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_string())
})
.unwrap_or_default()
}
fn redirect(path: &str) -> Response {
(
StatusCode::SEE_OTHER,
[(header::LOCATION, path.to_string())],
)
.into_response()
}
fn locale_from_headers(headers: &HeaderMap) -> Locale {
Locale::from_cookie_header(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))
}
fn account_error(locale: Locale, title: &str, message: &str) -> Response {
let lbl_back = t(locale, "Aftur í reikning", "Back to account");
let body = format!(
r#"<h2>{title}</h2>
<div class="error">{message}</div>
<p><a href="/account">{lbl_back}</a></p>"#,
title = esc_html(title),
message = esc_html(message),
lbl_back = lbl_back,
);
Html(account_page(locale, title, &body)).into_response()
}
fn truncate_ua(ua: &str) -> String {
if ua.len() > 80 {
format!("{}...", &ua[..80])
} else {
ua.to_string()
}
}
fn format_epoch(ts: i64) -> String {
// Simple UTC timestamp display
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| ts.to_string())
}