Skip to main content

taba_cli/tui/screens/tokens/
mod.rs

1//! The Tokens tab (doc 34, section 6.5): the tokens of the server, creating, editing, cloning and revoking them.
2//! This file is the state and the keys; `view.rs` draws it. The secret of a new token never leaves the popup that
3//! shows it (TUI-TOK-03).
4
5use 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
20/// The permissions a token can have, in the order the form lists them.
21pub const PERMISSIONS: [&str; 5] = ["publish", "consume", "create", "manage", "admin"];
22
23/// After this long the secret on the screen is masked (TUI-TOK-03), in milliseconds.
24pub const MASK_AFTER_MS: u64 = 30_000;
25
26/// A token that expires within this many days is shown in yellow (TUI-TOK-01).
27pub const SOON_DAYS: i64 = 7;
28
29/// How many pages of tokens one read follows before it gives up (200 tokens each).
30pub const MAX_PAGES: u32 = 25;
31
32/// What the tab can do with this token and this server.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Availability {
35    /// `GET /auth/me` has not answered yet.
36    Unknown,
37    /// The server runs without authentication: there are no tokens (`404`).
38    NoAuth,
39    /// Only the root token and the `admin` tokens manage tokens.
40    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
53/// The tokens shown: those whose name has the filter in it, in the chosen order.
54pub 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
74/// The index of the selected token in `tokens`; the first one if the selection is gone.
75pub 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
92/// Why an action cannot run now, as a catalog key; `None` means it can (TUI-FRM-10).
93pub 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        // An `admin` token is only changed and revoked by the root token (the server answers `403` otherwise).
108        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        // Only the root token creates an `admin` token.
187        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
253/// `2026-10-15T12:00:00Z` for a time in milliseconds since the epoch.
254pub 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/// The text typed as an expiry is not one of the forms `parse_expiry` reads.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct BadExpiry;
265
266/// What a person types as an expiry: nothing, `none` or `never` (no deadline), a time from now (`90m`, `12h`,
267/// `30d`), a date (`2027-01-31`, the start of that day in UTC) or a full RFC 3339 instant. `Ok(None)` is no
268/// deadline.
269pub 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
301/// The form was filled in and the person asked to save it.
302pub 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
384/// The keys of the form of a token.
385pub 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
400/// The server answered the form: the secret of a new token, or the change of a token.
401pub 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
446/// The server refused the form: the reason goes in the form, which stays open (TUI-TOK-02).
447pub 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
463/// A key in the popup of the secret (TUI-TOK-03).
464pub 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
512/// The file `s` writes the secret to: in the folder the interface was opened from.
513pub 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
521/// The secret was written to a file: it counts as kept.
522pub 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
530/// A token was revoked.
531pub 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;