diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-12-16 09:16:41 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-16 09:16:41 +0100 |
| commit | 1296a46a68f7a63681f294c12fadc9ca5e0c6459 (patch) | |
| tree | 8b829389b78205d7dcc322c80fcef50fb8a0e669 /default-plugins | |
| parent | HOTFIX: fix flaky macos test (diff) | |
| download | sl0ppij-1296a46a68f7a63681f294c12fadc9ca5e0c6459.tar.gz sl0ppij-1296a46a68f7a63681f294c12fadc9ca5e0c6459.tar.xz sl0ppij-1296a46a68f7a63681f294c12fadc9ca5e0c6459.zip | |
Read only web tokens (#4551)
* initial implementation
* fix race condition potentially allowing read-only clients to gain write access
* clear read only status on disconnect
* cleanups and formatting
* fix tests
* refactor first message + ipc pipe creation out of trait
* fix web tests
* fix web tests again
* refactor: move os_input connection establishment to session manager trait
* add read-only-token tests
* fix: identify watchers as web-clients when they are and enforce no-web-clients for them as well
* identify watcher web clients on the server
* fix tests
* cleanups and formatting
* add read-only-tokens to the plugin api
* update share plugin ui for read-only tokens
* cleanups and formatting
* docs(changelog): add PR
Diffstat (limited to 'default-plugins')
| -rw-r--r-- | default-plugins/share/src/main.rs | 53 | ||||
| -rw-r--r-- | default-plugins/share/src/token_management_screen.rs | 175 |
2 files changed, 166 insertions, 62 deletions
diff --git a/default-plugins/share/src/main.rs b/default-plugins/share/src/main.rs index 1708e495..86b5cb4a 100644 --- a/default-plugins/share/src/main.rs +++ b/default-plugins/share/src/main.rs @@ -205,7 +205,7 @@ impl App { fn handle_token_action(&mut self) { if self.tokens.list.is_empty() { - self.generate_new_token(None); + self.generate_new_token(None, false); } else { self.change_to_manage_tokens_screen(); } @@ -234,6 +234,10 @@ impl App { self.tokens.start_new_token_input(); true }, + BareKey::Char('o') if key.has_no_modifiers() => { + self.tokens.start_new_read_only_token_input(); + true + }, BareKey::Enter if key.has_no_modifiers() => self.handle_enter_key(), BareKey::Char('r') if key.has_no_modifiers() => { self.tokens.start_rename_input(); @@ -259,7 +263,12 @@ impl App { fn handle_enter_key(&mut self) -> bool { if let Some(token_name) = self.tokens.finish_new_token_input() { - self.generate_new_token(token_name); + self.generate_new_token(token_name, false); + return true; + } + + if let Some(token_name) = self.tokens.finish_new_read_only_token_input() { + self.generate_new_token(token_name, true); return true; } @@ -271,8 +280,8 @@ impl App { false } - fn generate_new_token(&mut self, name: Option<String>) { - match generate_web_login_token(name) { + fn generate_new_token(&mut self, name: Option<String>, read_only: bool) { + match generate_web_login_token(name, read_only) { Ok(token) => self.change_to_token_screen(token), Err(e) => self.web_server.error = Some(e), } @@ -448,11 +457,18 @@ impl App { } fn render_manage_tokens_screen(&self, rows: usize, cols: usize) { + // Pass whichever token input field is active (normal or read-only) + let entering_new_token_name = if self.tokens.entering_new_name.is_some() { + &self.tokens.entering_new_name + } else { + &self.tokens.entering_new_read_only_name + }; + TokenManagementScreen::new( &self.tokens.list, self.tokens.selected_index, &self.tokens.renaming_token, - &self.tokens.entering_new_name, + entering_new_token_name, &self.web_server.error, &self.state.info, rows, @@ -499,9 +515,10 @@ impl UIState { #[derive(Debug, Default)] struct TokenManager { - list: Vec<(String, String)>, + list: Vec<(String, String, bool)>, // bool -> is_read_only selected_index: Option<usize>, entering_new_name: Option<String>, + entering_new_read_only_name: Option<String>, renaming_token: Option<String>, } @@ -516,7 +533,7 @@ impl TokenManager { } } - fn get_selected_token(&self) -> Option<&(String, String)> { + fn get_selected_token(&self) -> Option<&(String, String, bool)> { self.selected_index.and_then(|i| self.list.get(i)) } @@ -560,6 +577,10 @@ impl TokenManager { self.entering_new_name = Some(String::new()); } + fn start_new_read_only_token_input(&mut self) { + self.entering_new_read_only_name = Some(String::new()); + } + fn start_rename_input(&mut self) { self.renaming_token = Some(String::new()); } @@ -571,6 +592,10 @@ impl TokenManager { name.push(c); return true; } + if let Some(ref mut name) = self.entering_new_read_only_name { + name.push(c); + return true; + } if let Some(ref mut name) = self.renaming_token { name.push(c); return true; @@ -581,6 +606,10 @@ impl TokenManager { name.pop(); return true; } + if let Some(ref mut name) = self.entering_new_read_only_name { + name.pop(); + return true; + } if let Some(ref mut name) = self.renaming_token { name.pop(); return true; @@ -597,12 +626,20 @@ impl TokenManager { .map(|name| if name.is_empty() { None } else { Some(name) }) } + fn finish_new_read_only_token_input(&mut self) -> Option<Option<String>> { + self.entering_new_read_only_name + .take() + .map(|name| if name.is_empty() { None } else { Some(name) }) + } + fn finish_rename_input(&mut self) -> Option<String> { self.renaming_token.take() } fn cancel_input(&mut self) -> bool { - self.entering_new_name.take().is_some() || self.renaming_token.take().is_some() + self.entering_new_name.take().is_some() + || self.entering_new_read_only_name.take().is_some() + || self.renaming_token.take().is_some() } } diff --git a/default-plugins/share/src/token_management_screen.rs b/default-plugins/share/src/token_management_screen.rs index 0eb3a873..8341798d 100644 --- a/default-plugins/share/src/token_management_screen.rs +++ b/default-plugins/share/src/token_management_screen.rs @@ -1,22 +1,26 @@ use zellij_tile::prelude::*; +#[derive(Debug)] struct ScreenContent { title: (String, Text), items: Vec<Vec<Text>>, help: (String, Text), status_message: Option<(String, Text)>, max_width: usize, - new_token_item: Option<Vec<Text>>, + new_token_line: Option<(String, Text)>, } +#[derive(Debug)] struct Layout { base_x: usize, base_y: usize, title_x: usize, + new_token_y: usize, help_y: usize, status_y: usize, } +#[derive(Debug)] struct ScrollInfo { start_index: usize, end_index: usize, @@ -24,14 +28,16 @@ struct ScrollInfo { truncated_bottom: usize, } +#[derive(Debug)] struct ColumnWidths { token: usize, date: usize, + read_only: usize, controls: usize, } pub struct TokenManagementScreen<'a> { - token_list: &'a Vec<(String, String)>, + token_list: &'a Vec<(String, String, bool)>, // bool -> is_read_only selected_list_index: Option<usize>, renaming_token: &'a Option<String>, entering_new_token_name: &'a Option<String>, @@ -43,7 +49,7 @@ pub struct TokenManagementScreen<'a> { impl<'a> TokenManagementScreen<'a> { pub fn new( - token_list: &'a Vec<(String, String)>, + token_list: &'a Vec<(String, String, bool)>, selected_list_index: Option<usize>, renaming_token: &'a Option<String>, entering_new_token_name: &'a Option<String>, @@ -76,44 +82,55 @@ impl<'a> TokenManagementScreen<'a> { const MIN_TOKEN_WIDTH: usize = 10; const MIN_DATE_WIDTH: usize = 10; // Minimum for just date "YYYY-MM-DD" + const MIN_READ_ONLY_WIDTH: usize = 5; // Minimum for "RO/RW" const MIN_CONTROLS_WIDTH: usize = 6; // Minimum for "(<x>, <r>)" - const COLUMN_SPACING: usize = 2; // Space between columns + const COLUMN_SPACING: usize = 5; // Space between columns (4 columns with table padding) - let min_total_width = - MIN_TOKEN_WIDTH + MIN_DATE_WIDTH + MIN_CONTROLS_WIDTH + COLUMN_SPACING; + let min_total_width = MIN_TOKEN_WIDTH + + MIN_DATE_WIDTH + + MIN_READ_ONLY_WIDTH + + MIN_CONTROLS_WIDTH + + COLUMN_SPACING; if max_table_width <= min_total_width { return ColumnWidths { token: MIN_TOKEN_WIDTH, date: MIN_DATE_WIDTH, + read_only: MIN_READ_ONLY_WIDTH, controls: MIN_CONTROLS_WIDTH, }; } const PREFERRED_DATE_WIDTH: usize = 29; // "issued on YYYY-MM-DD HH:MM:SS" + const PREFERRED_READ_ONLY_WIDTH: usize = 10; // "read-write" const PREFERRED_CONTROLS_WIDTH: usize = 24; // "(<x> revoke, <r> rename)" let available_width = max_table_width.saturating_sub(COLUMN_SPACING); - let preferred_fixed_width = PREFERRED_DATE_WIDTH + PREFERRED_CONTROLS_WIDTH; + let preferred_fixed_width = + PREFERRED_DATE_WIDTH + PREFERRED_READ_ONLY_WIDTH + PREFERRED_CONTROLS_WIDTH; if available_width >= preferred_fixed_width + MIN_TOKEN_WIDTH { - // We can use preferred widths for date and controls + // We can use preferred widths for date, read_only, and controls ColumnWidths { token: available_width.saturating_sub(preferred_fixed_width), date: PREFERRED_DATE_WIDTH, + read_only: PREFERRED_READ_ONLY_WIDTH, controls: PREFERRED_CONTROLS_WIDTH, } } else { // Need to balance truncation across all columns + // Priority: controls > read_only > date > token (token gets remaining space) let remaining_width = available_width .saturating_sub(MIN_TOKEN_WIDTH) .saturating_sub(MIN_DATE_WIDTH) + .saturating_sub(MIN_READ_ONLY_WIDTH) .saturating_sub(MIN_CONTROLS_WIDTH); - let extra_per_column = remaining_width / 3; + let extra_per_column = remaining_width / 4; ColumnWidths { token: MIN_TOKEN_WIDTH + extra_per_column, date: MIN_DATE_WIDTH + extra_per_column, + read_only: MIN_READ_ONLY_WIDTH + extra_per_column, controls: MIN_CONTROLS_WIDTH + extra_per_column, } } @@ -184,6 +201,38 @@ impl<'a> TokenManagementScreen<'a> { } } + fn format_read_only(&self, is_read_only: bool, max_width: usize) -> String { + let full_text = if is_read_only { + "read-only" + } else { + "read-write" + }; + let short_text = if is_read_only { "RO" } else { "RW" }; + + let text = if full_text.chars().count() <= max_width { + full_text + } else { + short_text + }; + + // Center the text in the column + let text_len = text.chars().count(); + if text_len >= max_width { + return text.to_string(); + } + + let padding = max_width - text_len; + let left_padding = padding / 2; + let right_padding = padding - left_padding; + + format!( + "{}{}{}", + " ".repeat(left_padding), + text, + " ".repeat(right_padding) + ) + } + fn format_controls(&self, max_width: usize, is_selected: bool) -> String { if !is_selected { return " ".repeat(max_width); @@ -234,15 +283,15 @@ impl<'a> TokenManagementScreen<'a> { max_width = std::cmp::max(max_width, title_text.len()); let mut items = vec![]; - for (i, (token, created_at)) in self.token_list.iter().enumerate() { + for (i, (token, created_at, read_only)) in self.token_list.iter().enumerate() { let is_selected = Some(i) == self.selected_list_index; let (row_text, row_items) = - self.create_token_item(token, created_at, is_selected, &column_widths); + self.create_token_item(token, created_at, *read_only, is_selected, &column_widths); max_width = std::cmp::max(max_width, row_text.chars().count()); items.push(row_items); } - let (new_token_text, new_token_row) = self.create_new_token_item(&column_widths); + let (new_token_text, new_token_line) = self.create_new_token_line(); max_width = std::cmp::max(max_width, new_token_text.chars().count()); let (help_text, help_line) = self.create_help_line(); @@ -261,7 +310,7 @@ impl<'a> TokenManagementScreen<'a> { help: (help_text, help_line), status_message, max_width, - new_token_item: Some(new_token_row), + new_token_line: Some((new_token_text, new_token_line)), } } @@ -357,17 +406,18 @@ impl<'a> TokenManagementScreen<'a> { &self, token: &str, created_at: &str, + is_read_only: bool, is_selected: bool, column_widths: &ColumnWidths, ) -> (String, Vec<Text>) { if is_selected { if let Some(new_name) = &self.renaming_token { - self.create_renaming_item(new_name, created_at, column_widths) + self.create_renaming_item(new_name, created_at, is_read_only, column_widths) } else { - self.create_selected_item(token, created_at, column_widths) + self.create_selected_item(token, created_at, is_read_only, column_widths) } } else { - self.create_regular_item(token, created_at, column_widths) + self.create_regular_item(token, created_at, is_read_only, column_widths) } } @@ -375,12 +425,14 @@ impl<'a> TokenManagementScreen<'a> { &self, new_name: &str, created_at: &str, + is_read_only: bool, column_widths: &ColumnWidths, ) -> (String, Vec<Text>) { let truncated_name = self.truncate_token_name(new_name, column_widths.token.saturating_sub(1)); // -1 for cursor let item_text = format!("{}_", truncated_name); let date_text = self.format_date(created_at, column_widths.date, true); + let read_only_text = self.format_read_only(is_read_only, column_widths.read_only); let controls_text = " ".repeat(column_widths.controls); let token_end = truncated_name.chars().count(); @@ -389,10 +441,14 @@ impl<'a> TokenManagementScreen<'a> { .color_range(0, ..token_end + 1) .selected(), Text::new(&date_text), + Text::new(&read_only_text).color_all(1), Text::new(&controls_text), ]; ( - format!("{} {} {}", item_text, date_text, controls_text), + format!( + "{} {} {} {}", + item_text, date_text, read_only_text, controls_text + ), items, ) } @@ -401,6 +457,7 @@ impl<'a> TokenManagementScreen<'a> { &self, token: &str, created_at: &str, + is_read_only: bool, column_widths: &ColumnWidths, ) -> (String, Vec<Text>) { let mut item_text = self.truncate_token_name(token, column_widths.token); @@ -409,6 +466,7 @@ impl<'a> TokenManagementScreen<'a> { item_text.push(' '); }; let date_text = self.format_date(created_at, column_widths.date, true); + let read_only_text = self.format_read_only(is_read_only, column_widths.read_only); let controls_text = self.format_controls(column_widths.controls, true); // Determine highlight ranges for controls based on the actual content @@ -432,11 +490,15 @@ impl<'a> TokenManagementScreen<'a> { let items = vec![ Text::new(&item_text).color_range(0, ..).selected(), Text::new(&date_text).selected(), + Text::new(&read_only_text).color_all(1).selected(), controls_colored, ]; ( - format!("{} {} {}", item_text, date_text, controls_text), + format!( + "{} {} {} {}", + item_text, date_text, read_only_text, controls_text + ), items, ) } @@ -445,6 +507,7 @@ impl<'a> TokenManagementScreen<'a> { &self, token: &str, created_at: &str, + is_read_only: bool, column_widths: &ColumnWidths, ) -> (String, Vec<Text>) { let mut item_text = self.truncate_token_name(token, column_widths.token); @@ -453,57 +516,54 @@ impl<'a> TokenManagementScreen<'a> { item_text.push(' '); }; let date_text = self.format_date(created_at, column_widths.date, true); + let read_only_text = self.format_read_only(is_read_only, column_widths.read_only); let controls_text = " ".repeat(column_widths.controls); let items = vec![ Text::new(&item_text).color_range(0, ..), Text::new(&date_text), + Text::new(&read_only_text).color_all(1), Text::new(&controls_text), ]; ( - format!("{} {} {}", item_text, date_text, controls_text), + format!( + "{} {} {} {}", + item_text, date_text, read_only_text, controls_text + ), items, ) } - fn create_new_token_item(&self, column_widths: &ColumnWidths) -> (String, Vec<Text>) { - let create_new_token_text = "<n> - create new token".to_string(); - let short_create_text = "<n> - new".to_string(); - let date_placeholder = " ".repeat(column_widths.date); - let controls_placeholder = " ".repeat(column_widths.controls); + fn create_new_token_line(&self) -> (String, Text) { + let full_create_text = "<n> - create new token, <o> - create read-only token".to_string(); + let medium_create_text = "<n> - new token, <o> - read-only".to_string(); + let short_create_text = "<n> - new, <o> - RO".to_string(); if let Some(name) = &self.entering_new_token_name { - let truncated_name = - self.truncate_token_name(name, column_widths.token.saturating_sub(1)); // -1 for cursor + let max_width = self.cols.saturating_sub(1); // Leave room for cursor + let truncated_name = if name.chars().count() > max_width { + let chars: Vec<char> = name.chars().take(max_width).collect(); + chars.into_iter().collect() + } else { + name.clone() + }; let text = format!("{}_", truncated_name); - let item = vec![ - Text::new(&text).color_range(3, ..), - Text::new(&date_placeholder), - Text::new(&controls_placeholder), - ]; - ( - format!("{} {} {}", text, date_placeholder, controls_placeholder), - item, - ) + (text.clone(), Text::new(&text).color_range(3, ..)) } else { - // Check if the full text fits, otherwise use the short version - let text_to_use = if create_new_token_text.chars().count() <= column_widths.token { - &create_new_token_text + // Check which text fits + let (text_to_use, n_range, o_range) = if full_create_text.chars().count() <= self.cols { + (&full_create_text, 0..=2, 24..=26) + } else if medium_create_text.chars().count() <= self.cols { + (&medium_create_text, 0..=2, 16..=19) } else { - &short_create_text + (&short_create_text, 0..=2, 11..=14) }; - let item = vec![ - Text::new(text_to_use).color_range(3, 0..=2), - Text::new(&date_placeholder), - Text::new(&controls_placeholder), - ]; ( - format!( - "{} {} {}", - text_to_use, date_placeholder, controls_placeholder - ), - item, + text_to_use.to_string(), + Text::new(text_to_use) + .color_range(3, n_range) + .color_range(3, o_range), ) } } @@ -579,6 +639,7 @@ impl<'a> TokenManagementScreen<'a> { base_x: (self.cols.saturating_sub(content.max_width) as f64 / 2.0).floor() as usize, base_y, title_x: self.cols.saturating_sub(content.title.0.len()) / 2, + new_token_y, help_y, status_y: help_y, // Status message uses the same position as help } @@ -587,17 +648,23 @@ impl<'a> TokenManagementScreen<'a> { fn print_items_to_screen(&self, content: ScreenContent, layout: Layout) { print_text_with_coordinates(content.title.1, layout.title_x, layout.base_y, None, None); - let mut table = Table::new().add_row(vec![" ", " ", " "]); + let mut table = Table::new().add_row(vec![" ", " ", " ", " "]); for item in content.items.into_iter() { table = table.add_styled_row(item); } - if let Some(new_token_item) = content.new_token_item { - table = table.add_styled_row(new_token_item); - } - print_table_with_coordinates(table, layout.base_x, layout.base_y + 1, None, None); + if let Some((_, new_token_text)) = content.new_token_line { + print_text_with_coordinates( + new_token_text, + layout.base_x, + layout.new_token_y, + None, + None, + ); + } + if let Some((_, status_text)) = content.status_message { print_text_with_coordinates(status_text, layout.base_x, layout.status_y, None, None); } else { |
