From f91021b2d52dd04f7b45e90b6cdf2402d53f456d Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 22 Oct 2026 17:50:08 +0100 Subject: [PATCH 1/3] ak-agent-desktop: list previous local authorizations --- Cargo.lock | 2 + Cargo.toml | 1 + ak-agent-desktop/src-tauri/Cargo.toml | 1 + ak-agent-desktop/src-tauri/src/cmd/authz.rs | 6 + ak-agent-desktop/src-tauri/src/cmd/mod.rs | 1 + ak-agent-desktop/src-tauri/src/lib.rs | 1 + ak-agent-desktop/src/bridge.ts | 23 +++ ak-agent-desktop/src/elements/app-shell.ts | 4 + ak-agent-desktop/src/pages/authorizations.ts | 162 +++++++++++++++++++ ak-agent/src/grpc/agent_auth.rs | 4 + ak-agent/src/ssh/txn.rs | 1 + ak-platform-authz/Cargo.toml | 1 + ak-platform-authz/src/lib.rs | 116 ++++++++++++- 13 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 ak-agent-desktop/src-tauri/src/cmd/authz.rs create mode 100644 ak-agent-desktop/src/pages/authorizations.ts diff --git a/Cargo.lock b/Cargo.lock index 5caafcd57..28293b56b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "ak-agent", "ak-meta", "ak-platform", + "ak-platform-authz", "ak-platform-keyring", "authentik-client", "eyre", @@ -365,6 +366,7 @@ dependencies = [ "ak-platform", "apple-localauthentication", "eyre", + "serde", "tokio", "tonic", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 201cbeb30..c7654c343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ ak-ee-wcp-wire = { path = "ee/wcp/wire" } ak-flow-executor = { path = "ak-flow-executor" } ak-meta = { path = "ak-meta" } ak-platform = { path = "ak-platform" } +ak-platform-authz = { path = "ak-platform-authz" } ak-platform-keyring = { path = "ak-platform-keyring" } authentik-client = { git = "https://github.com/goauthentik/client-rust", rev = "d608f835c32c64346ebeb199cb48810080890123", version = "2026.11.0-rc1", default-features = false, features = [ "rustls", diff --git a/ak-agent-desktop/src-tauri/Cargo.toml b/ak-agent-desktop/src-tauri/Cargo.toml index 150e22034..9081bf149 100644 --- a/ak-agent-desktop/src-tauri/Cargo.toml +++ b/ak-agent-desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ ak-agent = { workspace = true } eyre = { workspace = true } hex = { workspace = true } ak-platform = { workspace = true } +ak-platform-authz = { workspace = true } ak-platform-keyring = { workspace = true } ak-meta = { workspace = true } authentik-client = { workspace = true } diff --git a/ak-agent-desktop/src-tauri/src/cmd/authz.rs b/ak-agent-desktop/src-tauri/src/cmd/authz.rs new file mode 100644 index 000000000..cd9fdad9a --- /dev/null +++ b/ak-agent-desktop/src-tauri/src/cmd/authz.rs @@ -0,0 +1,6 @@ +use ak_platform_authz::AuthRecord; + +#[tauri::command] +pub fn recent_authorizations() -> Vec { + ak_platform_authz::history() +} diff --git a/ak-agent-desktop/src-tauri/src/cmd/mod.rs b/ak-agent-desktop/src-tauri/src/cmd/mod.rs index b4f5a11b7..51a4ed2f7 100644 --- a/ak-agent-desktop/src-tauri/src/cmd/mod.rs +++ b/ak-agent-desktop/src-tauri/src/cmd/mod.rs @@ -1,3 +1,4 @@ +pub mod authz; pub mod page; pub mod profile; pub mod ssh; diff --git a/ak-agent-desktop/src-tauri/src/lib.rs b/ak-agent-desktop/src-tauri/src/lib.rs index 32dffaea6..c89997e27 100644 --- a/ak-agent-desktop/src-tauri/src/lib.rs +++ b/ak-agent-desktop/src-tauri/src/lib.rs @@ -72,6 +72,7 @@ pub fn start_tauri(guard: ClientInitGuard) -> Result<()> { Ok(()) }) .invoke_handler(tauri::generate_handler![ + cmd::authz::recent_authorizations, cmd::page::take_pending_page, cmd::profile::get_user_info, cmd::profile::list_profiles, diff --git a/ak-agent-desktop/src/bridge.ts b/ak-agent-desktop/src/bridge.ts index 9a7c04a57..1e55685ce 100644 --- a/ak-agent-desktop/src/bridge.ts +++ b/ak-agent-desktop/src/bridge.ts @@ -119,6 +119,29 @@ export async function deleteProfile(name: string): Promise { return await invoke("delete_profile", { name }); } +export type AuthOutcome = "allowed" | "denied" | "failed"; + +export interface AuthRecord { + time: Date; + action: string; + /** Application, service or host access was requested for. */ + target: string; + pid: number; + cmdline?: string; + parentCmdline?: string; + outcome: AuthOutcome; + /** Answered from an earlier prompt's result. */ + cached: boolean; +} + +/** Newest first. */ +export async function recentAuthorizations(): Promise { + const records = + await invoke<(Omit & { time: number })[]>("recent_authorizations"); + + return records.map((r) => ({ ...r, time: new Date(r.time) })); +} + /** Page requested by a deep link, if any. Cleared once taken. */ export async function takePendingPage(): Promise { return await invoke("take_pending_page"); diff --git a/ak-agent-desktop/src/elements/app-shell.ts b/ak-agent-desktop/src/elements/app-shell.ts index 4d2a49565..e595f81d6 100644 --- a/ak-agent-desktop/src/elements/app-shell.ts +++ b/ak-agent-desktop/src/elements/app-shell.ts @@ -1,4 +1,5 @@ import "./header.js"; +import "../pages/authorizations.js"; import "../pages/mcp-instructions.js"; import "../pages/profile-status.js"; import "../pages/ssh-agent.js"; @@ -28,6 +29,7 @@ const PAGES = [ ["profiles", "Profiles"], ["ssh", "SSH Agent"], ["mcp", "MCP Server"], + ["authorizations", "Authorizations"], ] as const; type Page = (typeof PAGES)[number][0]; @@ -190,6 +192,8 @@ export class AppShell extends LitElement { return html``; case "mcp": return html``; + case "authorizations": + return html``; } } diff --git a/ak-agent-desktop/src/pages/authorizations.ts b/ak-agent-desktop/src/pages/authorizations.ts new file mode 100644 index 000000000..f3864e7dc --- /dev/null +++ b/ak-agent-desktop/src/pages/authorizations.ts @@ -0,0 +1,162 @@ +import { AuthOutcome, AuthRecord, recentAuthorizations } from "../bridge.js"; + +import { css, html, LitElement, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +const LABELS: Record = { + allowed: "Allowed", + denied: "Denied", + failed: "Failed", +}; + +@customElement("ak-authorizations") +export class Authorizations extends LitElement { + static styles = css` + :host { + display: block; + } + .section { + background: var(--ak-global--color--surface); + padding: var(--ak-global--spacer--lg); + font-size: var(--ak-global--font-size--sm); + color: var(--ak-global--color--ink); + } + .section-title { + display: flex; + align-items: center; + justify-content: space-between; + font-family: var(--ak-global--font-family--heading); + font-size: var(--ak-global--font-size--sm); + font-weight: var(--ak-global--font-weight--semi-bold); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 var(--ak-global--spacer--md); + } + .intro { + font-size: var(--ak-global--font-size--xs); + color: var(--ak-global--color--ink--muted); + margin: 0 0 var(--ak-global--spacer--sm); + } + button { + font: inherit; + font-size: var(--ak-global--font-size--xs); + text-transform: none; + letter-spacing: normal; + padding: var(--ak-global--spacer--xs) var(--ak-global--spacer--sm); + border: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); + border-radius: var(--ak-global--radius--sm); + background: var(--ak-global--color--surface); + color: var(--ak-global--color--ink); + cursor: pointer; + } + table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + font-size: var(--ak-global--font-size--xs); + } + th, + td { + text-align: left; + padding: var(--ak-global--spacer--xs) var(--ak-global--spacer--sm); + border-bottom: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; + } + th { + font-weight: var(--ak-global--font-weight--semi-bold); + color: var(--ak-global--color--ink--muted); + } + code { + font-family: var(--ak-global--font-family--code); + } + .muted { + color: var(--ak-global--color--ink--muted); + } + .allowed { + color: var(--ak-global--color--success); + } + .denied, + .failed { + color: var(--ak-global--color--danger); + } + `; + + @state() + private records?: AuthRecord[]; + + private _refresh = async () => { + this.records = await recentAuthorizations(); + }; + + async connectedCallback(): Promise { + super.connectedCallback(); + window.addEventListener("focus", this._refresh); + await this._refresh(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("focus", this._refresh); + } + + private _row(r: AuthRecord) { + const process = r.parentCmdline ?? r.cmdline ?? ""; + + return html` + ${r.time.toLocaleTimeString()} + ${r.action} + ${r.target} + + ${process} (${r.pid}) + + + ${LABELS[r.outcome]}${r.cached ? html` (cached)` : nothing} + + `; + } + + render() { + if (!this.records) return nothing; + + return html` +
+
+ Authorizations + +
+

+ Requests by other processes to use your authentik account since authentik Agent + was started. +

+ ${ + this.records.length + ? html` + + + + + + + + + + + + + + + + + + ${this.records.map((r) => this._row(r))} + +
TimeActionApplicationProcess (PID)Result
` + : html`

No authorizations yet.

` + } +
+ `; + } +} diff --git a/ak-agent/src/grpc/agent_auth.rs b/ak-agent/src/grpc/agent_auth.rs index 769c27c6b..5a05225e2 100644 --- a/ak-agent/src/grpc/agent_auth.rs +++ b/ak-agent/src/grpc/agent_auth.rs @@ -122,6 +122,7 @@ impl AgentAuth for AgentGRPCServer { .with_windows(format!("'{cmd}' is attempting to access your account info")) .with_linux(format!("'{cmd}' is attempting to access your account info"))) }) + .with_target("Account info", profile.app_slug.clone()) .with_uid(|c| c.clone().proc_info()?.unique_process_id()) .with_success_timeout(Duration::from_secs(0)) .with_denied_timeout(Duration::from_secs(0)) @@ -185,6 +186,7 @@ impl AgentAuth for AgentGRPCServer { .with_windows(format!("'{cmd}' is attempting to access your account")) .with_linux(format!("'{cmd}' is attempting to access your account"))) }) + .with_target("Access token", profile.app_slug.clone()) .with_uid(move |c| c.clone().proc_info()?.unique_process_id()) .with_success_timeout(Duration::from_secs(0)) .with_denied_timeout(Duration::from_secs(0)) @@ -256,6 +258,7 @@ impl AgentAuth for AgentGRPCServer { let pid = c.clone().proc_info()?.unique_process_id()?; Ok(format!("{cid2}:{pid}")) }) + .with_target("Token exchange", audience.clone()) .with_success_timeout(Duration::from_secs(30 * 60)) .with_denied_timeout(Duration::from_secs(1)) .finish() @@ -310,6 +313,7 @@ impl AgentAuth for AgentGRPCServer { request .auth_peer() + .with_target("Login", inner.service.clone()) .with_message(move |_c| { Ok(PlatformString::new().with_darwin(format!("authorize access to '{}'", service))) }) diff --git a/ak-agent/src/ssh/txn.rs b/ak-agent/src/ssh/txn.rs index f1b23bb7a..77adf5108 100644 --- a/ak-agent/src/ssh/txn.rs +++ b/ak-agent/src/ssh/txn.rs @@ -221,6 +221,7 @@ impl SSHAgentTransaction { let pid = c.clone().proc_info()?.unique_process_id()?; Ok(format!("{hk2}:{pid}")) }) + .with_target("SSH", host_key_str) .with_success_timeout(Duration::from_secs(30 * 60)) .with_denied_timeout(Duration::from_secs(5 * 60)) .prompt(self.creds.clone()) diff --git a/ak-platform-authz/Cargo.toml b/ak-platform-authz/Cargo.toml index 9ed25d36e..f31449db1 100644 --- a/ak-platform-authz/Cargo.toml +++ b/ak-platform-authz/Cargo.toml @@ -14,6 +14,7 @@ tokio = { workspace = true } tracing = { workspace = true } tonic = { workspace = true } eyre = { workspace = true } +serde = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] apple-localauthentication = { version = "0.3.5", features = ["async"] } diff --git a/ak-platform-authz/src/lib.rs b/ak-platform-authz/src/lib.rs index ca4f9bdec..3df9763a6 100644 --- a/ak-platform-authz/src/lib.rs +++ b/ak-platform-authz/src/lib.rs @@ -1,8 +1,9 @@ use std::ops::Add; use std::time::{Duration, Instant}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::{LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; use ak_platform::{net::server::creds::ProcCredentials, string::PlatformString}; use eyre::{self, Result, WrapErr, bail}; @@ -20,6 +21,8 @@ pub struct AuthorizeAction { timeout_success: Duration, timeout_denied: Duration, creds: Option, + action: &'static str, + target: String, } struct AuthState { @@ -30,6 +33,48 @@ struct AuthState { static LAST_AUTH_MAP: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +const HISTORY_SIZE: usize = 100; + +static HISTORY: Mutex> = Mutex::new(VecDeque::new()); + +#[derive(Clone, Copy, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthOutcome { + Allowed, + Denied, + Failed, +} + +impl From for AuthOutcome { + fn from(success: bool) -> Self { + if success { Self::Allowed } else { Self::Denied } + } +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthRecord { + /// Milliseconds since the unix epoch. + pub time: u64, + pub action: &'static str, + /// Application, service or host access was requested for. + pub target: String, + pub pid: i64, + pub cmdline: Option, + pub parent_cmdline: Option, + pub outcome: AuthOutcome, + /// Answered from an earlier prompt's result. + pub cached: bool, +} + +/// Recent authorization decisions of this process, newest first. +pub fn history() -> Vec { + HISTORY + .lock() + .map(|h| h.iter().cloned().collect()) + .unwrap_or_default() +} + impl AuthorizeAction { pub fn build() -> AuthorizeActionBuilder { AuthorizeActionBuilder::default() @@ -49,15 +94,17 @@ impl AuthorizeAction { .clone(); tracing::Span::current().record("uid", &uid); tracing::trace!(uid, "Checking if we need to authorize"); - if let Some(v) = match LAST_AUTH_MAP.try_lock() { + let cached = match LAST_AUTH_MAP.try_lock() { Ok(it) => it, Err(e) => bail!("auth cache lock poisoned: {e}"), } .get(&uid) - && v.exp >= Instant::now() - { - tracing::trace!(cached = v.success, "Valid last result in cache"); - return Ok(v.success); + .filter(|v| v.exp >= Instant::now()) + .map(|v| v.success); + if let Some(success) = cached { + tracing::trace!(cached = success, "Valid last result in cache"); + self.record(&creds, success.into(), true); + return Ok(success); } let msg = (self.message)(&creds) .wrap_err("failed to build authorization message")? @@ -67,6 +114,7 @@ impl AuthorizeAction { Ok(r) => r, Err(e) => { tracing::trace!("error during authz: {e:?}"); + self.record(&creds, AuthOutcome::Failed, false); return Err(e); } }; @@ -84,9 +132,31 @@ impl AuthorizeAction { Err(e) => bail!("auth cache lock poisoned: {e}"), } tracing::trace!(result = res, "Finished authorization"); + self.record(&creds, res.into(), false); Ok(res) } + fn record(&self, creds: &ProcCredentials, outcome: AuthOutcome, cached: bool) { + let info = creds.clone().proc_info().ok(); + let record = AuthRecord { + time: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(), + action: self.action, + target: self.target.clone(), + pid: creds.pid(), + cmdline: info.as_ref().map(|i| i.cmdline.clone()), + parent_cmdline: info.and_then(|i| i.parent_cmdline().ok()), + outcome, + cached, + }; + if let Ok(mut h) = HISTORY.lock() { + h.truncate(HISTORY_SIZE - 1); + h.push_front(record); + } + } + pub async fn prompt_grpc(self) -> std::result::Result<(), Status> { let creds = match self.creds.clone() { Some(c) => c, @@ -109,6 +179,8 @@ pub struct AuthorizeActionBuilder { timeout_success: Duration, timeout_denied: Duration, creds: Option, + action: &'static str, + target: String, } impl AuthorizeActionBuilder { @@ -138,6 +210,12 @@ impl AuthorizeActionBuilder { self.creds = creds; self } + /// What is being authorized, shown in [`history`]. + pub fn with_target(mut self, action: &'static str, target: impl Into) -> Self { + self.action = action; + self.target = target.into(); + self + } pub fn build(self) -> Result { let Some(m) = self.message else { bail!("Missing message function"); @@ -151,6 +229,8 @@ impl AuthorizeActionBuilder { timeout_success: self.timeout_success, timeout_denied: self.timeout_denied, creds: self.creds, + action: self.action, + target: self.target, }) } @@ -165,3 +245,27 @@ impl AuthorizeActionBuilder { .await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_is_bounded_and_newest_first() -> Result<()> { + let creds = ProcCredentials::current(); + for i in 0..=HISTORY_SIZE { + AuthorizeAction::build() + .with_message(|_| Ok(PlatformString::new())) + .with_uid(|_| Ok(String::new())) + .with_target("test", i.to_string()) + .build()? + .record(&creds, (i % 2 == 0).into(), false); + } + let h = history(); + assert_eq!(h.len(), HISTORY_SIZE); + assert_eq!(h[0].target, HISTORY_SIZE.to_string()); + assert_eq!(h[0].pid, creds.pid()); + assert!(matches!(h[0].outcome, AuthOutcome::Allowed)); + Ok(()) + } +} From a2ed90e890e2465e43d5bedcec708ba1d8a19863 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 22 Oct 2026 18:01:37 +0100 Subject: [PATCH 2/3] fix format error --- ak-agent/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ak-agent/Cargo.toml b/ak-agent/Cargo.toml index 29bad5357..28dda301f 100644 --- a/ak-agent/Cargo.toml +++ b/ak-agent/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] ak-meta = { workspace = true } ak-platform = { workspace = true } -ak-platform-authz = { path = "../ak-platform-authz" } +ak-platform-authz = { workspace = true } ak-platform-keyring = { workspace = true } authentik-client = { workspace = true } chrono = { workspace = true } From 59b55d51aa5149c0cb0c1d9b9e91fc2b5cf0bef3 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 22 Oct 2026 19:09:11 +0100 Subject: [PATCH 3/3] make default, add summary & empty state and resize window --- ak-agent-desktop/src-tauri/src/ui/mod.rs | 2 +- ak-agent-desktop/src/elements/app-shell.ts | 9 +- ak-agent-desktop/src/pages/authorizations.ts | 311 ++++++++++++++----- ak-agent-desktop/src/pages/profile-status.ts | 6 +- 4 files changed, 245 insertions(+), 83 deletions(-) diff --git a/ak-agent-desktop/src-tauri/src/ui/mod.rs b/ak-agent-desktop/src-tauri/src/ui/mod.rs index 64020c5cb..ea8485563 100644 --- a/ak-agent-desktop/src-tauri/src/ui/mod.rs +++ b/ak-agent-desktop/src-tauri/src/ui/mod.rs @@ -28,7 +28,7 @@ pub fn show_main(app: &tauri::AppHandle) { None => { let b = WebviewWindowBuilder::new(app, WINDOW_LABEL, WebviewUrl::default()) .title(WINDOW_TITLE) - .inner_size(870.0, 640.0); + .inner_size(1376.0, 850.0); #[cfg(target_os = "macos")] let b = b .hidden_title(true) diff --git a/ak-agent-desktop/src/elements/app-shell.ts b/ak-agent-desktop/src/elements/app-shell.ts index e595f81d6..659d0605f 100644 --- a/ak-agent-desktop/src/elements/app-shell.ts +++ b/ak-agent-desktop/src/elements/app-shell.ts @@ -26,10 +26,10 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; const PAGES = [ + ["authorizations", "Authorizations"], ["profiles", "Profiles"], ["ssh", "SSH Agent"], ["mcp", "MCP Server"], - ["authorizations", "Authorizations"], ] as const; type Page = (typeof PAGES)[number][0]; @@ -103,7 +103,7 @@ export class AppShell extends LitElement { `; @state() - private page: Page = "profiles"; + private page: Page = "authorizations"; @state() private user?: SessionUser; @@ -193,7 +193,10 @@ export class AppShell extends LitElement { case "mcp": return html``; case "authorizations": - return html``; + return html` p.name === this.activeProfile)} + .user=${this.user} + >`; } } diff --git a/ak-agent-desktop/src/pages/authorizations.ts b/ak-agent-desktop/src/pages/authorizations.ts index f3864e7dc..f655af8ad 100644 --- a/ak-agent-desktop/src/pages/authorizations.ts +++ b/ak-agent-desktop/src/pages/authorizations.ts @@ -1,7 +1,12 @@ -import { AuthOutcome, AuthRecord, recentAuthorizations } from "../bridge.js"; +import { AuthOutcome, AuthRecord, type profile, recentAuthorizations } from "../bridge.js"; +import "../elements/profile-setup.js"; + +import { renewalStatus, STATUS_LABELS } from "./profile-status.js"; + +import { SessionUser } from "@goauthentik/api"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; const LABELS: Record = { allowed: "Allowed", @@ -9,39 +14,135 @@ const LABELS: Record = { failed: "Failed", }; +/** Executable name of a command line, e.g. `claude` for `/usr/local/bin/claude --resume`. */ +function exeName(cmdline: string): string { + return cmdline.split(" ")[0].split(/[/\\]/).pop() || cmdline; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + @customElement("ak-authorizations") export class Authorizations extends LitElement { static styles = css` :host { display: block; - } - .section { - background: var(--ak-global--color--surface); padding: var(--ak-global--spacer--lg); font-size: var(--ak-global--font-size--sm); color: var(--ak-global--color--ink); } - .section-title { + .card { + background: var(--ak-global--color--surface); + border: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); + border-radius: var(--ak-global--radius--sm); + margin-bottom: var(--ak-global--spacer--lg); + } + .summary { + display: flex; + align-items: center; + gap: var(--ak-global--spacer--md); + padding: var(--ak-global--spacer--md) var(--ak-global--spacer--lg); + } + .avatar { + width: 44px; + height: 44px; + border-radius: 50%; + flex-shrink: 0; + background: var(--ak-global--color--surface--muted); + } + .who { + flex: 1; + min-width: 0; + } + .name { + font-family: var(--ak-global--font-family--heading); + font-size: var(--ak-global--font-size--md); + font-weight: var(--ak-global--font-weight--semi-bold); + } + .meta { + font-size: var(--ak-global--font-size--xs); + color: var(--ak-global--color--ink--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .badges { + display: flex; + gap: var(--ak-global--spacer--xs); + margin-top: var(--ak-global--spacer--xs); + } + .badge { + background: color-mix(in oklch, var(--badge) 15%, transparent); + color: var(--badge); + font-size: var(--ak-global--font-size--xs); + font-weight: var(--ak-global--font-weight--semi-bold); + padding: 0 var(--ak-global--spacer--sm); + border-radius: var(--ak-global--radius--pill); + white-space: nowrap; + } + .active, + .allowed { + --badge: var(--ak-global--color--success); + } + .expiring { + --badge: var(--ak-global--color--warning--deep); + } + .expired, + .denied, + .failed { + --badge: var(--ak-global--color--danger); + } + .disconnected { + --badge: var(--ak-global--color--ink--muted); + } + .bound { + --badge: var(--ak-global--color--primary); + } + .stats { + display: flex; + gap: var(--ak-global--spacer--lg); + text-align: center; + } + .stat strong { + display: block; + font-size: var(--ak-global--font-size--lg); + font-weight: var(--ak-global--font-weight--semi-bold); + } + .stat span { + font-size: var(--ak-global--font-size--xs); + color: var(--ak-global--color--ink--muted); + } + .stat.denied strong { + color: var(--ak-global--color--danger); + } + .card-title { display: flex; align-items: center; justify-content: space-between; + padding: var(--ak-global--spacer--md) var(--ak-global--spacer--lg); + border-bottom: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); + } + h2 { + margin: 0; font-family: var(--ak-global--font-family--heading); font-size: var(--ak-global--font-size--sm); font-weight: var(--ak-global--font-weight--semi-bold); text-transform: uppercase; letter-spacing: 0.05em; - margin: 0 0 var(--ak-global--spacer--md); } - .intro { + .hint { + margin: var(--ak-global--spacer--xs) 0 0; font-size: var(--ak-global--font-size--xs); color: var(--ak-global--color--ink--muted); - margin: 0 0 var(--ak-global--spacer--sm); } button { font: inherit; font-size: var(--ak-global--font-size--xs); - text-transform: none; - letter-spacing: normal; padding: var(--ak-global--spacer--xs) var(--ak-global--spacer--sm); border: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); border-radius: var(--ak-global--radius--sm); @@ -49,41 +150,65 @@ export class Authorizations extends LitElement { color: var(--ak-global--color--ink); cursor: pointer; } - table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - font-size: var(--ak-global--font-size--xs); + button:hover { + background: var(--ak-global--color--surface--muted); } - th, - td { - text-align: left; - padding: var(--ak-global--spacer--xs) var(--ak-global--spacer--sm); + ul { + list-style: none; + margin: 0; + padding: 0; + } + li { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 1fr) auto; + align-items: center; + gap: var(--ak-global--spacer--md); + padding: var(--ak-global--spacer--sm) var(--ak-global--spacer--lg); border-bottom: var(--ak-global--border-width--sm) solid var(--ak-global--color--border); + user-select: text; + } + li:last-child { + border-bottom: none; + } + li:hover { + background: var(--ak-global--color--surface--muted); + } + .primary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - user-select: text; - } - th { - font-weight: var(--ak-global--font-weight--semi-bold); - color: var(--ak-global--color--ink--muted); } code { font-family: var(--ak-global--font-family--code); + font-size: var(--ak-global--font-size--xs); } - .muted { + .time { + text-align: right; + font-size: var(--ak-global--font-size--xs); color: var(--ak-global--color--ink--muted); + white-space: nowrap; } - .allowed { - color: var(--ak-global--color--success); + .setup { + padding: var(--ak-global--spacer--xl) var(--ak-global--spacer--lg); + text-align: center; } - .denied, - .failed { - color: var(--ak-global--color--danger); + .setup .hint { + max-width: 28rem; + margin: var(--ak-global--spacer--sm) auto var(--ak-global--spacer--md); + } + .empty { + padding: var(--ak-global--spacer--xl) var(--ak-global--spacer--lg); + text-align: center; + color: var(--ak-global--color--ink--muted); } `; + @property({ attribute: false }) + profile?: profile; + + @property({ attribute: false }) + user?: SessionUser; + @state() private records?: AuthRecord[]; @@ -102,59 +227,93 @@ export class Authorizations extends LitElement { window.removeEventListener("focus", this._refresh); } + private _setup() { + return html`
+

Connect to authentik

+

+ Set up a profile to sign in to your authentik instance. Apps on this device can then + request access to your account, and every request shows up here. +

+ +
`; + } + + private _summary() { + const p = this.profile; + + if (!p) return this._setup(); + const count = (o: AuthOutcome) => this.records?.filter((r) => r.outcome === o).length ?? 0; + + return html`
+ ${ + this.user?.user.avatar + ? html`` + : nothing + } +
+
${this.user?.user.name || p.username}
+
+ ${p.username} · ${hostOf(p.authentikUrl)} · profile ${p.name} +
+
+ ${ + p.status === "FAILED" + ? html`Renewal failed` + : html`${STATUS_LABELS[renewalStatus(p.nextRenew)]}` + } + ${p.dpopBound ? html`Key-bound` : nothing} +
+
+
+
${count("allowed")}Allowed
+
+ ${count("denied") + count("failed")}Denied +
+
+
`; + } + private _row(r: AuthRecord) { - const process = r.parentCmdline ?? r.cmdline ?? ""; - - return html` - ${r.time.toLocaleTimeString()} - ${r.action} - ${r.target} - - ${process} (${r.pid}) - - - ${LABELS[r.outcome]}${r.cached ? html` (cached)` : nothing} - - `; + const cmdline = r.parentCmdline ?? r.cmdline; + + return html`
  • + + ${LABELS[r.outcome]} + +
    + ${r.action} +
    ${r.target}
    +
    +
    + ${cmdline ? exeName(cmdline) : "Unknown process"} +
    PID ${r.pid}${r.cached ? " · remembered" : ""}
    +
    +
    ${r.time.toLocaleTimeString()}
    +
  • `; } render() { - if (!this.records) return nothing; - return html` -
    -
    - Authorizations + ${this._summary()} +
    +
    +
    +

    Recent authorizations

    +

    + Requests by other processes to use your account since authentik Agent + was started. +

    +
    -

    - Requests by other processes to use your authentik account since authentik Agent - was started. -

    ${ - this.records.length - ? html` - - - - - - - - - - - - - - - - - - ${this.records.map((r) => this._row(r))} - -
    TimeActionApplicationProcess (PID)Result
    ` - : html`

    No authorizations yet.

    ` + this.records?.length + ? html`
      + ${this.records.map((r) => this._row(r))} +
    ` + : html`
    No authorizations yet.
    ` }
    `; diff --git a/ak-agent-desktop/src/pages/profile-status.ts b/ak-agent-desktop/src/pages/profile-status.ts index 0503a6417..622603eec 100644 --- a/ak-agent-desktop/src/pages/profile-status.ts +++ b/ak-agent-desktop/src/pages/profile-status.ts @@ -6,9 +6,9 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -type RenewalStatus = "active" | "expiring" | "expired" | "disconnected"; +export type RenewalStatus = "active" | "expiring" | "expired" | "disconnected"; -function renewalStatus(nextRenew: Date | string | null | undefined): RenewalStatus { +export function renewalStatus(nextRenew: Date | string | null | undefined): RenewalStatus { if (!nextRenew) return "disconnected"; const next = new Date(nextRenew); const now = Date.now(); @@ -21,7 +21,7 @@ function renewalStatus(nextRenew: Date | string | null | undefined): RenewalStat return "active"; } -const STATUS_LABELS: Record = { +export const STATUS_LABELS: Record = { active: "Valid", expiring: "Expiring soon", expired: "Needs renewal",