Skip to main content

codoseo_web/routes/
settings_keys.rs

1//! `/settings/api-keys`: the keys agents use for the REST API and the cloud MCP server, today's
2//! API usage and how to connect. A key is shown once, on the response that creates it.
3
4use askama::Template;
5use axum::extract::{Path, State};
6use axum::http::{HeaderName, StatusCode, header};
7use axum::response::{Html, IntoResponse, Redirect, Response};
8use axum::routing::{get, post};
9use axum::{Form, Router};
10use codoseo_core::plan::PlanLimits;
11use codoseo_store::api_keys::{self, ApiKey, CreateKeyOutcome};
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::agent::keys;
16use crate::auth::CurrentUser;
17use crate::error::AppError;
18use crate::fmt;
19use crate::layout::{Screen, Shell};
20use crate::render::{Hx, html};
21use crate::state::AppState;
22
23/// The longest key name.
24const NAME_MAX: usize = 60;
25
26pub fn routes() -> Router<AppState> {
27    Router::new()
28        .route("/settings/api-keys", get(page).post(create))
29        .route("/settings/api-keys/{id}/revoke", post(revoke))
30}
31
32// ---- views -----------------------------------------------------------------------------------
33
34pub struct KeyView {
35    pub id: Uuid,
36    pub name: String,
37    pub prefix: String,
38    pub created: String,
39    pub last_used: String,
40}
41
42/// A key just created: the only time it is shown.
43pub struct ShownKey {
44    pub name: String,
45    pub key: String,
46    /// The Claude Code command with this key filled in.
47    pub command: String,
48}
49
50#[derive(Template)]
51#[template(path = "settings/api_keys_form.html")]
52pub struct CreateForm {
53    pub name: String,
54    pub error: Option<String>,
55}
56
57#[derive(Template)]
58#[template(path = "settings/api_keys.html")]
59pub struct KeysPage {
60    pub shell: Shell,
61    pub keys: Vec<KeyView>,
62    pub form: CreateForm,
63    pub new_key: Option<ShownKey>,
64    /// `37 of 100 API calls used today`, or the unlimited wording.
65    pub usage: String,
66    /// The `/mcp` address.
67    pub mcp_url: String,
68    pub max_keys: i64,
69}
70
71fn key_view(k: &ApiKey) -> KeyView {
72    KeyView {
73        id: k.id,
74        name: k.name.clone(),
75        prefix: k.prefix.clone(),
76        created: fmt::date(k.created_at),
77        last_used: k
78            .last_used_at
79            .map_or_else(|| "never used".to_owned(), fmt::ago),
80    }
81}
82
83async fn usage_line(state: &AppState, user: &CurrentUser) -> Result<String, AppError> {
84    let used = fmt::thousands(api_keys::usage_today(&state.pool, user.id()).await?);
85    Ok(
86        match PlanLimits::for_plan(user.account.plan).api_calls_per_day {
87            Some(limit) => format!("{used} of {} API calls used today", fmt::thousands(limit)),
88            None => format!("Unlimited API calls, {used} used today"),
89        },
90    )
91}
92
93async fn render_page(
94    state: &AppState,
95    user: &CurrentUser,
96    form: CreateForm,
97    new_key: Option<ShownKey>,
98) -> Result<Html<String>, AppError> {
99    let listed = api_keys::list_for_account(&state.pool, user.id()).await?;
100    let shell = Shell::load(state, user, None, Screen::ApiKeys).await?;
101    html(&KeysPage {
102        shell,
103        keys: listed.iter().map(key_view).collect(),
104        form,
105        new_key,
106        usage: usage_line(state, user).await?,
107        mcp_url: keys::mcp_url(&state.config)?,
108        max_keys: api_keys::MAX_LIVE_KEYS,
109    })
110}
111
112fn blank_form() -> CreateForm {
113    CreateForm {
114        name: String::new(),
115        error: None,
116    }
117}
118
119async fn page(State(state): State<AppState>, user: CurrentUser) -> Result<Response, AppError> {
120    Ok(render_page(&state, &user, blank_form(), None)
121        .await?
122        .into_response())
123}
124
125// ---- create and revoke -----------------------------------------------------------------------
126
127#[derive(Deserialize)]
128struct NewKeyForm {
129    #[serde(default)]
130    name: String,
131}
132
133enum Refusal {
134    Invalid(String),
135    Limit(String),
136}
137
138async fn create(
139    State(state): State<AppState>,
140    user: CurrentUser,
141    hx: Hx,
142    Form(form): Form<NewKeyForm>,
143) -> Result<Response, AppError> {
144    let name = form.name.trim();
145    let refusal = match try_create(&state, &user, name).await? {
146        Ok(page) => return Ok(page),
147        Err(refusal) => refusal,
148    };
149    let (status, message) = match &refusal {
150        Refusal::Invalid(m) => (StatusCode::BAD_REQUEST, m.clone()),
151        Refusal::Limit(m) => (StatusCode::FORBIDDEN, m.clone()),
152    };
153    if !hx.request {
154        return Err(match refusal {
155            Refusal::Invalid(m) => AppError::BadRequest(m),
156            Refusal::Limit(m) => AppError::Limit(m),
157        });
158    }
159    Ok((
160        status,
161        [
162            (HeaderName::from_static("hx-retarget"), "#add-key"),
163            (HeaderName::from_static("hx-reswap"), "outerHTML"),
164        ],
165        html(&CreateForm {
166            name: name.to_owned(),
167            error: Some(message),
168        })?,
169    )
170        .into_response())
171}
172
173/// Makes the key and answers with the page that shows it once.
174async fn try_create(
175    state: &AppState,
176    user: &CurrentUser,
177    name: &str,
178) -> Result<Result<Response, Refusal>, AppError> {
179    if name.is_empty() || name.chars().count() > NAME_MAX || name.chars().any(char::is_control) {
180        return Ok(Err(Refusal::Invalid(format!(
181            "Give the key a name of 1 to {NAME_MAX} characters, with no line breaks or control characters."
182        ))));
183    }
184    let key = keys::generate();
185    let outcome = api_keys::create(
186        &state.pool,
187        user.id(),
188        name,
189        &key.hash,
190        &key.prefix,
191        api_keys::MAX_LIVE_KEYS,
192    )
193    .await?;
194    if outcome == CreateKeyOutcome::LimitReached {
195        return Ok(Err(Refusal::Limit(format!(
196            "You can have up to {} API keys. Revoke one you don't use to create another.",
197            api_keys::MAX_LIVE_KEYS
198        ))));
199    }
200    let command = keys::claude_command(&keys::mcp_url(&state.config)?, &key.plaintext);
201    let page = render_page(
202        state,
203        user,
204        blank_form(),
205        Some(ShownKey {
206            name: name.to_owned(),
207            key: key.plaintext,
208            command,
209        }),
210    )
211    .await?;
212    // The key is on this page: htmx must not keep it in a history snapshot (the template says
213    // `hx-history="false"`), the URL goes back to the plain screen, and nothing may cache it.
214    Ok(Ok((
215        [
216            (
217                HeaderName::from_static("hx-replace-url"),
218                "/settings/api-keys",
219            ),
220            (header::CACHE_CONTROL, "no-store"),
221        ],
222        page,
223    )
224        .into_response()))
225}
226
227async fn revoke(
228    State(state): State<AppState>,
229    user: CurrentUser,
230    Path(id): Path<Uuid>,
231) -> Result<Response, AppError> {
232    if !api_keys::revoke(&state.pool, user.id(), id).await? {
233        return Err(AppError::NotFound);
234    }
235    Ok(Redirect::to("/settings/api-keys").into_response())
236}