1use taba_client::json;
6
7use crate::tui::api::{ApiCall, Severity, Token};
8use crate::tui::editor::{Editor, Field, Outcome, Purpose};
9use crate::tui::effect::Effect;
10use crate::tui::i18n;
11use crate::tui::keymap::Action;
12use crate::tui::model::{
13 Confirm, Fail, Input, InputPurpose, Modal, Model, Pending, Secret, TokenSort,
14};
15use crate::tui::screens::cluster::moved;
16use crate::tui::update::run;
17
18pub mod view;
19
20pub const PERMISSIONS: [&str; 5] = ["publish", "consume", "create", "manage", "admin"];
22
23pub const MASK_AFTER_MS: u64 = 30_000;
25
26pub const SOON_DAYS: i64 = 7;
28
29pub const MAX_PAGES: u32 = 25;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Availability {
35 Unknown,
37 NoAuth,
39 NotAdmin,
41 Ready,
42}
43
44pub fn availability(m: &Model) -> Availability {
45 match &m.me.data {
46 None => Availability::Unknown,
47 Some(me) if !me.auth_enabled => Availability::NoAuth,
48 Some(me) if !me.is_admin() => Availability::NotAdmin,
49 Some(_) => Availability::Ready,
50 }
51}
52
53pub fn visible(m: &Model) -> Vec<Token> {
55 let wanted = m.tokens.filter.to_lowercase();
56 let mut tokens: Vec<Token> = m
57 .tokens
58 .list
59 .data
60 .iter()
61 .flatten()
62 .filter(|t| wanted.is_empty() || t.name.to_lowercase().contains(&wanted))
63 .cloned()
64 .collect();
65 match m.tokens.sort {
66 TokenSort::NewestFirst => tokens.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
67 TokenSort::OldestFirst => tokens.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
68 TokenSort::Name => tokens.sort_by_key(|t| t.name.to_lowercase()),
69 TokenSort::Id => tokens.sort_by(|a, b| a.id.cmp(&b.id)),
70 }
71 tokens
72}
73
74pub fn selected_index(m: &Model, tokens: &[Token]) -> Option<usize> {
76 if tokens.is_empty() {
77 return None;
78 }
79 let found = m
80 .tokens
81 .selected
82 .as_ref()
83 .and_then(|id| tokens.iter().position(|t| &t.id == id));
84 Some(found.unwrap_or(0))
85}
86
87pub fn selected(m: &Model) -> Option<Token> {
88 let tokens = visible(m);
89 selected_index(m, &tokens).map(|i| tokens[i].clone())
90}
91
92pub fn gate(m: &Model, action: Action) -> Option<&'static str> {
94 match availability(m) {
95 Availability::Unknown => return Some("gate.tokens-unknown"),
96 Availability::NoAuth => return Some("gate.tokens-no-auth"),
97 Availability::NotAdmin => return Some("gate.not-admin"),
98 Availability::Ready => {}
99 }
100 if m.busy.is_some() {
101 return Some("gate.busy");
102 }
103 match action {
104 Action::TokenEdit | Action::TokenClone | Action::TokenRevoke if selected(m).is_none() => {
105 Some("gate.no-token")
106 }
107 Action::TokenEdit | Action::TokenRevoke
109 if !m.is_root()
110 && selected(m).is_some_and(|t| t.permissions.iter().any(|p| p == "admin")) =>
111 {
112 Some("gate.root-for-admin")
113 }
114 _ => None,
115 }
116}
117
118fn refuse(m: &mut Model, key: &str) -> Vec<Effect> {
119 let text = m.tx().t(key);
120 m.notify(Severity::Warning, text);
121 Vec::new()
122}
123
124pub fn on_action(m: &mut Model, action: Action) -> Vec<Effect> {
125 match action {
126 Action::Up => step(m, -1),
127 Action::Down => step(m, 1),
128 Action::PageUp => step(m, -10),
129 Action::PageDown => step(m, 10),
130 Action::First => step(m, i32::MIN / 2),
131 Action::Last => step(m, i32::MAX / 2),
132 Action::Enter => crate::tui::menu::open(m),
133 Action::Sort => {
134 m.tokens.sort = m.tokens.sort.next();
135 Vec::new()
136 }
137 Action::Filter => {
138 m.modal = Some(Modal::Input(Input {
139 purpose: InputPurpose::TokenFilter,
140 value: m.tokens.filter.clone(),
141 }));
142 Vec::new()
143 }
144 Action::TokenNew | Action::TokenEdit | Action::TokenClone | Action::TokenRevoke => {
145 if let Some(key) = gate(m, action) {
146 return refuse(m, key);
147 }
148 match action {
149 Action::TokenNew => open_create(m, None),
150 Action::TokenEdit => open_edit(m),
151 Action::TokenClone => open_create(m, selected(m)),
152 _ => open_revoke(m),
153 }
154 Vec::new()
155 }
156 _ => Vec::new(),
157 }
158}
159
160fn step(m: &mut Model, delta: i32) -> Vec<Effect> {
161 let tokens = visible(m);
162 if let Some(current) = selected_index(m, &tokens) {
163 let next = moved(current, delta, tokens.len());
164 m.tokens.selected = Some(tokens[next].id.clone());
165 }
166 Vec::new()
167}
168
169fn open_create(m: &mut Model, from: Option<Token>) {
170 let (name, prefixes, perms, clone_of) = match &from {
171 Some(t) => (
172 format!("{}-copy", t.name),
173 t.prefixes.join(" "),
174 t.permissions.clone(),
175 Some((t.id.clone(), t.name.clone())),
176 ),
177 None => (String::new(), String::new(), Vec::new(), None),
178 };
179 let mut fields = vec![Field::text("name", "editor.field.name", &name)];
180 for permission in PERMISSIONS {
181 let mut field = Field::check(
182 permission,
183 permission_label(permission),
184 perms.iter().any(|p| p == permission) && (permission != "admin" || m.is_root()),
185 );
186 if permission == "admin" && !m.is_root() {
188 field = field.locked("editor.locked.root");
189 }
190 fields.push(field);
191 }
192 fields.push(Field::text("prefixes", "editor.field.prefixes", &prefixes));
193 fields.push(Field::text("expires", "editor.field.expires", ""));
194 m.modal = Some(Modal::Editor(Editor::new(
195 Purpose::TokenCreate { clone_of },
196 fields,
197 )));
198}
199
200fn permission_label(permission: &str) -> &'static str {
201 match permission {
202 "publish" => "editor.perm.publish",
203 "consume" => "editor.perm.consume",
204 "create" => "editor.perm.create",
205 "manage" => "editor.perm.manage",
206 _ => "editor.perm.admin",
207 }
208}
209
210fn open_edit(m: &mut Model) {
211 let Some(token) = selected(m) else { return };
212 let expires = token.expires_at.clone().unwrap_or_default();
213 let mut fields = vec![Field::text("name", "editor.field.name", &token.name)];
214 for permission in PERMISSIONS {
215 fields.push(
216 Field::check(
217 permission,
218 permission_label(permission),
219 token.permissions.iter().any(|p| p == permission),
220 )
221 .locked("editor.locked.clone"),
222 );
223 }
224 fields.push(
225 Field::text(
226 "prefixes",
227 "editor.field.prefixes",
228 &token.prefixes.join(" "),
229 )
230 .locked("editor.locked.clone"),
231 );
232 fields.push(Field::text("expires", "editor.field.expires", &expires));
233 m.modal = Some(Modal::Editor(Editor::new(
234 Purpose::TokenEdit {
235 id: token.id,
236 name: token.name,
237 expires,
238 },
239 fields,
240 )));
241}
242
243fn open_revoke(m: &mut Model) {
244 if let Some(token) = selected(m) {
245 m.modal = Some(Modal::Confirm(Confirm {
246 pending: Pending::RevokeToken(token.id, token.name),
247 typed: None,
248 ok_focused: false,
249 }));
250 }
251}
252
253pub fn rfc3339(ms: i64) -> String {
255 let c = i18n::civil_from_ms(ms);
256 format!(
257 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
258 c.year, c.month, c.day, c.hour, c.minute, c.second
259 )
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct BadExpiry;
265
266pub fn parse_expiry(text: &str, now_ms: i64) -> Result<Option<String>, BadExpiry> {
270 let text = text.trim();
271 if text.is_empty() || text.eq_ignore_ascii_case("none") || text.eq_ignore_ascii_case("never") {
272 return Ok(None);
273 }
274 let relative = text.char_indices().last().and_then(|(at, unit)| {
275 let number = text[..at].parse::<i64>().ok().filter(|n| *n > 0)?;
276 let step = match unit {
277 'm' => 60_000,
278 'h' => 3_600_000,
279 'd' => 86_400_000,
280 _ => return None,
281 };
282 number.checked_mul(step)
283 });
284 if let Some(span) = relative {
285 return Ok(Some(rfc3339(now_ms.saturating_add(span))));
286 }
287 let instant = if text.len() == 10 {
288 format!("{text}T00:00:00Z")
289 } else {
290 text.to_owned()
291 };
292 i18n::parse_instant(&instant).ok_or(BadExpiry)?;
293 Ok(Some(instant))
294}
295
296fn quoted_list(items: &[String]) -> String {
297 let quoted: Vec<String> = items.iter().map(|s| json::quote(s)).collect();
298 format!("[{}]", quoted.join(","))
299}
300
301pub fn submit(m: &mut Model, mut editor: Editor) -> Vec<Effect> {
303 let tx = m.tx();
304 let fail = |editor: &mut Editor, field: &str, key: &str, m: &mut Model| {
305 editor.error = Some(m.tx().t(key));
306 editor.focus_on(field);
307 m.modal = Some(Modal::Editor(editor.clone()));
308 };
309 let name = editor.text("name").to_owned();
310 if name.is_empty() {
311 fail(&mut editor, "name", "editor.error.name", m);
312 return Vec::new();
313 }
314 let call = match editor.purpose.clone() {
315 Purpose::TokenCreate { .. } => {
316 let permissions: Vec<String> = PERMISSIONS
317 .iter()
318 .filter(|p| editor.checked(p))
319 .map(|p| (*p).to_owned())
320 .collect();
321 if permissions.is_empty() {
322 fail(&mut editor, "publish", "editor.error.permissions", m);
323 return Vec::new();
324 }
325 let expires = match parse_expiry(editor.text("expires"), m.wall_ms) {
326 Ok(expires) => expires,
327 Err(BadExpiry) => {
328 fail(&mut editor, "expires", "editor.error.expiry", m);
329 return Vec::new();
330 }
331 };
332 let prefixes: Vec<String> = editor
333 .text("prefixes")
334 .split([',', ' '])
335 .filter(|p| !p.is_empty())
336 .map(str::to_owned)
337 .collect();
338 let mut body = vec![
339 format!("\"name\":{}", json::quote(&name)),
340 format!("\"permissions\":{}", quoted_list(&permissions)),
341 ];
342 if !prefixes.is_empty() {
343 body.push(format!("\"topicPrefixes\":{}", quoted_list(&prefixes)));
344 }
345 if let Some(at) = expires {
346 body.push(format!("\"expiresAt\":{}", json::quote(&at)));
347 }
348 ApiCall::TokenCreate(format!("{{{}}}", body.join(",")))
349 }
350 Purpose::TokenEdit {
351 id,
352 name: was_name,
353 expires: was_expires,
354 } => {
355 let mut body = Vec::new();
356 if name != was_name {
357 body.push(format!("\"name\":{}", json::quote(&name)));
358 }
359 if editor.text("expires") != was_expires.trim() {
360 match parse_expiry(editor.text("expires"), m.wall_ms) {
361 Ok(Some(at)) => body.push(format!("\"expiresAt\":{}", json::quote(&at))),
362 Ok(None) => body.push("\"expiresAt\":null".to_owned()),
363 Err(BadExpiry) => {
364 fail(&mut editor, "expires", "editor.error.expiry", m);
365 return Vec::new();
366 }
367 }
368 }
369 if body.is_empty() {
370 editor.error = Some(tx.t("editor.error.nothing"));
371 m.modal = Some(Modal::Editor(editor));
372 return Vec::new();
373 }
374 ApiCall::TokenUpdate(id, format!("{{{}}}", body.join(",")))
375 }
376 _ => return Vec::new(),
377 };
378 editor.busy = true;
379 editor.error = None;
380 m.modal = Some(Modal::Editor(editor));
381 run(m, call)
382}
383
384pub fn editor_key(m: &mut Model, mut editor: Editor, outcome: Outcome) -> Vec<Effect> {
386 match outcome {
387 Outcome::Cancel => {
388 m.modal = None;
389 Vec::new()
390 }
391 Outcome::Submit => submit(m, editor),
392 Outcome::Test | Outcome::None => {
393 editor.note = None;
394 m.modal = Some(Modal::Editor(editor));
395 Vec::new()
396 }
397 }
398}
399
400pub fn saved(m: &mut Model, call: &ApiCall, text: &str) -> Vec<Effect> {
402 let tx = m.tx();
403 let editor = match m.modal.take() {
404 Some(Modal::Editor(editor)) => Some(editor),
405 other => {
406 m.modal = other;
407 None
408 }
409 };
410 m.tokens.list.refresh_now();
411 match call {
412 ApiCall::TokenCreate(_) => match crate::tui::api::parse_token_created(text) {
413 Ok((token, secret)) => {
414 let replaces = match editor.map(|e| e.purpose) {
415 Some(Purpose::TokenCreate { clone_of }) => clone_of,
416 _ => None,
417 };
418 m.tokens.selected = Some(token.id.clone());
419 m.modal = Some(Modal::Secret(Secret {
420 id: token.id,
421 name: token.name,
422 token: secret,
423 shown_at: m.now,
424 copied: false,
425 saved: false,
426 warned: false,
427 replaces,
428 revoke_old: false,
429 }));
430 }
431 Err(e) => {
432 m.notify(
433 Severity::Warning,
434 tx.tw("notice.bad-reply", &[("error", &e)]),
435 );
436 }
437 },
438 _ => {
439 let text = tx.t("notice.token-updated");
440 m.notify(Severity::Ok, text);
441 }
442 }
443 Vec::new()
444}
445
446pub fn refused(m: &mut Model, fail: &Fail) -> bool {
448 if !matches!(m.modal, Some(Modal::Editor(_))) {
449 return false;
450 }
451 let text = if fail.is_denied() {
452 crate::tui::view::widgets::denied_text(m, fail)
453 } else {
454 format!("{}: {}", fail.code, fail.message)
455 };
456 if let Some(Modal::Editor(editor)) = &mut m.modal {
457 editor.busy = false;
458 editor.error = Some(text);
459 }
460 true
461}
462
463pub fn secret_key(
465 m: &mut Model,
466 mut secret: Secret,
467 code: ratatui::crossterm::event::KeyCode,
468) -> Vec<Effect> {
469 use ratatui::crossterm::event::KeyCode;
470 let tx = m.tx();
471 match code {
472 KeyCode::Char('c') => {
473 secret.copied = true;
474 let text = tx.t("notice.secret-copied");
475 let effect = Effect::Copy(secret.token.clone());
476 m.modal = Some(Modal::Secret(secret));
477 m.notify(Severity::Ok, text);
478 vec![effect]
479 }
480 KeyCode::Char('s') => {
481 let effect = Effect::WriteSecret {
482 path: secret_file(&secret.id),
483 contents: format!("{}\n", secret.token),
484 };
485 m.modal = Some(Modal::Secret(secret));
486 vec![effect]
487 }
488 KeyCode::Char('r') if secret.replaces.is_some() => {
489 secret.revoke_old = !secret.revoke_old;
490 m.modal = Some(Modal::Secret(secret));
491 Vec::new()
492 }
493 KeyCode::Esc | KeyCode::Enter => {
494 if !(secret.copied || secret.saved || secret.warned) {
495 secret.warned = true;
496 m.modal = Some(Modal::Secret(secret));
497 return Vec::new();
498 }
499 m.modal = None;
500 match (&secret.replaces, secret.revoke_old) {
501 (Some((old, _)), true) => run(m, ApiCall::TokenRevoke(old.clone())),
502 _ => Vec::new(),
503 }
504 }
505 _ => {
506 m.modal = Some(Modal::Secret(secret));
507 Vec::new()
508 }
509 }
510}
511
512pub fn secret_file(id: &str) -> String {
514 let safe: String = id
515 .chars()
516 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
517 .collect();
518 format!("taba-token-{safe}.txt")
519}
520
521pub fn secret_written(m: &mut Model, path: &str) {
523 if let Some(Modal::Secret(secret)) = &mut m.modal {
524 if secret_file(&secret.id) == path {
525 secret.saved = true;
526 }
527 }
528}
529
530pub fn revoked(m: &mut Model) {
532 let text = m.tx().t("notice.token-revoked");
533 m.notify(Severity::Ok, text);
534 m.tokens.list.refresh_now();
535}
536
537#[cfg(test)]
538mod tests;