Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add admin onboarding implementation plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1030
+1030
docs/plans/2025-12-01-admin-onboarding.md
··· 1 + # Admin Onboarding Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Replace ADMIN_DIDS env var with database-stored admin list, with onboarding flow for first admin setup. 6 + 7 + **Architecture:** On first boot (no admins configured), redirect all routes to `/onboarding`. User enters handle, completes OAuth, becomes first admin. Admin list stored in config table. Settings page allows admin management. 8 + 9 + **Tech Stack:** Gleam, Lustre, SQLite, wisp HTTP framework 10 + 11 + --- 12 + 13 + ## Task 1: Add Admin DID Repository Functions 14 + 15 + **Files:** 16 + - Modify: `server/src/database/repositories/config.gleam` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + No existing test file for config repository. Skip test for repository layer - will be covered by integration. 21 + 22 + **Step 2: Add get_admin_dids function** 23 + 24 + Add after line 83 in `server/src/database/repositories/config.gleam`: 25 + 26 + ```gleam 27 + import gleam/list 28 + import gleam/string 29 + 30 + /// Get admin DIDs from config 31 + pub fn get_admin_dids(conn: sqlight.Connection) -> List(String) { 32 + case get(conn, "admin_dids") { 33 + Ok(value) -> { 34 + value 35 + |> string.split(",") 36 + |> list.map(string.trim) 37 + |> list.filter(fn(did) { !string.is_empty(did) }) 38 + } 39 + Error(_) -> [] 40 + } 41 + } 42 + 43 + /// Add an admin DID to the list 44 + pub fn add_admin_did( 45 + conn: sqlight.Connection, 46 + did: String, 47 + ) -> Result(Nil, sqlight.Error) { 48 + let current = get_admin_dids(conn) 49 + case list.contains(current, did) { 50 + True -> Ok(Nil) // Already exists, idempotent 51 + False -> { 52 + let new_list = list.append(current, [did]) 53 + let value = string.join(new_list, ",") 54 + set(conn, "admin_dids", value) 55 + } 56 + } 57 + } 58 + 59 + pub type RemoveAdminError { 60 + LastAdminError 61 + NotFoundError 62 + DatabaseError(sqlight.Error) 63 + } 64 + 65 + /// Remove an admin DID from the list 66 + /// Returns error if trying to remove the last admin 67 + pub fn remove_admin_did( 68 + conn: sqlight.Connection, 69 + did: String, 70 + ) -> Result(List(String), RemoveAdminError) { 71 + let current = get_admin_dids(conn) 72 + case list.contains(current, did) { 73 + False -> Error(NotFoundError) 74 + True -> { 75 + let new_list = list.filter(current, fn(d) { d != did }) 76 + case new_list { 77 + [] -> Error(LastAdminError) 78 + _ -> { 79 + let value = string.join(new_list, ",") 80 + case set(conn, "admin_dids", value) { 81 + Ok(_) -> Ok(new_list) 82 + Error(err) -> Error(DatabaseError(err)) 83 + } 84 + } 85 + } 86 + } 87 + } 88 + } 89 + 90 + /// Check if a DID is an admin 91 + pub fn is_admin(conn: sqlight.Connection, did: String) -> Bool { 92 + let admins = get_admin_dids(conn) 93 + list.contains(admins, did) 94 + } 95 + 96 + /// Check if any admins are configured 97 + pub fn has_admins(conn: sqlight.Connection) -> Bool { 98 + case get_admin_dids(conn) { 99 + [] -> False 100 + _ -> True 101 + } 102 + } 103 + ``` 104 + 105 + **Step 3: Add imports at top of file** 106 + 107 + Add to existing imports: 108 + ```gleam 109 + import gleam/list 110 + import gleam/string 111 + ``` 112 + 113 + **Step 4: Build and verify** 114 + 115 + Run: `cd server && gleam build` 116 + Expected: Build succeeds 117 + 118 + **Step 5: Commit** 119 + 120 + ```bash 121 + git add server/src/database/repositories/config.gleam 122 + git commit -m "feat: add admin DID repository functions" 123 + ``` 124 + 125 + --- 126 + 127 + ## Task 2: Remove ADMIN_DIDS from Server Context 128 + 129 + **Files:** 130 + - Modify: `server/src/server.gleam` 131 + 132 + **Step 1: Remove admin_dids from Context type** 133 + 134 + In `server/src/server.gleam`, find the Context type definition (around line 50-60) and remove `admin_dids: List(String)`: 135 + 136 + Before: 137 + ```gleam 138 + pub type Context { 139 + Context( 140 + db: sqlight.Connection, 141 + external_base_url: String, 142 + plc_url: String, 143 + admin_dids: List(String), 144 + backfill_state: Subject(backfill_state.Message), 145 + config: Subject(config.Message), 146 + jetstream_consumer: Option(Subject(jetstream_consumer.ManagerMessage)), 147 + did_cache: Subject(did_cache.Message), 148 + oauth_signing_key: Option(String), 149 + oauth_supported_scopes: List(String), 150 + oauth_loopback_mode: Bool, 151 + ) 152 + } 153 + ``` 154 + 155 + After: 156 + ```gleam 157 + pub type Context { 158 + Context( 159 + db: sqlight.Connection, 160 + external_base_url: String, 161 + plc_url: String, 162 + backfill_state: Subject(backfill_state.Message), 163 + config: Subject(config.Message), 164 + jetstream_consumer: Option(Subject(jetstream_consumer.ManagerMessage)), 165 + did_cache: Subject(did_cache.Message), 166 + oauth_signing_key: Option(String), 167 + oauth_supported_scopes: List(String), 168 + oauth_loopback_mode: Bool, 169 + ) 170 + } 171 + ``` 172 + 173 + **Step 2: Remove ADMIN_DIDS env var parsing** 174 + 175 + Find and remove lines 347-356 (the ADMIN_DIDS parsing block): 176 + 177 + Remove: 178 + ```gleam 179 + // Parse ADMIN_DIDS from environment variable (comma-separated list) 180 + let admin_dids = case envoy.get("ADMIN_DIDS") { 181 + Ok(dids_str) -> { 182 + dids_str 183 + |> string.split(",") 184 + |> list.map(string.trim) 185 + |> list.filter(fn(did) { !string.is_empty(did) }) 186 + } 187 + Error(_) -> [] 188 + } 189 + ``` 190 + 191 + **Step 3: Remove admin_dids from Context construction** 192 + 193 + Find the Context construction (around line 438-451) and remove `admin_dids: admin_dids,`: 194 + 195 + Before: 196 + ```gleam 197 + let ctx = 198 + Context( 199 + db: db, 200 + external_base_url: external_base_url, 201 + plc_url: plc_url, 202 + admin_dids: admin_dids, 203 + backfill_state: backfill_state_subject, 204 + ... 205 + ) 206 + ``` 207 + 208 + After: 209 + ```gleam 210 + let ctx = 211 + Context( 212 + db: db, 213 + external_base_url: external_base_url, 214 + plc_url: plc_url, 215 + backfill_state: backfill_state_subject, 216 + ... 217 + ) 218 + ``` 219 + 220 + **Step 4: Build to find all places that now fail** 221 + 222 + Run: `cd server && gleam build` 223 + Expected: Build fails with errors showing where admin_dids is used 224 + 225 + **Step 5: Commit partial progress** 226 + 227 + ```bash 228 + git add server/src/server.gleam 229 + git commit -m "refactor: remove admin_dids from server Context" 230 + ``` 231 + 232 + --- 233 + 234 + ## Task 3: Update Settings Handler to Use Config Repository 235 + 236 + **Files:** 237 + - Modify: `server/src/handlers/settings.gleam` 238 + 239 + **Step 1: Update Context type to remove admin_dids** 240 + 241 + In `server/src/handlers/settings.gleam`, update the Context type: 242 + 243 + Before: 244 + ```gleam 245 + pub type Context { 246 + Context( 247 + db: sqlight.Connection, 248 + admin_dids: List(String), 249 + config: process.Subject(config.Message), 250 + jetstream_consumer: option.Option( 251 + process.Subject(jetstream_consumer.ManagerMessage), 252 + ), 253 + did_cache: process.Subject(did_cache.Message), 254 + ) 255 + } 256 + ``` 257 + 258 + After: 259 + ```gleam 260 + pub type Context { 261 + Context( 262 + db: sqlight.Connection, 263 + config: process.Subject(config.Message), 264 + jetstream_consumer: option.Option( 265 + process.Subject(jetstream_consumer.ManagerMessage), 266 + ), 267 + did_cache: process.Subject(did_cache.Message), 268 + ) 269 + } 270 + ``` 271 + 272 + **Step 2: Update admin check to use config repository** 273 + 274 + Change the handle function (around line 35-57): 275 + 276 + Before: 277 + ```gleam 278 + pub fn handle(req: wisp.Request, ctx: Context) -> wisp.Response { 279 + // Get current user from session 280 + let #(current_user, user_is_admin) = case 281 + session.get_current_user(req, ctx.db, ctx.did_cache) 282 + { 283 + Ok(#(did, handle, _access_token)) -> { 284 + let admin = is_admin(did, ctx.admin_dids) 285 + #(option.Some(#(did, handle)), admin) 286 + } 287 + Error(_) -> #(option.None, False) 288 + } 289 + ... 290 + } 291 + ``` 292 + 293 + After: 294 + ```gleam 295 + pub fn handle(req: wisp.Request, ctx: Context) -> wisp.Response { 296 + // Get current user from session 297 + let #(current_user, user_is_admin) = case 298 + session.get_current_user(req, ctx.db, ctx.did_cache) 299 + { 300 + Ok(#(did, handle, _access_token)) -> { 301 + let admin = config_repo.is_admin(ctx.db, did) 302 + #(option.Some(#(did, handle)), admin) 303 + } 304 + Error(_) -> #(option.None, False) 305 + } 306 + ... 307 + } 308 + ``` 309 + 310 + **Step 3: Remove the local is_admin function** 311 + 312 + Remove lines 158-160: 313 + ```gleam 314 + fn is_admin(did: String, admin_dids: List(String)) -> Bool { 315 + list.contains(admin_dids, did) 316 + } 317 + ``` 318 + 319 + **Step 4: Build and verify** 320 + 321 + Run: `cd server && gleam build` 322 + Expected: Build succeeds (or shows remaining errors to fix) 323 + 324 + **Step 5: Commit** 325 + 326 + ```bash 327 + git add server/src/handlers/settings.gleam 328 + git commit -m "refactor: settings handler uses config repo for admin check" 329 + ``` 330 + 331 + --- 332 + 333 + ## Task 4: Update Client GraphQL Schema Admin Checks 334 + 335 + **Files:** 336 + - Modify: `server/src/client_schema.gleam` 337 + 338 + **Step 1: Add config repository import** 339 + 340 + Add import at top: 341 + ```gleam 342 + import database/repositories/config as config_repo 343 + ``` 344 + 345 + **Step 2: Update is_admin helper function** 346 + 347 + Find the is_admin function (around lines 35-36) and update: 348 + 349 + Before: 350 + ```gleam 351 + fn is_admin(did: String, admin_dids: List(String)) -> Bool { 352 + list.contains(admin_dids, did) 353 + } 354 + ``` 355 + 356 + After: 357 + ```gleam 358 + fn is_admin(conn: sqlight.Connection, did: String) -> Bool { 359 + config_repo.is_admin(conn, did) 360 + } 361 + ``` 362 + 363 + **Step 3: Update all is_admin calls** 364 + 365 + Search for all uses of `is_admin(` and update them. Each call that was `is_admin(sess.did, admin_dids)` becomes `is_admin(conn, sess.did)`. 366 + 367 + Key locations to update: 368 + - Line 708: `let user_is_admin = is_admin(conn, sess.did)` 369 + - Line 783: `case is_admin(conn, sess.did) {` 370 + - Line 1012: `case is_admin(conn, sess.did) {` 371 + - Line 1058: `case is_admin(conn, sess.did) {` 372 + - Line 1258: `case is_admin(conn, sess.did) {` 373 + - Line 1423: `case is_admin(conn, sess.did) {` 374 + - Line 1544: `case is_admin(conn, sess.did) {` 375 + 376 + **Step 4: Remove admin_dids parameter from functions** 377 + 378 + Update function signatures that take admin_dids parameter: 379 + - `query_type` function (around line 695) 380 + - `mutation_type` function (around line 885) 381 + - `schema` function (around line 1575) 382 + 383 + Remove `admin_dids: List(String)` parameter from each. 384 + 385 + **Step 5: Build and verify** 386 + 387 + Run: `cd server && gleam build` 388 + Expected: Build succeeds 389 + 390 + **Step 6: Commit** 391 + 392 + ```bash 393 + git add server/src/client_schema.gleam 394 + git commit -m "refactor: client schema uses config repo for admin checks" 395 + ``` 396 + 397 + --- 398 + 399 + ## Task 5: Update Client GraphQL Handler 400 + 401 + **Files:** 402 + - Modify: `server/src/handlers/client_graphql.gleam` 403 + 404 + **Step 1: Remove admin_dids from handler parameters** 405 + 406 + Update the Context type and all function signatures to remove `admin_dids: List(String)`. 407 + 408 + **Step 2: Update schema calls** 409 + 410 + Remove admin_dids from calls to `client_schema.schema(...)`. 411 + 412 + **Step 3: Build and verify** 413 + 414 + Run: `cd server && gleam build` 415 + Expected: Build succeeds 416 + 417 + **Step 4: Commit** 418 + 419 + ```bash 420 + git add server/src/handlers/client_graphql.gleam 421 + git commit -m "refactor: client graphql handler removes admin_dids parameter" 422 + ``` 423 + 424 + --- 425 + 426 + ## Task 6: Update Server Router to Pass Correct Context 427 + 428 + **Files:** 429 + - Modify: `server/src/server.gleam` 430 + 431 + **Step 1: Update handler calls to remove admin_dids** 432 + 433 + Find all places where handlers are called with admin_dids and remove that parameter: 434 + - Settings handler call (around line 580) 435 + - Client GraphQL handler calls 436 + 437 + **Step 2: Build and verify** 438 + 439 + Run: `cd server && gleam build` 440 + Expected: Build succeeds with no errors 441 + 442 + **Step 3: Commit** 443 + 444 + ```bash 445 + git add server/src/server.gleam 446 + git commit -m "refactor: server router removes admin_dids from handler calls" 447 + ``` 448 + 449 + --- 450 + 451 + ## Task 7: Create Onboarding Handler 452 + 453 + **Files:** 454 + - Create: `server/src/handlers/onboarding.gleam` 455 + 456 + **Step 1: Create the onboarding handler file** 457 + 458 + ```gleam 459 + /// Onboarding handler 460 + /// GET /onboarding - Shows first-run setup page 461 + import database/repositories/config as config_repo 462 + import gleam/string_tree 463 + import sqlight 464 + import wisp 465 + 466 + pub type Context { 467 + Context(db: sqlight.Connection, external_base_url: String) 468 + } 469 + 470 + /// Handle GET /onboarding 471 + pub fn handle(req: wisp.Request, ctx: Context) -> wisp.Response { 472 + // If admins already exist, redirect to home 473 + case config_repo.has_admins(ctx.db) { 474 + True -> wisp.redirect("/") 475 + False -> render_onboarding_page(ctx.external_base_url) 476 + } 477 + } 478 + 479 + fn render_onboarding_page(external_base_url: String) -> wisp.Response { 480 + let html = 481 + string_tree.from_string( 482 + "<!DOCTYPE html> 483 + <html lang=\"en\"> 484 + <head> 485 + <meta charset=\"UTF-8\"> 486 + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"> 487 + <title>Welcome to Quickslice</title> 488 + <script src=\"https://cdn.tailwindcss.com\"></script> 489 + </head> 490 + <body class=\"bg-zinc-900 min-h-screen flex items-center justify-center\"> 491 + <div class=\"bg-zinc-800 rounded-lg p-8 max-w-md w-full mx-4 border border-zinc-700\"> 492 + <h1 class=\"text-2xl font-semibold text-zinc-200 mb-2 text-center\">Welcome to Quickslice</h1> 493 + <p class=\"text-zinc-400 text-sm mb-6 text-center\">Enter your Bluesky handle to become the first administrator.</p> 494 + <form method=\"POST\" action=\"/admin/oauth/authorize\" class=\"space-y-4\"> 495 + <input type=\"hidden\" name=\"onboarding\" value=\"true\" /> 496 + <div> 497 + <label for=\"login_hint\" class=\"block text-sm text-zinc-400 mb-2\">Bluesky Handle</label> 498 + <input 499 + type=\"text\" 500 + id=\"login_hint\" 501 + name=\"login_hint\" 502 + placeholder=\"handle.bsky.social\" 503 + required 504 + class=\"w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-300 placeholder-zinc-600 focus:outline-none focus:border-zinc-500\" 505 + /> 506 + </div> 507 + <button 508 + type=\"submit\" 509 + class=\"w-full px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors font-medium\" 510 + > 511 + Complete Setup 512 + </button> 513 + </form> 514 + <p class=\"text-zinc-500 text-xs mt-4 text-center\"> 515 + You'll be redirected to Bluesky to authenticate. 516 + </p> 517 + </div> 518 + </body> 519 + </html>", 520 + ) 521 + 522 + wisp.response(200) 523 + |> wisp.set_header("content-type", "text/html") 524 + |> wisp.set_body(wisp.Text(string_tree.to_string(html))) 525 + } 526 + ``` 527 + 528 + **Step 2: Build and verify** 529 + 530 + Run: `cd server && gleam build` 531 + Expected: Build succeeds 532 + 533 + **Step 3: Commit** 534 + 535 + ```bash 536 + git add server/src/handlers/onboarding.gleam 537 + git commit -m "feat: add onboarding page handler" 538 + ``` 539 + 540 + --- 541 + 542 + ## Task 8: Add Onboarding Route and Hard Block Middleware 543 + 544 + **Files:** 545 + - Modify: `server/src/server.gleam` 546 + 547 + **Step 1: Add import for onboarding handler** 548 + 549 + Add to imports: 550 + ```gleam 551 + import handlers/onboarding as onboarding_handler 552 + ``` 553 + 554 + **Step 2: Add onboarding middleware** 555 + 556 + Create a function to check onboarding state and wrap routes: 557 + 558 + ```gleam 559 + import database/repositories/config as config_repo 560 + 561 + /// Check if onboarding is required and redirect if needed 562 + fn require_onboarding_complete( 563 + req: wisp.Request, 564 + ctx: Context, 565 + next: fn() -> wisp.Response, 566 + ) -> wisp.Response { 567 + // Skip check for these paths 568 + let path = wisp.path_segments(req) 569 + let skip_paths = [ 570 + ["onboarding"], 571 + ["admin", "oauth", "authorize"], 572 + ["admin", "oauth", "callback"], 573 + ["static"], 574 + ["favicon.ico"], 575 + ] 576 + 577 + // Check if path starts with any skip path 578 + let should_skip = list.any(skip_paths, fn(skip) { 579 + list.take(path, list.length(skip)) == skip 580 + }) 581 + 582 + case should_skip { 583 + True -> next() 584 + False -> { 585 + case config_repo.has_admins(ctx.db) { 586 + True -> next() 587 + False -> wisp.redirect("/onboarding") 588 + } 589 + } 590 + } 591 + } 592 + ``` 593 + 594 + **Step 3: Add GET /onboarding route** 595 + 596 + In the request handler, add the route: 597 + 598 + ```gleam 599 + ["onboarding"] -> { 600 + case req.method { 601 + http.Get -> onboarding_handler.handle(req, onboarding_handler.Context( 602 + db: ctx.db, 603 + external_base_url: ctx.external_base_url, 604 + )) 605 + _ -> wisp.method_not_allowed([http.Get]) 606 + } 607 + } 608 + ``` 609 + 610 + **Step 4: Wrap routes with onboarding middleware** 611 + 612 + Apply `require_onboarding_complete` to the main route handler. 613 + 614 + **Step 5: Build and verify** 615 + 616 + Run: `cd server && gleam build` 617 + Expected: Build succeeds 618 + 619 + **Step 6: Commit** 620 + 621 + ```bash 622 + git add server/src/server.gleam 623 + git commit -m "feat: add onboarding route and hard block middleware" 624 + ``` 625 + 626 + --- 627 + 628 + ## Task 9: Update OAuth Callback to Store First Admin 629 + 630 + **Files:** 631 + - Modify: `server/src/handlers/admin_oauth_callback.gleam` 632 + 633 + **Step 1: Add config repository import** 634 + 635 + Add import: 636 + ```gleam 637 + import database/repositories/config as config_repo 638 + ``` 639 + 640 + **Step 2: Update process_callback to detect onboarding mode** 641 + 642 + After the successful session creation (around line 120, after `Ok(_) -> {`), before redirecting: 643 + 644 + ```gleam 645 + // Check if this is onboarding (no admins yet) 646 + // If so, add this user as the first admin 647 + case config_repo.has_admins(conn) { 648 + False -> { 649 + let _ = config_repo.add_admin_did(conn, did) 650 + wisp.log_info("[onboarding] First admin registered: " <> did) 651 + } 652 + True -> Nil 653 + } 654 + ``` 655 + 656 + **Step 3: Build and verify** 657 + 658 + Run: `cd server && gleam build` 659 + Expected: Build succeeds 660 + 661 + **Step 4: Test manually** 662 + 663 + 1. Delete the database to start fresh 664 + 2. Start the server 665 + 3. Visit http://localhost:8080/ 666 + 4. Should redirect to /onboarding 667 + 5. Enter a handle, complete OAuth 668 + 6. Should redirect to home as logged-in admin 669 + 670 + **Step 5: Commit** 671 + 672 + ```bash 673 + git add server/src/handlers/admin_oauth_callback.gleam 674 + git commit -m "feat: OAuth callback stores first admin during onboarding" 675 + ``` 676 + 677 + --- 678 + 679 + ## Task 10: Add Admin Management GraphQL Mutations 680 + 681 + **Files:** 682 + - Modify: `server/src/client_schema.gleam` 683 + 684 + **Step 1: Add admins query field** 685 + 686 + In the `query_type` function, add a field to return the admin list (only for admins): 687 + 688 + ```gleam 689 + field.new("admins") 690 + |> field.type_(graphql.list(graphql.non_null(graphql.string()))) 691 + |> field.resolve(fn(_parent, _args, ctx) { 692 + case ctx.session { 693 + None -> graphql.resolve_value(graphql.null()) 694 + Some(sess) -> { 695 + case is_admin(conn, sess.did) { 696 + False -> graphql.resolve_value(graphql.null()) 697 + True -> { 698 + let admins = config_repo.get_admin_dids(conn) 699 + graphql.resolve_value(graphql.list_value( 700 + list.map(admins, graphql.string_value) 701 + )) 702 + } 703 + } 704 + } 705 + } 706 + }) 707 + ``` 708 + 709 + **Step 2: Add addAdmin mutation** 710 + 711 + In the `mutation_type` function: 712 + 713 + ```gleam 714 + field.new("addAdmin") 715 + |> field.type_(graphql.list(graphql.non_null(graphql.string()))) 716 + |> field.arg(arg.new("did", graphql.non_null(graphql.string()))) 717 + |> field.resolve(fn(_parent, args, ctx) { 718 + case ctx.session { 719 + None -> graphql.resolve_error("Authentication required") 720 + Some(sess) -> { 721 + case is_admin(conn, sess.did) { 722 + False -> graphql.resolve_error("Admin access required") 723 + True -> { 724 + let did = case dict.get(args, "did") { 725 + Ok(graphql.StringValue(d)) -> d 726 + _ -> "" 727 + } 728 + // Validate DID format 729 + case string.starts_with(did, "did:") { 730 + False -> graphql.resolve_error("Invalid DID format") 731 + True -> { 732 + case config_repo.add_admin_did(conn, did) { 733 + Ok(_) -> { 734 + let admins = config_repo.get_admin_dids(conn) 735 + graphql.resolve_value(graphql.list_value( 736 + list.map(admins, graphql.string_value) 737 + )) 738 + } 739 + Error(_) -> graphql.resolve_error("Failed to add admin") 740 + } 741 + } 742 + } 743 + } 744 + } 745 + } 746 + } 747 + }) 748 + ``` 749 + 750 + **Step 3: Add removeAdmin mutation** 751 + 752 + ```gleam 753 + field.new("removeAdmin") 754 + |> field.type_(graphql.list(graphql.non_null(graphql.string()))) 755 + |> field.arg(arg.new("did", graphql.non_null(graphql.string()))) 756 + |> field.resolve(fn(_parent, args, ctx) { 757 + case ctx.session { 758 + None -> graphql.resolve_error("Authentication required") 759 + Some(sess) -> { 760 + case is_admin(conn, sess.did) { 761 + False -> graphql.resolve_error("Admin access required") 762 + True -> { 763 + let did = case dict.get(args, "did") { 764 + Ok(graphql.StringValue(d)) -> d 765 + _ -> "" 766 + } 767 + case config_repo.remove_admin_did(conn, did) { 768 + Ok(admins) -> { 769 + graphql.resolve_value(graphql.list_value( 770 + list.map(admins, graphql.string_value) 771 + )) 772 + } 773 + Error(config_repo.LastAdminError) -> 774 + graphql.resolve_error("Cannot remove the last admin") 775 + Error(config_repo.NotFoundError) -> 776 + graphql.resolve_error("DID not found in admin list") 777 + Error(config_repo.DatabaseError(_)) -> 778 + graphql.resolve_error("Database error") 779 + } 780 + } 781 + } 782 + } 783 + } 784 + }) 785 + ``` 786 + 787 + **Step 4: Build and verify** 788 + 789 + Run: `cd server && gleam build` 790 + Expected: Build succeeds 791 + 792 + **Step 5: Commit** 793 + 794 + ```bash 795 + git add server/src/client_schema.gleam 796 + git commit -m "feat: add admins query and addAdmin/removeAdmin mutations" 797 + ``` 798 + 799 + --- 800 + 801 + ## Task 11: Add Admin Management UI to Settings Page (Client) 802 + 803 + **Files:** 804 + - Modify: `client/src/pages/settings.gleam` 805 + 806 + **Step 1: Add admin management messages** 807 + 808 + Add to the `Msg` type: 809 + 810 + ```gleam 811 + // Admin management messages 812 + UpdateNewAdminDid(String) 813 + SubmitAddAdmin 814 + ConfirmRemoveAdmin(String) 815 + CancelRemoveAdmin 816 + SubmitRemoveAdmin 817 + ``` 818 + 819 + **Step 2: Add admin management state to Model** 820 + 821 + Add to Model: 822 + 823 + ```gleam 824 + new_admin_did: String, 825 + remove_confirm_admin_did: Option(String), 826 + admin_alert: Option(#(String, String)), 827 + ``` 828 + 829 + **Step 3: Update init() to initialize new fields** 830 + 831 + Add to init: 832 + 833 + ```gleam 834 + new_admin_did: "", 835 + remove_confirm_admin_did: None, 836 + admin_alert: None, 837 + ``` 838 + 839 + **Step 4: Add administrators section** 840 + 841 + Create new function `administrators_section`: 842 + 843 + ```gleam 844 + fn administrators_section(cache: Cache, model: Model) -> Element(Msg) { 845 + // Query admins from cache (will need new GraphQL query) 846 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 847 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 848 + element.text("Administrators"), 849 + ]), 850 + // Admin alert 851 + case model.admin_alert { 852 + Some(#(kind, message)) -> { 853 + let alert_kind = case kind { 854 + "success" -> alert.Success 855 + "error" -> alert.Error 856 + _ -> alert.Info 857 + } 858 + alert.alert(alert_kind, message) 859 + } 860 + None -> element.none() 861 + }, 862 + // Admin list (loaded from currentSession.admins or separate query) 863 + html.div([attribute.class("space-y-2 mb-4")], [ 864 + // TODO: Map over admins list and render each with remove button 865 + ]), 866 + // Add admin form 867 + html.div([attribute.class("flex gap-2")], [ 868 + html.input([ 869 + attribute.type_("text"), 870 + attribute.class( 871 + "font-mono px-3 py-2 text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded flex-1", 872 + ), 873 + attribute.placeholder("did:plc:..."), 874 + attribute.value(model.new_admin_did), 875 + event.on_input(UpdateNewAdminDid), 876 + ]), 877 + button.button( 878 + disabled: string.is_empty(model.new_admin_did), 879 + on_click: SubmitAddAdmin, 880 + text: "Add", 881 + ), 882 + ]), 883 + ]) 884 + } 885 + ``` 886 + 887 + **Step 5: Add section to view** 888 + 889 + In the view function, add `administrators_section(cache, model)` to the list of sections. 890 + 891 + **Step 6: Build client** 892 + 893 + Run: `cd client && gleam build` 894 + Expected: Build succeeds 895 + 896 + **Step 7: Commit** 897 + 898 + ```bash 899 + git add client/src/pages/settings.gleam 900 + git commit -m "feat: add administrators section to settings page" 901 + ``` 902 + 903 + --- 904 + 905 + ## Task 12: Add GetAdmins GraphQL Query to Client 906 + 907 + **Files:** 908 + - Create: `client/src/generated/queries/get_admins.gleam` 909 + - Modify: `client/src/quickslice_client.gleam` 910 + 911 + **Step 1: Create GetAdmins query module** 912 + 913 + ```gleam 914 + /// GetAdmins query - fetches the list of admin DIDs 915 + import gleam/dynamic/decode 916 + import gleam/list 917 + import gleam/result 918 + 919 + pub type GetAdminsResponse { 920 + GetAdminsResponse(admins: List(String)) 921 + } 922 + 923 + pub fn parse_get_admins_response( 924 + json: decode.Dynamic, 925 + ) -> Result(GetAdminsResponse, List(decode.DecodeError)) { 926 + let decoder = { 927 + use admins <- decode.field( 928 + "admins", 929 + decode.optional(decode.list(decode.string)), 930 + ) 931 + decode.success(GetAdminsResponse( 932 + admins: admins |> result.unwrap([]), 933 + )) 934 + } 935 + decode.run(json, decode.field("data", decoder)) 936 + } 937 + ``` 938 + 939 + **Step 2: Add admin mutations** 940 + 941 + Create mutation handlers in the main client for `addAdmin` and `removeAdmin`. 942 + 943 + **Step 3: Build client** 944 + 945 + Run: `cd client && gleam build` 946 + Expected: Build succeeds 947 + 948 + **Step 4: Commit** 949 + 950 + ```bash 951 + git add client/src/generated/queries/get_admins.gleam 952 + git add client/src/quickslice_client.gleam 953 + git commit -m "feat: add GetAdmins query and admin mutations to client" 954 + ``` 955 + 956 + --- 957 + 958 + ## Task 13: Remove ADMIN_DIDS from .env.example 959 + 960 + **Files:** 961 + - Modify: `server/.env.example` 962 + 963 + **Step 1: Remove ADMIN_DIDS line** 964 + 965 + Remove the ADMIN_DIDS line and comment from .env.example. 966 + 967 + **Step 2: Commit** 968 + 969 + ```bash 970 + git add server/.env.example 971 + git commit -m "chore: remove ADMIN_DIDS from .env.example" 972 + ``` 973 + 974 + --- 975 + 976 + ## Task 14: Final Integration Test 977 + 978 + **Step 1: Clean database and test full flow** 979 + 980 + ```bash 981 + cd server 982 + rm -f quickslice.db 983 + gleam run 984 + ``` 985 + 986 + **Step 2: Test onboarding flow** 987 + 988 + 1. Visit http://localhost:8080/ - should redirect to /onboarding 989 + 2. Enter handle, complete OAuth 990 + 3. Should be logged in as admin, redirected to home 991 + 992 + **Step 3: Test admin management** 993 + 994 + 1. Go to Settings page 995 + 2. Add a new admin DID 996 + 3. Try to remove yourself (should fail if only admin) 997 + 4. Verify new admin DID appears in list 998 + 999 + **Step 4: Test non-admin access** 1000 + 1001 + 1. Log out 1002 + 2. Log in as different (non-admin) user 1003 + 3. Verify they cannot access settings 1004 + 4. Verify they are not treated as admin 1005 + 1006 + **Step 5: Commit final state** 1007 + 1008 + ```bash 1009 + git add -A 1010 + git commit -m "feat: complete admin onboarding implementation" 1011 + ``` 1012 + 1013 + --- 1014 + 1015 + ## Summary of Files Changed 1016 + 1017 + **Modified:** 1018 + - `server/src/database/repositories/config.gleam` - Admin DID functions 1019 + - `server/src/server.gleam` - Remove admin_dids from Context, add onboarding middleware 1020 + - `server/src/handlers/settings.gleam` - Use config repo for admin check 1021 + - `server/src/handlers/admin_oauth_callback.gleam` - Store first admin during onboarding 1022 + - `server/src/handlers/client_graphql.gleam` - Remove admin_dids parameter 1023 + - `server/src/client_schema.gleam` - Admin queries/mutations, use config repo 1024 + - `client/src/pages/settings.gleam` - Add administrators section 1025 + - `client/src/quickslice_client.gleam` - Admin management handlers 1026 + - `server/.env.example` - Remove ADMIN_DIDS 1027 + 1028 + **Created:** 1029 + - `server/src/handlers/onboarding.gleam` - Onboarding page handler 1030 + - `client/src/generated/queries/get_admins.gleam` - GetAdmins query