aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-12-16 09:16:41 +0100
committerGitHub <noreply@github.com>2025-12-16 09:16:41 +0100
commit1296a46a68f7a63681f294c12fadc9ca5e0c6459 (patch)
tree8b829389b78205d7dcc322c80fcef50fb8a0e669 /default-plugins
parentHOTFIX: fix flaky macos test (diff)
downloadsl0ppij-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.rs53
-rw-r--r--default-plugins/share/src/token_management_screen.rs175
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 {