╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2552 - self.data.editDraft(self.session, allocator, entry.id, title, tags, description, target_branch) catch return; │ │2590 + self.data.editDraft(self.session, allocator, entry.id, title, tags, description, target_branch) catch |err| switch (err) {│ │2591 + error.InvalidFields => { │ │2592 + try self.rememberThreadFeedback(.invalid_target_branch, title, tags, description, target_branch); │ │2593 + return; │ │2594 + }, │ │2595 + else => |other| return other, │ │2596 + }; │ │2597 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; │ │2598 try self.session.navigate(route); │ │2599 return; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │2601 } │ │2602 │ │2603 const id_bytes = try evt.parseEventId(entry.id); │ │2560 - const src = self.data.repo_source orelse return; │ │2604 const author = (try self.session.eventAuthor()) orelse return;│ │2605 │ │2606 switch (src.repo_kind) { │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2609 defer any_repo.deinit(io, allocator); │ │2610 switch (any_repo) { │ │2611 inline else => |*repo| if (supports_drafts) { │ │2569 - try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{│ │2612 + Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ │ │2613 .title = title, │ │2614 .tags = tags, │ │2615 .description = description, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2616 .target_branch = target_branch, │ │2574 - } }, author); │ │2617 + } }, author) catch |err| switch (err) { │ │2618 + error.InvalidFields => { │ │2619 + try self.rememberThreadFeedback(.invalid_target_branch, title, tags, description, target_branch);│ │2620 + return; │ │2621 + }, │ │2622 + else => |other| return other, │ │2623 + }; │ │2624 } else if (has_status) │ │2625 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ │ │2626 .title = title, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2635 │ │2636 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; │ │2637 try self.session.navigate(route); │ │2638 + } │ │2639 + │ │2640 + fn rememberThreadFeedback(self: *This, failure: FeedbackFailure, title: []const u8, tags: []const u8, description: []const u8, target_branch: []const u8) !void {│ │2641 + const aa = self.session.arena.allocator(); │ │2642 + self.session.data.form_feedback = @unionInit(ui.Session.FormFeedback, @tagName(feedback_tag), .{ │ │2643 + .failure = failure, │ │2644 + .fields = if (comptime supports_drafts) .{ │ │2645 + .title = try aa.dupe(u8, title), │ │2646 + .tags = try aa.dupe(u8, tags), │ │2647 + .description = try aa.dupe(u8, description), │ │2648 + .target_branch = try aa.dupe(u8, target_branch), │ │2649 + } else .{ │ │2650 + .title = try aa.dupe(u8, title), │ │2651 + .tags = try aa.dupe(u8, tags), │ │2652 + .description = try aa.dupe(u8, description), │ │2653 + }, │ │2654 + }); │ │2655 } │ │2656 │ │2657 fn submitConfirmation(self: *This, allocator: std.mem.Allocator, button_index: usize) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │17 // the session cookie a login or a claimed auto-login sets; both must scope │ │18 // it the same way. │ │19 const session_cookie_fmt = cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict";│ │20 -// flash cookie for surfacing the outcome of the most recent /login POST. │ │21 -// set on the failure redirect, read and immediately expired on the next │ │22 -// GET / so refreshing the page doesn't keep showing the error. │ │23 -const login_failure_cookie = "haxy_login_failure"; │ │24 -const sync_failure_cookie = "haxy_sync_failure"; │ │20 +const form_flash_cookie = "haxy_form_flash"; │ │21 +const local_flash_cookie = "haxy_local_flash"; │ │22 +const local_required_title_prefix = "required_title:"; │ │23 +const local_sync_failure_prefix = "sync:"; │ │24 │ │25 const Embed = struct { │ │26 path: []const u8, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │210 // accounts, so it is always logged out. │ │211 var user_id_buf: [evt.event_id_size]u8 = undefined; │ │212 var user_id: ?[]const u8 = null; │ │214 - var login_failure: ?ui.Home.Auth.Login.Failure = null; │ │213 + var form_arena = std.heap.ArenaAllocator.init(allocator); │ │214 + defer form_arena.deinit(); │ │215 + var form_feedback: ?ui.Session.FormFeedback = null; │ │216 + var form_cookie_seen = false; │ │217 + var local_flash_seen = false; │ │218 var sync_failure: ?[]const u8 = null; │ │219 var sync_failure_allocated: ?[]u8 = null; │ │220 defer if (sync_failure_allocated) |value| allocator.free(value);│ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │236 user_id = user_id_buf[0..evt.event_id_size]; │ │237 } │ │238 } │ │236 - login_failure = if (getCookieValue(request, login_failure_cookie)) |raw| │ │237 - if (std.mem.eql(u8, raw, "unknown_user")) │ │238 - .unknown_user │ │239 - else if (std.mem.eql(u8, raw, "wrong_password")) │ │240 - .wrong_password │ │241 - else │ │242 - null │ │243 - else │ │244 - null; │ │239 + if (getCookieValue(request, form_flash_cookie)) |token| { │ │240 + form_cookie_seen = true; │ │241 + form_feedback = remote.session_store.takeFlash(form_arena.allocator(), token); │ │242 + } │ │243 }, │ │246 - .local => if (getCookieValue(request, sync_failure_cookie)) |raw| { │ │247 - const value = try allocator.dupe(u8, raw); │ │248 - sync_failure_allocated = value; │ │249 - sync_failure = std.Uri.percentDecodeInPlace(value); │ │244 + .local => { │ │245 + if (getCookieValue(request, local_flash_cookie)) |raw| { │ │246 + local_flash_seen = true; │ │247 + if (std.mem.startsWith(u8, raw, local_required_title_prefix)) { │ │248 + const tag = std.meta.stringToEnum(std.meta.Tag(ui.Session.FormFeedback), raw[local_required_title_prefix.len..]);│ │249 + form_feedback = if (tag) |feedback_tag| switch (feedback_tag) { │ │250 + .issue => .{ .issue = .{ .failure = .required_title } }, │ │251 + .patch => .{ .patch = .{ .failure = .required_title } }, │ │252 + .discussion => .{ .discussion = .{ .failure = .required_title } }, │ │253 + else => null, │ │254 + } else null; │ │255 + } else if (std.mem.startsWith(u8, raw, local_sync_failure_prefix)) { │ │256 + const value = try allocator.dupe(u8, raw[local_sync_failure_prefix.len..]); │ │257 + sync_failure_allocated = value; │ │258 + sync_failure = std.Uri.percentDecodeInPlace(value); │ │259 + } │ │260 + } │ │261 }, │ │262 } │ │263 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────╮ │267 }; │ │268 const html = renderIndexHtml(io, allocator, host, .{│ │269 .user_id = user_id, │ │259 - .login_failure = login_failure, │ │270 + .form_feedback = form_feedback, │ │271 .sync_failure = sync_failure, │ │272 .current_page = current_page, │ │273 .is_local = host == .local, │ ╰─────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │286 }; │ │287 defer allocator.free(html); │ │288 │ │289 + const expired_form_cookie = if (form_cookie_seen) │ │290 + try std.fmt.allocPrint(allocator, form_flash_cookie ++ "=; Path={s}; Max-Age=0", .{path}) │ │291 + else │ │292 + null; │ │293 + defer if (expired_form_cookie) |cookie| allocator.free(cookie); │ │294 var header_buf: [4]std.http.Header = undefined; │ │295 var headers: std.ArrayList(std.http.Header) = .initBuffer(&header_buf); │ │296 headers.appendAssumeCapacity(.{ .name = "content-type", .value = "text/html; charset=utf-8" });│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯