1use 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
23const 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
32pub 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
42pub struct ShownKey {
44 pub name: String,
45 pub key: String,
46 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 pub usage: String,
66 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#[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
173async 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 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}