╭ Login.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 xit = @import("xit"); 6 const rp = xit.repo; 7 const hash = xit.hash; 8 const xitui = xit.xitui; 9 const wgt = xitui.widget; 10 const layout = xitui.layout; 11 const inp = xitui.input; 12 const Grid = xitui.grid.Grid; 13 const Focus = xitui.focus.Focus; 14 const bcrypt = std.crypto.pwhash.bcrypt; 15 16 const wasm = builtin.target.cpu.arch == .wasm32; 17 18 const Self = @This(); 19 20 // classifies a failed /login attempt; null in session.data.login_failure means 21 // "no failure to surface". 22 pub const Failure = enum { wrong_password, unknown_user }; 23 24 pub fn init() Self { 25 return .{}; 26 } 27 28 pub const View = struct { 29 center: ui.Center, 30 data: *const Self, 31 session: *ui.Session, 32 nav_ids: [3]usize, 33 // focus id of the header's "users" tab; on a successful submit we jump 34 // focus there so the user isn't stranded on the login button. 35 success_redirect_tab_id: usize, 36 37 const username_index: usize = 0; 38 const password_index: usize = 1; 39 const button_index: usize = 2; 40 41 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, success_redirect_tab_id: usize) !View { 42 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 43 errdefer box.deinit(allocator); 44 // marks this subtree as an HTML form scope for the web overlay 45 box.getFocus().kind = .{ .custom = switch (session.data.current_page) { 46 .user_repos => |u| try std.fmt.allocPrint(session.page_arena.allocator(), "form:/user/{s}/login", .{u.name.slice()}), 47 .user_settings, .user_auth => |name| try std.fmt.allocPrint(session.page_arena.allocator(), "form:/user/{s}/login", .{name.slice()}), 48 else => "form:/login", 49 } }; 50 51 var nav_ids: [3]usize = undefined; 52 53 { 54 var username = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " username ", .name = "username", .rounded_corners = true, .render_content = !wasm }); 55 errdefer username.deinit(allocator); 56 username.getFocus().focusable = true; 57 nav_ids[username_index] = username.getFocus().id; 58 try box.children.put(allocator, username.getFocus().id, .{ 59 .widget = .{ .text_input = username }, 60 .rect = null, 61 .min_size = null, 62 }); 63 } 64 65 { 66 var password = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " password ", .password = true, .name = "password", .rounded_corners = true, .render_content = !wasm }); 67 errdefer password.deinit(allocator); 68 password.getFocus().focusable = true; 69 nav_ids[password_index] = password.getFocus().id; 70 try box.children.put(allocator, password.getFocus().id, .{ 71 .widget = .{ .text_input = password }, 72 .rect = null, 73 .min_size = null, 74 }); 75 } 76 77 { 78 var button = try wgt.TextBox(ui.Widget).init(allocator, "login", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 79 errdefer button.deinit(allocator); 80 button.getFocus().focusable = true; 81 // the renderer distinguishes plain clickables from buttons that 82 // should POST to a server route by this kind. 83 button.getFocus().kind = .{ .custom = "submit" }; 84 nav_ids[button_index] = button.getFocus().id; 85 try box.children.put(allocator, button.getFocus().id, .{ 86 .widget = .{ .text_box = button }, 87 .rect = null, 88 .min_size = null, 89 }); 90 } 91 92 box.getFocus().child_id = nav_ids[username_index]; 93 94 return .{ 95 .center = try ui.Center.init(allocator, .{ .box = box }), 96 .data = data, 97 .session = session, 98 .nav_ids = nav_ids, 99 .success_redirect_tab_id = success_redirect_tab_id, 100 }; 101 } 102 103 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 104 self.center.deinit(allocator); 105 } 106 107 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 108 const box = &self.center.child.box; 109 110 const failure = self.session.data.login_failure; 111 112 const username_input = &box.children.values()[username_index].widget.text_input; 113 username_input.options.label = if (failure == .unknown_user) 114 " username (invalid) " 115 else 116 " username "; 117 118 const password_input = &box.children.values()[password_index].widget.text_input; 119 password_input.options.label = if (failure == .wrong_password) 120 " password (invalid) " 121 else 122 " password "; 123 124 // refresh our entries in the session's focus-id -> *TextInput map with 125 // this frame's current input addresses, so the web/wasm form handling 126 // can find them by focus id 127 const inputs_arena = self.session.arena.allocator(); 128 try self.session.text_inputs.put(inputs_arena, username_input.getFocus().id, username_input); 129 try self.session.text_inputs.put(inputs_arena, password_input.getFocus().id, password_input); 130 131 try self.center.build(allocator, constraint, root_focus); 132 } 133 134 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 135 const box = &self.center.child.box; 136 const child_id = box.focus.child_id orelse return; 137 const current = self.indexOf(child_id) orelse return; 138 139 switch (key) { 140 // Shift+Tab walks backward through the form, matching the 141 // typical UX; arrow_up does the same. 142 .arrow_up, .back_tab => if (current > 0) { 143 root_focus.setFocus(self.nav_ids[current - 1]); 144 return; 145 }, 146 // Tab moves to the next field, matching the form-style UX users 147 // expect; arrow_down does the same. 148 .arrow_down, .tab => if (current + 1 < self.nav_ids.len) { 149 root_focus.setFocus(self.nav_ids[current + 1]); 150 return; 151 }, 152 // submit on Enter from any nav field — typical "press Enter in 153 // a form to submit" UX 154 .enter => { 155 try self.submit(allocator, root_focus); 156 return; 157 }, 158 else => {}, 159 } 160 161 if (current == button_index) { 162 switch (key) { 163 .mouse => |mouse| { 164 if (mouse.action == .press and mouse.action.press == .left) { 165 if (root_focus.children.get(child_id)) |entry| { 166 const r = entry.rect; 167 if (mouse.x >= r.x and mouse.y >= r.y and 168 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 169 { 170 try self.submit(allocator, root_focus); 171 return; 172 } 173 } 174 } 175 }, 176 else => {}, 177 } 178 } 179 180 const username_input = &box.children.values()[username_index].widget.text_input; 181 const password_input = &box.children.values()[password_index].widget.text_input; 182 const username_len_before = username_input.content.items.len; 183 const password_len_before = password_input.content.items.len; 184 185 if (box.children.getIndex(child_id)) |idx| { 186 try box.children.values()[idx].widget.input(allocator, key, root_focus); 187 } 188 189 if (username_input.content.items.len != username_len_before or 190 password_input.content.items.len != password_len_before) 191 { 192 // any edit clears the failure flag so the "(invalid)" label 193 // doesn't linger after the user has typed a correction. 194 self.session.data.login_failure = null; 195 } 196 } 197 198 pub fn clearGrid(self: *View) void { 199 self.center.clearGrid(); 200 } 201 202 pub fn getGrid(self: View) ?Grid { 203 return self.center.getGrid(); 204 } 205 206 pub fn getFocus(self: *View) *Focus { 207 return self.center.getFocus(); 208 } 209 210 pub fn getSelectedIndex(self: View) ?usize { 211 const child_id = self.center.child.box.focus.child_id orelse return null; 212 return self.indexOf(child_id); 213 } 214 215 fn indexOf(self: View, child_id: usize) ?usize { 216 for (self.nav_ids, 0..) |id, i| { 217 if (id == child_id) return i; 218 } 219 return null; 220 } 221 222 fn submit(self: *View, allocator: std.mem.Allocator, root_focus: *Focus) !void { 223 if (comptime wasm) { 224 // no DB cursor available on the wasm render path 225 self.session.data.login_failure = .unknown_user; 226 return; 227 } 228 229 const box = &self.center.child.box; 230 const username_input = &box.children.values()[username_index].widget.text_input; 231 const password_input = &box.children.values()[password_index].widget.text_input; 232 233 const username = try username_input.text(allocator); 234 defer allocator.free(username); 235 236 const password = try password_input.text(allocator); 237 defer allocator.free(password); 238 239 const haxy_moment = self.session.haxy_moment orelse { 240 // no DB context (e.g. wasm/web rendering path); treat as unknown. 241 self.session.data.login_failure = .unknown_user; 242 return; 243 }; 244 245 const result = try evt.User.verifyCredentials(evt.AdminDB, evt.admin_repo_opts.hash, haxy_moment, self.session.arena, username, password); 246 switch (result) { 247 .unknown_user => { 248 self.session.data.login_failure = .unknown_user; 249 return; 250 }, 251 .wrong_password => { 252 self.session.data.login_failure = .wrong_password; 253 return; 254 }, 255 .success => |user_id| { 256 // dupe user_id into the arena so the session can hold a stable slice 257 const user_id_stable = try self.session.arena.allocator().dupe(u8, &user_id); 258 self.session.data.user_id = user_id_stable; 259 self.session.data.login_failure = null; 260 261 // adopt the user's persisted prefs 262 try self.session.loadUserPrefs(); 263 264 // wipe the entered credentials so they don't linger if the 265 // user returns to this page after logging out 266 username_input.clear(allocator); 267 password_input.clear(allocator); 268 269 // jump focus back to the users tab — the login button we 270 // just pressed is about to be hidden by the tab-label swap 271 root_focus.setFocus(self.success_redirect_tab_id); 272 }, 273 } 274 } 275 }; 276