╭ UserKeys.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const evt = @import("../event.zig"); 4 const ui = @import("../ui.zig"); 5 const inp = @import("./input.zig"); 6 const xit = @import("xit"); 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const layout = xitui.layout; 10 const Key = xitui.input.Key; 11 const Grid = xitui.grid.Grid; 12 const Focus = xitui.focus.Focus; 13 14 const wasm = builtin.target.cpu.arch == .wasm32; 15 16 // the logged-in user's ssh keys, one authorized_keys line each 17 keys: []const []const u8 = &.{}, 18 19 const Self = @This(); 20 21 pub fn init(arena: *std.heap.ArenaAllocator, session: *ui.Session, user_id: *const [evt.event_id_size]u8) !Self { 22 const moment = session.haxy_moment orelse return .{}; 23 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, user_id)) orelse return .{}; 24 var keys: std.ArrayList([]const u8) = .empty; 25 var lines = std.mem.splitScalar(u8, user.event.ssh_keys, '\n'); 26 while (lines.next()) |line| { 27 if (evt.User.fingerprintOfKey(line) == null) continue; 28 try keys.append(arena.allocator(), line); 29 } 30 return .{ .keys = keys.items }; 31 } 32 33 // a key's comment, or its type when it has none 34 fn keyLabel(key: []const u8) []const u8 { 35 var tokens = std.mem.tokenizeAny(u8, key, " \t"); 36 const key_type = tokens.next() orelse return ""; 37 _ = tokens.next(); 38 const comment = std.mem.trim(u8, tokens.rest(), " \t"); 39 return if (comment.len > 0) comment else key_type; 40 } 41 42 const add_label = " add ssh key "; 43 const input_width = 60; 44 45 pub const View = struct { 46 scroll: wgt.Scroll(ui.Widget), 47 data: *const Self, 48 session: *ui.Session, 49 50 const add_index = 0; 51 52 // `route` is the ssh keys route, which the forms post under 53 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, route: ui.RoutablePage) !View { 54 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 55 errdefer box.deinit(allocator); 56 const aa = session.page_arena.allocator(); 57 const url = try route.toUrl(session.page_arena); 58 59 { 60 var add = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 61 errdefer add.deinit(allocator); 62 add.getFocus().kind = .{ .custom = try aa.print("form:{s}/addkey", .{url}) }; 63 var key = try wgt.TextInput.init(allocator, .{ .top_label = .{ .text = add_label }, .name = "key", .visible_width = input_width, .round_corners = true, .render_content = session.is_terminal }); 64 errdefer key.deinit(allocator); 65 key.getFocus().mode = .all; 66 if (session.formFeedback(.user_keys)) |saved| try key.setContent(allocator, saved.key); 67 add.getFocus().child_id = key.getFocus().id; 68 try add.children.put(allocator, key.getFocus().id, .{ .widget = .{ .text_input = key }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 69 try box.children.put(allocator, add.getFocus().id, .{ .widget = .{ .box = add }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 70 } 71 72 // one row per key, whose form posts the key to remove 73 for (data.keys) |key| { 74 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 75 errdefer row.deinit(allocator); 76 row.getFocus().kind = .{ .custom = try aa.print("form:{s}/removekey", .{url}) }; 77 { 78 var text_input = try wgt.TextInput.init(allocator, .{ 79 .top_label = .{ .text = try aa.print(" {s} ", .{keyLabel(key)}) }, 80 .name = "key", 81 .visible_width = input_width, 82 .round_corners = true, 83 .read_only = true, 84 .render_content = session.is_terminal, 85 }); 86 errdefer text_input.deinit(allocator); 87 text_input.getFocus().mode = .all; 88 try text_input.setContent(allocator, key); 89 // show the start of the key, as the web's input does 90 text_input.cursor = 0; 91 try row.children.put(allocator, text_input.getFocus().id, .{ .widget = .{ .text_input = text_input }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 92 } 93 { 94 var remove = try wgt.TextBox.init(allocator, "✕", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 95 errdefer remove.deinit(allocator); 96 remove.getFocus().mode = .all; 97 remove.getFocus().kind = .{ .custom = "submit" }; 98 try row.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); 99 } 100 row.getFocus().child_id = row.children.keys()[0]; 101 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 102 } 103 104 box.getFocus().child_id = box.children.keys()[add_index]; 105 var center = try ui.widget.Center.init(allocator, .{ .box = box }); 106 errdefer center.deinit(allocator); 107 return .{ 108 .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .center = center }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }), 109 .data = data, 110 .session = session, 111 }; 112 } 113 114 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 115 self.scroll.deinit(allocator); 116 } 117 118 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 119 return &self.scroll.child.center.child.box; 120 } 121 122 fn addBox(self: *View) *wgt.Box(ui.Widget) { 123 return &self.contentBox().children.values()[add_index].widget.box; 124 } 125 126 fn addInput(self: *View) *wgt.TextInput { 127 return &self.addBox().children.values()[0].widget.text_input; 128 } 129 130 fn addActive(self: *View) bool { 131 return self.contentBox().getFocus().child_id == self.addBox().getFocus().id; 132 } 133 134 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 135 self.clearGrid(); 136 const add = self.addInput(); 137 const failure = if (self.session.formFeedback(.user_keys)) |saved| saved.failure else null; 138 add.options.top_label.text = if (failure) |value| switch (value) { 139 .invalid => " add ssh key (invalid) ", 140 .unsupported => " add ssh key (unsupported type) ", 141 .already_added => " add ssh key (already added) ", 142 } else add_label; 143 add.options.bottom_label.text = if (root_focus.grandchild_id == add.getFocus().id) " press enter " else ""; 144 // the web form handling finds the inputs by focus id 145 const inputs_arena = self.session.arena.allocator(); 146 for (self.contentBox().children.values()) |*child| { 147 const text_input = &child.widget.box.children.values()[0].widget.text_input; 148 try self.session.text_inputs.put(inputs_arena, text_input.getFocus().id, text_input); 149 } 150 // the window's height as a minimum centers rows that fit 151 try self.scroll.build(allocator, .{ 152 .min_size = .{ .width = constraint.min_size.width, .height = constraint.max_size.height orelse constraint.min_size.height }, 153 .max_size = constraint.max_size, 154 }, root_focus); 155 } 156 157 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 158 // the add row keeps every key but the vertical ones, which move between rows 159 if (self.addActive() and inp.vertDirection(key) == .none) { 160 if (key == .enter) { 161 const text = try self.addInput().text(allocator); 162 defer allocator.free(text); 163 try self.act(allocator, .add, text); 164 } else try self.addInput().input(allocator, key, root_focus); 165 return; 166 } 167 168 const rows = self.contentBox(); 169 const current = rows.children.getIndex(rows.getFocus().child_id orelse return) orelse return; 170 if (inp.rowDelta(key, @intCast(rows.children.count()))) |delta| { 171 ui.widget.moveRowFocus(rows, &self.scroll, root_focus, delta); 172 return; 173 } 174 switch (key) { 175 .arrow_left, .arrow_right => _ = ui.widget.moveInSelectedRow(rows, root_focus, key == .arrow_right), 176 else => { 177 // the add row sits above the key rows 178 if (current == add_index) return; 179 const row = &rows.children.values()[current].widget.box; 180 const remove_id = row.children.keys()[1]; 181 if (row.getFocus().child_id != remove_id or !inp.activated(root_focus, remove_id, key)) return; 182 try self.act(allocator, .remove, self.data.keys[current - 1]); 183 }, 184 } 185 } 186 187 // the terminal changes the keys itself; the web posts the form instead 188 fn act(self: *View, allocator: std.mem.Allocator, action: evt.KeyAction, key: []const u8) !void { 189 if (comptime wasm) return; 190 const io = self.session.io orelse return; 191 const users_dir = self.session.users_dir orelse return; 192 const admin_repo = self.session.admin_repo orelse return; 193 const user_id = self.session.userId() orelse return; 194 evt.changeUserKey(io, allocator, users_dir, admin_repo, &user_id, action, key) catch |err| switch (err) { 195 // the account was removed under them 196 error.NotFound => return ui.UserNotifications.logOut(self.session), 197 else => { 198 const failure = ui.Session.FormFeedback.KeyFailure.fromError(err) orelse return err; 199 self.session.data.form_feedback = .{ .user_keys = .{ .failure = failure, .key = try self.session.arena.allocator().dupe(u8, key) } }; 200 return; 201 }, 202 }; 203 // reload the page with the keys as they are now 204 self.session.haxy_moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 205 try self.session.navigate(self.session.data.current_page); 206 } 207 208 pub fn clearGrid(self: *View) void { 209 self.scroll.clearGrid(); 210 } 211 212 pub fn getGrid(self: View) ?Grid { 213 return self.scroll.getGrid(); 214 } 215 216 pub fn getFocus(self: *View) *Focus { 217 return self.scroll.getFocus(); 218 } 219 220 // up leaves the view from the add row 221 pub fn atTop(self: *View) bool { 222 return self.addActive(); 223 } 224 }; 225