╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Home/Users.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │59 const aa = arena.allocator(); │ │60 │ │61 const lines = try aa.alloc([]const u8, data.users.len); │ │62 + const links = try aa.alloc([]const u8, data.users.len); │ │63 for (data.users, 0..) |user, i| { │ │64 lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ user.name, user.display_name });│ │65 + // clicking a user opens their page; the "a:" prefix makes the web │ │66 + // renderer emit an <a href="/user/foo"> anchor. │ │67 + links[i] = try std.fmt.allocPrint(aa, "a:/user/{s}", .{user.name}); │ │68 } │ │65 - try self.list.setItems(allocator, lines); │ │69 + try self.list.setItems(allocator, lines, links); │ │70 │ │71 return self; │ │72 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/ui/Settings.zig│ ╰───────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │30 │ │31 var box = wgt.Box(ui.Widget).init(.{ .border_style = null, .rounded_corners = true, .direction = .vert }); │ │32 errdefer box.deinit(allocator); │ │33 - box.getFocus().kind = .{ .custom = if (logged_in) "form:/ansi" else "form:" }; │ │33 + // when logged in, the toggle posts to a page-scoped /ansi path so the │ │34 + // server sends us back to this page's settings tab; logged out, the │ │35 + // empty action blocks the POST and the input goes to the TUI instead. │ │36 + box.getFocus().kind = .{ .custom = if (!logged_in) "form:" else switch (session.data.current_page) { │ │37 + .user, .user_settings, .user_auth => |name| try std.fmt.allocPrint(session.arena.allocator(), "form:/user/{s}/ansi", .{name}),│ │38 + else => "form:/ansi", │ │39 + } }; │ │40 │ │41 var button_id: usize = undefined; │ │42 { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ added ────────╮ │src/ui/User.zig│ ╰───────────────╯ ╭ added ───────────────╮ │src/ui/User/Header.zig│ ╰──────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │142 const uri = try std.Uri.parseAfterScheme("", request.head.target); │ │143 const path = uri.path.percent_encoded; │ │144 │ │145 - if (method == .POST and std.mem.eql(u8, path, "/login")) { │ │146 - return handleLogin(io, request, allocator, admin_repo_path, session_store); │ │145 + // login/logout are page-scoped. the base (the path minus the trailing verb) │ │146 + // is where we redirect afterward, so logging in keeps you on the page you │ │147 + // were on instead of jumping to the home view. │ │148 + if (method == .POST and std.mem.endsWith(u8, path, "/login")) { │ │149 + return handleLogin(io, request, allocator, path[0 .. path.len - "/login".len], admin_repo_path, session_store);│ │150 } │ │148 - if (method == .POST and std.mem.eql(u8, path, "/logout")) { │ │149 - return handleLogout(request, session_store); │ │151 + if (method == .POST and std.mem.endsWith(u8, path, "/logout")) { │ │152 + return handleLogout(request, path[0 .. path.len - "/logout".len], session_store); │ │153 } │ │151 - if (method == .POST and std.mem.eql(u8, path, "/ansi")) { │ │152 - return handleAnsi(io, request, allocator, admin_repo_path, session_store); │ │154 + if (method == .POST and std.mem.endsWith(u8, path, "/ansi")) { │ │155 + return handleAnsi(io, request, allocator, path[0 .. path.len - "/ansi".len], admin_repo_path, session_store); │ │156 } │ │157 │ │158 const get_or_head = method == .GET or method == .HEAD; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────╮ │239 io: std.Io, │ │240 request: *std.http.Server.Request,│ │241 allocator: std.mem.Allocator, │ │242 + base: []const u8, │ │243 admin_repo_path: []const u8, │ │244 session_store: SessionStore, │ │245 ) !void { │ ╰───────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │262 │ │263 const haxy_moment = try evt.currentMoment(evt.admin_repo_opts, &repo); │ │264 const result = try evt.User.verifyCredentials(evt.AdminDB, evt.admin_repo_opts.hash, haxy_moment, &arena, username, password);│ │265 + │ │266 + // on success, return to the page the login came from (base, or "/" at the │ │267 + // root); on failure, stay on its auth tab to surface the error. │ │268 + const success_location: []const u8 = if (base.len == 0) "/" else base; │ │269 + const failure_location = try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{base}); │ │270 │ │271 switch (result) { │ │272 .success => |user_id| { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │276 try request.respond("", .{ │ │277 .status = .see_other, │ │278 .extra_headers = &.{ │ │270 - .{ .name = "location", .value = ui.RoutablePage.url(.default) },│ │279 + .{ .name = "location", .value = success_location }, │ │280 .{ .name = "set-cookie", .value = cookie }, │ │281 }, │ │282 }); │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │285 try request.respond("", .{ │ │286 .status = .see_other, │ │287 .extra_headers = &.{ │ │279 - .{ .name = "location", .value = ui.RoutablePage.url(.auth) }, │ │288 + .{ .name = "location", .value = failure_location }, │ │289 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" },│ │290 }, │ │291 }); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │294 try request.respond("", .{ │ │295 .status = .see_other, │ │296 .extra_headers = &.{ │ │288 - .{ .name = "location", .value = ui.RoutablePage.url(.auth) }, │ │297 + .{ .name = "location", .value = failure_location }, │ │298 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" },│ │299 }, │ │300 }); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯