╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2255 .id = std.fmt.bytesToHex(user_id.*, .lower), │ │2256 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │2257 .author = .{ .name = event.name, .email = email }, │ │2258 + .event = .{ .user = event }, │ │2259 + }}); │ │2260 +} │ │2261 + │ │2262 +pub const KeyAction = enum { add, remove }; │ │2263 + │ │2264 +// add an authorized_keys line to the user's ssh keys, or remove the lines │ │2265 +// holding the same key. removing a key they don't hold writes nothing. │ │2266 +pub fn changeUserKey( │ │2267 + io: std.Io, │ │2268 + allocator: std.mem.Allocator, │ │2269 + users_dir: []const u8, │ │2270 + admin_repo: *rp.Repo(.xit, admin_repo_opts), │ │2271 + user_id: *const [event_id_size]u8, │ │2272 + action: KeyAction, │ │2273 + key_text: []const u8, │ │2274 +) !void { │ │2275 + const key = std.mem.trim(u8, key_text, " \t\r"); │ │2276 + // the keys are stored one per line │ │2277 + if (std.mem.indexOfScalar(u8, key, '\n') != null) return error.InvalidKey; │ │2278 + const fingerprint = User.fingerprintOfKey(key) orelse return error.InvalidKey; │ │2279 + // the key types the ssh server verifies │ │2280 + const key_type = std.mem.sliceTo(key, ' '); │ │2281 + if (action == .add and !std.mem.eql(u8, key_type, "ssh-ed25519") and !std.mem.eql(u8, key_type, "ssh-rsa")) return error.UnsupportedKey; │ │2282 + │ │2283 + var arena = std.heap.ArenaAllocator.init(allocator); │ │2284 + defer arena.deinit(); │ │2285 + const moment = try currentMoment(admin_repo_opts, admin_repo); │ │2286 + const user = (try User.readById(AdminDB, admin_repo_opts.hash, moment, &arena, user_id)) orelse return error.NotFound; │ │2287 + if (user.removed) return error.NotFound; │ │2288 + │ │2289 + var keys: std.ArrayList(u8) = .empty; │ │2290 + var held = false; │ │2291 + var lines = std.mem.splitScalar(u8, user.event.ssh_keys, '\n'); │ │2292 + while (lines.next()) |line| { │ │2293 + const line_fingerprint = User.fingerprintOfKey(line) orelse continue; │ │2294 + if (std.mem.eql(u8, &line_fingerprint, &fingerprint)) { │ │2295 + held = true; │ │2296 + if (action == .remove) continue; │ │2297 + } │ │2298 + if (keys.items.len > 0) try keys.append(arena.allocator(), '\n'); │ │2299 + try keys.appendSlice(arena.allocator(), line); │ │2300 + } │ │2301 + switch (action) { │ │2302 + .add => { │ │2303 + if (held) return error.KeyAdded; │ │2304 + if (keys.items.len > 0) try keys.append(arena.allocator(), '\n'); │ │2305 + try keys.appendSlice(arena.allocator(), key); │ │2306 + }, │ │2307 + .remove => if (!held) return, │ │2308 + } │ │2309 + │ │2310 + var event = user.event; │ │2311 + event.ssh_keys = keys.items; │ │2312 + try consume(.{ .server = .{ .users_dir = users_dir } }, .admin, .xit, admin_repo_opts, io, allocator, admin_repo, events_ref, &[_]EventWithId{.{│ │2313 + .id = std.fmt.bytesToHex(user_id.*, .lower), │ │2314 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │2315 + .author = .{ .name = event.name, .email = event.email }, │ │2316 .event = .{ .user = event }, │ │2317 }}); │ │2318 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/User.zig│ ╰──────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │142 │ │143 // the SHA256 fingerprint of one authorized_keys line, or null if the line is│ │144 // blank or not a parseable "<type> <base64> [comment]" entry │ │145 -fn fingerprintOfKey(line: []const u8) ?[ssh.fingerprint_len]u8 { │ │145 +pub fn fingerprintOfKey(line: []const u8) ?[ssh.fingerprint_len]u8 { │ │146 var it = std.mem.tokenizeAny(u8, line, " \t\r"); │ │147 _ = it.next() orelse return null; // key type │ │148 const blob_b64 = it.next() orelse return null; │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │35 pub const NewRepo = @import("./ui/NewRepo.zig"); │ │36 pub const NewUser = @import("./ui/NewUser.zig"); │ │37 pub const UserSettings = @import("./ui/UserSettings.zig"); │ │38 +pub const UserKeys = @import("./ui/UserKeys.zig"); │ │39 pub const UserNotifications = @import("./ui/UserNotifications.zig");│ │40 pub const UserLogin = @import("./ui/UserLogin.zig"); │ │41 pub const widget = @import("./ui/widget.zig"); │ ╰────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │216 │ │217 pub const EventsView = enum { active, removed }; │ │218 │ │218 - pub const UserView = enum { notifications, settings }; │ │219 + pub const UserView = enum { notifications, settings, keys }; │ │220 │ │221 // the user tab's sub tab and the notifications window it shows│ │222 pub const UserTab = struct { │ ╰────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1300 fn userTabUrl(arena: *std.heap.ArenaAllocator, prefix: []const u8, tab: UserTab) ![]const u8 {│ │1301 return switch (tab.view) { │ │1302 .settings => try arena.allocator().print("{s}/user/settings", .{prefix}), │ │1303 + .keys => try arena.allocator().print("{s}/user/keys", .{prefix}), │ │1304 .notifications => if (tab.start == 0) │ │1305 try arena.allocator().print("{s}/user/notifications", .{prefix}) │ │1306 else │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1505 } │ │1506 │ │1507 // the tail after a page's "user": none or "notifications" with an optional │ │1506 - // "start:<n>", "settings", or "new" │ │1508 + // "start:<n>", "settings", "keys", or "new" │ │1509 fn userTail(segments: *std.mem.SplitIterator(u8, .scalar)) ?union(enum) { new, tab: UserTab } {│ │1510 const word = segments.next() orelse return .{ .tab = .{} }; │ │1511 if (std.mem.eql(u8, word, "new")) return if (segments.next() == null) .new else null; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1512 return switch (std.meta.stringToEnum(UserView, word) orelse return null) { │ │1511 - .settings => if (segments.next() == null) .{ .tab = .{ .view = .settings } } else null, │ │1513 + inline .settings, .keys => |view| if (segments.next() == null) .{ .tab = .{ .view = view } } else null,│ │1514 .notifications => .{ .tab = .{ .start = listStart(segments) orelse return null } }, │ │1515 }; │ │1516 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │2322 │ │2323 pub const AssignmentFailure = enum { not_found, not_an_email, already_assigned };│ │2324 │ │2325 + pub const KeyFailure = enum { │ │2326 + invalid, │ │2327 + unsupported, │ │2328 + already_added, │ │2329 + │ │2330 + pub fn fromError(err: anyerror) ?KeyFailure { │ │2331 + return switch (err) { │ │2332 + error.InvalidKey => .invalid, │ │2333 + error.UnsupportedKey => .unsupported, │ │2334 + error.KeyAdded => .already_added, │ │2335 + else => null, │ │2336 + }; │ │2337 + } │ │2338 + }; │ │2339 + │ │2340 pub const UserFailure = enum { │ │2341 required_name, │ │2342 invalid_name, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │2378 failure: UserFailure, │ │2379 // passwords are never retained │ │2380 fields: struct { email: []const u8 },│ │2381 + }, │ │2382 + user_keys: struct { │ │2383 + failure: KeyFailure, │ │2384 + key: []const u8, │ │2385 }, │ │2386 repo: struct { │ │2387 failure: RepoFailure, │ ╰───────────────────────────────────────────────────────╯ ╭ changed ───────────────────╮ │src/ui/UserNotifications.zig│ ╰────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │29 next_start: ?usize, // the `start` for the "next" row, or null when this is the last window│ │30 // the sub tab the route names │ │31 view: ui.RoutablePage.UserView, │ │32 +ssh_keys: ui.UserKeys = .{}, │ │33 │ │34 const Self = @This(); │ │35 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯