🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: add ghost user and assign transcript there when we delete users

+104 -36
+78 -36
src/components/admin-users.ts
··· 80 80 border-color: var(--primary); 81 81 } 82 82 83 + .user-card.system { 84 + cursor: default; 85 + opacity: 0.8; 86 + } 87 + 88 + .user-card.system:hover { 89 + border-color: var(--secondary); 90 + } 91 + 83 92 .card-header { 84 93 display: flex; 85 94 justify-content: space-between; ··· 117 126 118 127 .admin-badge { 119 128 background: var(--accent); 129 + color: var(--white); 130 + padding: 0.5rem 1rem; 131 + border-radius: 4px; 132 + font-size: 0.75rem; 133 + font-weight: 600; 134 + text-transform: uppercase; 135 + } 136 + 137 + .system-badge { 138 + background: var(--paynes-gray); 120 139 color: var(--white); 121 140 padding: 0.5rem 1rem; 122 141 border-radius: 4px; ··· 555 574 } 556 575 557 576 private handleCardClick(userId: number, event: Event) { 558 - // Don't open modal if clicking on delete button, revoke button, or role select 577 + // Don't open modal for ghost user 578 + if (userId === 0) { 579 + return; 580 + } 581 + 582 + // Don't open modal if clicking on delete button, revoke button, sync button, or role select 559 583 if ( 560 584 (event.target as HTMLElement).closest(".delete-btn") || 561 585 (event.target as HTMLElement).closest(".revoke-btn") || 586 + (event.target as HTMLElement).closest(".sync-btn") || 562 587 (event.target as HTMLElement).closest(".role-select") 563 588 ) { 564 589 return; ··· 577 602 } 578 603 579 604 private get filteredUsers() { 580 - if (!this.searchQuery) return this.users; 581 - 582 605 const query = this.searchQuery.toLowerCase(); 583 - return this.users.filter( 606 + 607 + // Filter users based on search query 608 + let filtered = this.users.filter( 584 609 (u) => 585 610 u.email.toLowerCase().includes(query) || 586 611 u.name?.toLowerCase().includes(query), 587 612 ); 613 + 614 + // Hide ghost user unless specifically searched for 615 + if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) { 616 + filtered = filtered.filter(u => u.id !== 0); 617 + } 618 + 619 + return filtered; 588 620 } 589 621 590 622 override render() { ··· 619 651 <div class="users-grid"> 620 652 ${filtered.map( 621 653 (u) => html` 622 - <div class="user-card" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 654 + <div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 623 655 <div class="card-header"> 624 656 <div class="user-info"> 625 657 <img ··· 632 664 <div class="user-email">${u.email}</div> 633 665 </div> 634 666 </div> 635 - ${u.role === "admin" ? html`<span class="admin-badge">Admin</span>` : ""} 667 + ${u.id === 0 668 + ? html`<span class="system-badge">System</span>` 669 + : u.role === "admin" 670 + ? html`<span class="admin-badge">Admin</span>` 671 + : "" 672 + } 636 673 </div> 637 674 638 675 <div class="meta-row"> ··· 664 701 </div> 665 702 666 703 <div class="actions"> 667 - <select 668 - class="role-select" 669 - .value=${u.role} 670 - @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)} 671 - > 672 - <option value="user">User</option> 673 - <option value="admin">Admin</option> 674 - </select> 675 - <button 676 - class="sync-btn" 677 - ?disabled=${this.syncingSubscriptions.has(u.id)} 678 - @click=${(e: Event) => this.handleSyncSubscription(u.id, e)} 679 - title="Sync subscription status from Polar" 680 - > 681 - ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "🔄 Sync"} 682 - </button> 683 - <button 684 - class="revoke-btn" 685 - ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 686 - @click=${(e: Event) => { 687 - if (u.subscription_id) { 688 - this.handleRevokeClick(u.id, u.email, u.subscription_id, e); 689 - } 690 - }} 691 - > 692 - ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")} 693 - </button> 694 - <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}> 695 - ${this.getDeleteButtonText(u.id, "user")} 696 - </button> 704 + ${u.id === 0 705 + ? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>` 706 + : html` 707 + <select 708 + class="role-select" 709 + .value=${u.role} 710 + @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)} 711 + > 712 + <option value="user">User</option> 713 + <option value="admin">Admin</option> 714 + </select> 715 + <button 716 + class="sync-btn" 717 + ?disabled=${this.syncingSubscriptions.has(u.id)} 718 + @click=${(e: Event) => this.handleSyncSubscription(u.id, e)} 719 + title="Sync subscription status from Polar" 720 + > 721 + ${this.syncingSubscriptions.has(u.id) ? "Syncing..." : "🔄 Sync"} 722 + </button> 723 + <button 724 + class="revoke-btn" 725 + ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 726 + @click=${(e: Event) => { 727 + if (u.subscription_id) { 728 + this.handleRevokeClick(u.id, u.email, u.subscription_id, e); 729 + } 730 + }} 731 + > 732 + ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")} 733 + </button> 734 + <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, e)}> 735 + ${this.getDeleteButtonText(u.id, "user")} 736 + </button> 737 + ` 738 + } 697 739 </div> 698 740 </div> 699 741 `,
+9
src/db/schema.ts
··· 204 204 CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id); 205 205 `, 206 206 }, 207 + { 208 + version: 7, 209 + name: "Create ghost user for deleted accounts", 210 + sql: ` 211 + -- Create a ghost user account for orphaned transcriptions 212 + INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at) 213 + VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now')); 214 + `, 215 + }, 207 216 ]; 208 217 209 218 function getCurrentVersion(): number {
+17
src/lib/auth.ts
··· 183 183 } 184 184 185 185 export async function deleteUser(userId: number): Promise<void> { 186 + // Prevent deleting the ghost user 187 + if (userId === 0) { 188 + throw new Error("Cannot delete ghost user account"); 189 + } 190 + 186 191 // Get user's subscription if they have one 187 192 const subscription = db 188 193 .query<{ id: string }, [number]>( ··· 210 215 } 211 216 } 212 217 218 + // Reassign class transcriptions to ghost user (id=0) 219 + // Delete personal transcriptions (no class_id) 220 + db.run( 221 + "UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL", 222 + [userId], 223 + ); 224 + db.run( 225 + "DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", 226 + [userId], 227 + ); 228 + 229 + // Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members) 213 230 db.run("DELETE FROM users WHERE id = ?", [userId]); 214 231 } 215 232