A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

feat: add RBAC admin users and harden install/update flows

jack 26c9ab73 000f891e

+4164 -791
+79 -4
README.md
··· 47 47 ./install.sh --stop 48 48 ./install.sh --status 49 49 ./install.sh --port 3100 50 + ./install.sh --host 127.0.0.1 51 + ./install.sh --skip-native-rebuild 50 52 ``` 51 53 52 54 If you prefer full manual setup, skip to [Manual Setup](#manual-setup-technical). 55 + 56 + ## Linux VPS Without Domain (Secure HTTPS via Tailscale) 57 + 58 + If you host on a public VPS (Linux) and do not own a domain, use the server installer: 59 + 60 + ```bash 61 + chmod +x install-server.sh 62 + ./install-server.sh 63 + ``` 64 + 65 + What this does: 66 + 67 + - runs the normal app install/build/start flow 68 + - auto-selects a free local app port if your chosen/default port is already in use 69 + - forces the app to bind locally only (`HOST=127.0.0.1`) 70 + - installs and starts Tailscale if needed 71 + - configures `tailscale serve` on a free HTTPS port so your dashboard is reachable over Tailnet HTTPS 72 + - prints the final Tailnet URL to open from any device authenticated on your Tailscale account 73 + 74 + Optional non-interactive login: 75 + 76 + ```bash 77 + ./install-server.sh --auth-key <TS_AUTHKEY> 78 + ``` 79 + 80 + Optional fixed Tailscale HTTPS port: 81 + 82 + ```bash 83 + ./install-server.sh --https-port 443 84 + ``` 85 + 86 + Optional public exposure (internet) with Funnel: 87 + 88 + ```bash 89 + ./install-server.sh --funnel 90 + ``` 91 + 92 + Notes: 93 + 94 + - this does **not** replace or delete `install.sh`; it wraps server-hardening around it 95 + - normal updates still use `./update.sh` and keep your local `.env` values 96 + - if you already installed manually, this is still safe to run later 53 97 54 98 ## What This Project Does 55 99 ··· 235 279 236 280 `update.sh`: 237 281 238 - - pulls latest code 282 + - stashes local uncommitted changes before pull and restores them after update 283 + - pulls latest code (supports non-`origin` remotes and detached-head recovery) 239 284 - installs dependencies 240 - - rebuilds native modules 285 + - rebuilds native modules when Node ABI changed 241 286 - builds server + web dashboard 242 - - restarts PM2 process when PM2 is available 243 - - preserves local `config.json` with backup/restore 287 + - restarts existing runtime for PM2 **or** nohup mode 288 + - preserves local `config.json` and `.env` with backup/restore 289 + 290 + Useful update flags: 291 + 292 + ```bash 293 + ./update.sh --no-restart 294 + ./update.sh --skip-install --skip-build 295 + ./update.sh --remote origin --branch main 296 + ``` 244 297 245 298 ## Data, Config, and Security 246 299 ··· 253 306 Security notes: 254 307 255 308 - first registered dashboard user is admin 309 + - after bootstrap, only admins can create additional dashboard users 310 + - users can sign in with username or email 311 + - non-admin users only see mappings they created by default 312 + - admins can grant fine-grained permissions (view all mappings, manage groups, queue backfills, run-now, etc.) 313 + - only admins can view or edit Twitter/AI provider credentials 314 + - admin user management never exposes other users' password hashes in the UI 256 315 - if `JWT_SECRET` is missing, server falls back to an insecure default; set your own secret in `.env` 257 316 - prefer Bluesky app passwords (not your full account password) 317 + 318 + ### Multi-User Access Control 319 + 320 + - bootstrap account: 321 + - the first account created through the web UI becomes admin 322 + - open registration is automatically disabled after this 323 + - admin capabilities: 324 + - create, edit, reset password, and delete dashboard users 325 + - assign role (`admin` or `user`) and per-user permissions 326 + - filter the Accounts page by creator to review each user's mappings 327 + - deleting a user: 328 + - disables that user's mappings so crossposting stops 329 + - leaves already-published Bluesky posts untouched 330 + - self-service security: 331 + - every user can change their own password 332 + - users can change their own email after password verification 258 333 259 334 ## Development 260 335
+494
install-server.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + set -euo pipefail 4 + 5 + APP_NAME="tweets-2-bsky" 6 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 + cd "$SCRIPT_DIR" 8 + 9 + ENV_FILE="$SCRIPT_DIR/.env" 10 + APP_PORT="" 11 + TS_HTTPS_PORT="" 12 + TS_AUTHKEY="" 13 + TS_HOSTNAME="" 14 + USE_FUNNEL=0 15 + INSTALL_ARGS=() 16 + 17 + usage() { 18 + cat <<'USAGE' 19 + Usage: ./install-server.sh [options] 20 + 21 + Secure Linux VPS install for Tailscale-first access: 22 + - Runs regular app installer 23 + - Forces app bind to localhost only (HOST=127.0.0.1) 24 + - Installs and starts Tailscale if needed 25 + - Publishes app through Tailscale Serve (HTTPS on tailnet) 26 + 27 + Options: 28 + --port <number> App port (default: 3000; auto-adjusts if already in use) 29 + --https-port <number> Tailscale HTTPS serve port (auto-select if omitted) 30 + --auth-key <key> Tailscale auth key for non-interactive login 31 + --hostname <name> Optional Tailscale device hostname 32 + --funnel Also enable Tailscale Funnel (public internet) 33 + 34 + Install passthrough options (forwarded to ./install.sh): 35 + --no-start 36 + --start-only 37 + --pm2 38 + --nohup 39 + --skip-install 40 + --skip-build 41 + 42 + -h, --help Show this help 43 + USAGE 44 + } 45 + 46 + require_command() { 47 + local command_name="$1" 48 + if ! command -v "$command_name" >/dev/null 2>&1; then 49 + echo "Required command not found: $command_name" 50 + exit 1 51 + fi 52 + } 53 + 54 + is_valid_port() { 55 + local candidate="$1" 56 + [[ "$candidate" =~ ^[0-9]+$ ]] || return 1 57 + (( candidate >= 1 && candidate <= 65535 )) 58 + } 59 + 60 + is_local_port_free() { 61 + local port="$1" 62 + node -e ' 63 + const net = require("node:net"); 64 + const port = Number(process.argv[1]); 65 + const server = net.createServer(); 66 + server.once("error", () => process.exit(1)); 67 + server.once("listening", () => server.close(() => process.exit(0))); 68 + server.listen(port, "127.0.0.1"); 69 + ' "$port" >/dev/null 2>&1 70 + } 71 + 72 + find_next_free_local_port() { 73 + local start_port="$1" 74 + local port 75 + for port in $(seq "$start_port" 65535); do 76 + if is_local_port_free "$port"; then 77 + printf '%s\n' "$port" 78 + return 0 79 + fi 80 + done 81 + return 1 82 + } 83 + 84 + ensure_linux() { 85 + if [[ "$(uname -s)" != "Linux" ]]; then 86 + echo "install-server.sh currently supports Linux only." 87 + echo "Use ./install.sh on macOS or other environments." 88 + exit 1 89 + fi 90 + } 91 + 92 + ensure_sudo() { 93 + if [[ "$(id -u)" -eq 0 ]]; then 94 + return 95 + fi 96 + 97 + if ! command -v sudo >/dev/null 2>&1; then 98 + echo "sudo is required to install and configure Tailscale on this host." 99 + exit 1 100 + fi 101 + } 102 + 103 + run_as_root() { 104 + if [[ "$(id -u)" -eq 0 ]]; then 105 + "$@" 106 + return 107 + fi 108 + sudo "$@" 109 + } 110 + 111 + get_env_value() { 112 + local key="$1" 113 + if [[ ! -f "$ENV_FILE" ]]; then 114 + return 0 115 + fi 116 + local line 117 + line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)" 118 + if [[ -z "$line" ]]; then 119 + return 0 120 + fi 121 + printf '%s\n' "${line#*=}" 122 + } 123 + 124 + upsert_env_value() { 125 + local key="$1" 126 + local value="$2" 127 + touch "$ENV_FILE" 128 + local tmp_file 129 + tmp_file="$(mktemp)" 130 + awk -v key="$key" -v value="$value" ' 131 + BEGIN { updated = 0 } 132 + $0 ~ ("^" key "=") { 133 + print key "=" value 134 + updated = 1 135 + next 136 + } 137 + { print } 138 + END { 139 + if (!updated) { 140 + print key "=" value 141 + } 142 + } 143 + ' "$ENV_FILE" > "$tmp_file" 144 + mv "$tmp_file" "$ENV_FILE" 145 + } 146 + 147 + ensure_local_only_env() { 148 + local configured_port 149 + configured_port="$(get_env_value PORT)" 150 + if [[ -n "$configured_port" && -z "${APP_PORT:-}" ]]; then 151 + APP_PORT="$configured_port" 152 + fi 153 + if [[ -z "${APP_PORT:-}" ]]; then 154 + APP_PORT="3000" 155 + fi 156 + 157 + if ! is_valid_port "$APP_PORT"; then 158 + echo "Invalid port: $APP_PORT" 159 + exit 1 160 + fi 161 + 162 + if ! is_local_port_free "$APP_PORT"; then 163 + local requested_port="$APP_PORT" 164 + APP_PORT="$(find_next_free_local_port "$APP_PORT" || true)" 165 + if [[ -z "$APP_PORT" ]]; then 166 + echo "Could not find a free local port for the app." 167 + exit 1 168 + fi 169 + echo "⚠️ App port ${requested_port} is already in use. Using ${APP_PORT} instead." 170 + fi 171 + 172 + upsert_env_value PORT "$APP_PORT" 173 + upsert_env_value HOST "127.0.0.1" 174 + } 175 + 176 + run_app_install() { 177 + local install_cmd=(bash "$SCRIPT_DIR/install.sh" --port "$APP_PORT") 178 + install_cmd+=("${INSTALL_ARGS[@]}") 179 + "${install_cmd[@]}" 180 + } 181 + 182 + install_tailscale_if_needed() { 183 + if command -v tailscale >/dev/null 2>&1; then 184 + echo "✅ Tailscale already installed." 185 + return 186 + fi 187 + 188 + echo "📦 Installing Tailscale..." 189 + if command -v curl >/dev/null 2>&1; then 190 + run_as_root bash -c 'curl -fsSL https://tailscale.com/install.sh | sh' 191 + elif command -v wget >/dev/null 2>&1; then 192 + run_as_root bash -c 'wget -qO- https://tailscale.com/install.sh | sh' 193 + else 194 + echo "Need curl or wget to install Tailscale automatically." 195 + exit 1 196 + fi 197 + 198 + if ! command -v tailscale >/dev/null 2>&1; then 199 + echo "Tailscale installation did not complete successfully." 200 + exit 1 201 + fi 202 + } 203 + 204 + ensure_tailscaled_running() { 205 + echo "🔧 Ensuring tailscaled is running..." 206 + if command -v systemctl >/dev/null 2>&1; then 207 + run_as_root systemctl enable --now tailscaled 208 + return 209 + fi 210 + 211 + if command -v rc-service >/dev/null 2>&1; then 212 + run_as_root rc-service tailscaled start || true 213 + if command -v rc-update >/dev/null 2>&1; then 214 + run_as_root rc-update add tailscaled default || true 215 + fi 216 + return 217 + fi 218 + 219 + if command -v service >/dev/null 2>&1; then 220 + run_as_root service tailscaled start || true 221 + return 222 + fi 223 + 224 + echo "Could not detect init system to start tailscaled automatically." 225 + echo "Please start tailscaled manually, then re-run this script." 226 + exit 1 227 + } 228 + 229 + ensure_tailscale_connected() { 230 + if run_as_root tailscale ip -4 >/dev/null 2>&1; then 231 + echo "✅ Tailscale is already connected." 232 + return 233 + fi 234 + 235 + echo "🔐 Connecting this host to Tailscale..." 236 + local up_cmd=(tailscale up) 237 + 238 + if [[ -n "$TS_AUTHKEY" ]]; then 239 + up_cmd+=(--authkey "$TS_AUTHKEY") 240 + fi 241 + 242 + if [[ -n "$TS_HOSTNAME" ]]; then 243 + up_cmd+=(--hostname "$TS_HOSTNAME") 244 + fi 245 + 246 + if ! run_as_root "${up_cmd[@]}"; then 247 + echo "Failed to connect to Tailscale." 248 + if [[ -z "$TS_AUTHKEY" ]]; then 249 + echo "Tip: provide --auth-key for non-interactive server setup." 250 + fi 251 + exit 1 252 + fi 253 + } 254 + 255 + get_used_tailscale_https_ports() { 256 + local json 257 + json="$(run_as_root tailscale serve status --json 2>/dev/null || true)" 258 + if [[ -z "$json" || "$json" == "{}" ]]; then 259 + return 0 260 + fi 261 + 262 + printf '%s' "$json" | node -e ' 263 + const fs = require("node:fs"); 264 + try { 265 + const data = JSON.parse(fs.readFileSync(0, "utf8")); 266 + const sets = [data?.Web, data?.web, data?.TCP, data?.tcp]; 267 + const used = new Set(); 268 + for (const obj of sets) { 269 + if (!obj || typeof obj !== "object") continue; 270 + for (const key of Object.keys(obj)) { 271 + if (/^\d+$/.test(key)) used.add(Number(key)); 272 + } 273 + } 274 + process.stdout.write([...used].sort((a, b) => a - b).join("\n")); 275 + } catch {} 276 + ' 277 + } 278 + 279 + pick_tailscale_https_port() { 280 + local preferred="$1" 281 + local allow_used_preferred="${2:-0}" 282 + local used_ports 283 + used_ports="$(get_used_tailscale_https_ports || true)" 284 + 285 + local is_used=0 286 + if [[ -n "$preferred" ]]; then 287 + if ! is_valid_port "$preferred"; then 288 + echo "Invalid Tailscale HTTPS port: $preferred" 289 + exit 1 290 + fi 291 + if [[ -z "$used_ports" ]] || ! grep -qx "$preferred" <<<"$used_ports"; then 292 + printf '%s\n' "$preferred" 293 + return 0 294 + fi 295 + if [[ "$allow_used_preferred" -eq 1 ]]; then 296 + printf '%s\n' "$preferred" 297 + return 0 298 + fi 299 + is_used=1 300 + fi 301 + 302 + local candidate 303 + for candidate in 443 8443 9443; do 304 + if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then 305 + printf '%s\n' "$candidate" 306 + return 0 307 + fi 308 + done 309 + 310 + for candidate in $(seq 10000 65535); do 311 + if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then 312 + printf '%s\n' "$candidate" 313 + return 0 314 + fi 315 + done 316 + 317 + if [[ "$is_used" -eq 1 ]]; then 318 + echo "No free Tailscale HTTPS serve port available (preferred port is already used)." 319 + else 320 + echo "No free Tailscale HTTPS serve port available." 321 + fi 322 + exit 1 323 + } 324 + 325 + configure_tailscale_serve() { 326 + local preferred_https_port="$TS_HTTPS_PORT" 327 + local preferred_from_saved=0 328 + local saved_https_port 329 + saved_https_port="$(get_env_value TAILSCALE_HTTPS_PORT)" 330 + if [[ -z "$preferred_https_port" && -n "$saved_https_port" ]]; then 331 + preferred_https_port="$saved_https_port" 332 + preferred_from_saved=1 333 + fi 334 + 335 + TS_HTTPS_PORT="$(pick_tailscale_https_port "$preferred_https_port" "$preferred_from_saved")" 336 + upsert_env_value TAILSCALE_HTTPS_PORT "$TS_HTTPS_PORT" 337 + 338 + if [[ -n "$preferred_https_port" && "$preferred_https_port" != "$TS_HTTPS_PORT" ]]; then 339 + echo "⚠️ Tailscale HTTPS port ${preferred_https_port} is already used. Using ${TS_HTTPS_PORT}." 340 + fi 341 + 342 + echo "🌐 Configuring Tailscale Serve (HTTPS ${TS_HTTPS_PORT} -> localhost:${APP_PORT})..." 343 + if ! run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then 344 + run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}" 345 + fi 346 + 347 + if [[ "$USE_FUNNEL" -eq 1 ]]; then 348 + echo "⚠️ Enabling Tailscale Funnel (public internet exposure)..." 349 + if ! run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then 350 + run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}" 351 + fi 352 + fi 353 + } 354 + 355 + get_tailscale_dns_name() { 356 + local json 357 + json="$(run_as_root tailscale status --json 2>/dev/null || true)" 358 + if [[ -z "$json" ]]; then 359 + return 0 360 + fi 361 + 362 + printf '%s' "$json" | node -e ' 363 + const fs = require("node:fs"); 364 + try { 365 + const data = JSON.parse(fs.readFileSync(0, "utf8")); 366 + const dnsName = typeof data?.Self?.DNSName === "string" ? data.Self.DNSName : ""; 367 + process.stdout.write(dnsName.replace(/\.$/, "")); 368 + } catch {} 369 + ' 370 + } 371 + 372 + get_tailscale_ipv4() { 373 + run_as_root tailscale ip -4 2>/dev/null | head -n 1 || true 374 + } 375 + 376 + print_summary() { 377 + local dns_name 378 + dns_name="$(get_tailscale_dns_name)" 379 + local ts_ip 380 + ts_ip="$(get_tailscale_ipv4)" 381 + local final_url="" 382 + local port_suffix="" 383 + if [[ "$TS_HTTPS_PORT" != "443" ]]; then 384 + port_suffix=":${TS_HTTPS_PORT}" 385 + fi 386 + if [[ -n "$dns_name" ]]; then 387 + final_url="https://${dns_name}${port_suffix}" 388 + elif [[ -n "$ts_ip" ]]; then 389 + final_url="https://${ts_ip}${port_suffix}" 390 + fi 391 + 392 + echo "" 393 + echo "Setup complete for Linux server mode." 394 + echo "" 395 + echo "App binding:" 396 + echo " HOST=127.0.0.1 (local-only)" 397 + echo " PORT=${APP_PORT}" 398 + echo "" 399 + echo "Local checks on server:" 400 + echo " http://127.0.0.1:${APP_PORT}" 401 + echo "" 402 + echo "Tailnet access:" 403 + if [[ -n "$final_url" ]]; then 404 + echo " ${final_url}" 405 + echo "" 406 + echo "✅ It will be accessible on ${final_url} wherever that person is authenticated on Tailscale." 407 + else 408 + echo " Run: sudo tailscale status" 409 + fi 410 + 411 + if [[ "$USE_FUNNEL" -eq 1 ]]; then 412 + echo "" 413 + echo "Public access is enabled via Funnel." 414 + else 415 + echo "" 416 + echo "Public internet exposure is disabled." 417 + fi 418 + 419 + echo "" 420 + echo "Useful commands:" 421 + echo " ./install.sh --status" 422 + echo " sudo tailscale serve status" 423 + if [[ "$USE_FUNNEL" -eq 1 ]]; then 424 + echo " sudo tailscale funnel status" 425 + fi 426 + } 427 + 428 + while [[ $# -gt 0 ]]; do 429 + case "$1" in 430 + --port) 431 + if [[ $# -lt 2 ]]; then 432 + echo "Missing value for --port" 433 + exit 1 434 + fi 435 + APP_PORT="$2" 436 + shift 437 + ;; 438 + --https-port) 439 + if [[ $# -lt 2 ]]; then 440 + echo "Missing value for --https-port" 441 + exit 1 442 + fi 443 + TS_HTTPS_PORT="$2" 444 + shift 445 + ;; 446 + --auth-key) 447 + if [[ $# -lt 2 ]]; then 448 + echo "Missing value for --auth-key" 449 + exit 1 450 + fi 451 + TS_AUTHKEY="$2" 452 + shift 453 + ;; 454 + --hostname) 455 + if [[ $# -lt 2 ]]; then 456 + echo "Missing value for --hostname" 457 + exit 1 458 + fi 459 + TS_HOSTNAME="$2" 460 + shift 461 + ;; 462 + --funnel) 463 + USE_FUNNEL=1 464 + ;; 465 + --pm2|--nohup|--skip-install|--skip-build|--no-start|--start-only) 466 + INSTALL_ARGS+=("$1") 467 + ;; 468 + -h|--help) 469 + usage 470 + exit 0 471 + ;; 472 + *) 473 + echo "Unknown option: $1" 474 + usage 475 + exit 1 476 + ;; 477 + esac 478 + shift 479 + done 480 + 481 + ensure_linux 482 + ensure_sudo 483 + require_command bash 484 + require_command node 485 + require_command npm 486 + require_command git 487 + 488 + ensure_local_only_env 489 + run_app_install 490 + install_tailscale_if_needed 491 + ensure_tailscaled_running 492 + ensure_tailscale_connected 493 + configure_tailscale_serve 494 + print_summary
+228 -46
install.sh
··· 3 3 set -euo pipefail 4 4 5 5 APP_NAME="tweets-2-bsky" 6 + LEGACY_APP_NAME="twitter-mirror" 6 7 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 8 cd "$SCRIPT_DIR" 8 9 ··· 10 11 RUNTIME_DIR="$SCRIPT_DIR/data/runtime" 11 12 PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid" 12 13 LOG_FILE="$RUNTIME_DIR/${APP_NAME}.log" 14 + LOCK_DIR="$RUNTIME_DIR/.install.lock" 13 15 14 16 ACTION="install" 15 17 DO_INSTALL=1 16 18 DO_BUILD=1 17 19 DO_START=1 20 + DO_NATIVE_REBUILD=1 18 21 RUNNER="auto" 19 22 PORT_OVERRIDE="" 23 + HOST_OVERRIDE="" 20 24 APP_PORT="" 25 + APP_HOST="" 21 26 ACTIVE_RUNNER="" 22 27 CREATED_JWT_SECRET=0 23 28 ··· 27 32 28 33 Default behavior: 29 34 - Installs dependencies 35 + - Rebuilds native modules if needed 30 36 - Builds server + web app 31 37 - Starts in the background (PM2 if installed, otherwise nohup) 32 38 - Prints local web URL 33 39 34 40 Options: 35 - --no-start Install/build only (do not start background process) 36 - --start-only Start background process only (skip install/build) 37 - --stop Stop background process (PM2 and/or nohup) 38 - --status Show background process status 39 - --pm2 Force PM2 runner 40 - --nohup Force nohup runner 41 - --port <number> Set or override PORT in .env 42 - --skip-install Skip npm install 43 - --skip-build Skip npm run build 44 - -h, --help Show this help 41 + --no-start Install/build only (do not start background process) 42 + --start-only Start background process only (skip install/build) 43 + --stop Stop background process (PM2 and/or nohup) 44 + --status Show background process status 45 + --pm2 Force PM2 runner 46 + --nohup Force nohup runner 47 + --port <number> Set or override PORT in .env 48 + --host <bind-host> Set or override HOST in .env (for example 127.0.0.1) 49 + --skip-install Skip npm install 50 + --skip-build Skip npm run build 51 + --skip-native-rebuild Skip native-module compatibility rebuild checks 52 + -h, --help Show this help 45 53 USAGE 46 54 } 47 55 ··· 59 67 (( candidate >= 1 && candidate <= 65535 )) 60 68 } 61 69 70 + check_node_version() { 71 + local node_major 72 + node_major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)" 73 + if [[ "$node_major" -lt 22 ]]; then 74 + echo "Node.js 22+ is required. Current: $(node -v 2>/dev/null || echo 'unknown')" 75 + exit 1 76 + fi 77 + } 78 + 79 + acquire_lock() { 80 + mkdir -p "$RUNTIME_DIR" 81 + if ! mkdir "$LOCK_DIR" 2>/dev/null; then 82 + echo "Another install/update operation appears to be running." 83 + echo "If this is stale, remove: $LOCK_DIR" 84 + exit 1 85 + fi 86 + } 87 + 88 + release_lock() { 89 + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true 90 + } 91 + 92 + cleanup() { 93 + release_lock 94 + } 95 + 62 96 get_env_value() { 63 97 local key="$1" 64 98 if [[ ! -f "$ENV_FILE" ]]; then ··· 115 149 upsert_env_value PORT "$APP_PORT" 116 150 fi 117 151 152 + local existing_host 153 + existing_host="$(get_env_value HOST)" 154 + if [[ -n "$HOST_OVERRIDE" ]]; then 155 + APP_HOST="$HOST_OVERRIDE" 156 + upsert_env_value HOST "$APP_HOST" 157 + elif [[ -n "$existing_host" ]]; then 158 + APP_HOST="$existing_host" 159 + else 160 + APP_HOST="0.0.0.0" 161 + fi 162 + 118 163 local existing_secret 119 164 existing_secret="$(get_env_value JWT_SECRET)" 120 165 if [[ -z "$existing_secret" ]]; then ··· 125 170 fi 126 171 } 127 172 173 + ensure_node_modules_present() { 174 + if [[ ! -d "$SCRIPT_DIR/node_modules" ]]; then 175 + echo "node_modules not found. Run ./install.sh (without --start-only) first." 176 + exit 1 177 + fi 178 + } 179 + 180 + native_module_compatible() { 181 + node -e "try{require('better-sqlite3');process.exit(0)}catch(e){console.error(e && e.message ? e.message : e);process.exit(1)}" >/dev/null 2>&1 182 + } 183 + 184 + run_native_rebuild() { 185 + echo "Verifying native modules for Node $(node -v)..." 186 + 187 + if npm run rebuild:native; then 188 + return 0 189 + fi 190 + 191 + echo "rebuild:native failed. Falling back to direct better-sqlite3 rebuild..." 192 + if npm rebuild better-sqlite3; then 193 + return 0 194 + fi 195 + 196 + npm rebuild better-sqlite3 --build-from-source 197 + } 198 + 199 + ensure_native_compatibility() { 200 + if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then 201 + return 0 202 + fi 203 + 204 + if native_module_compatible; then 205 + return 0 206 + fi 207 + 208 + echo "Detected native module mismatch (likely from Node version change)." 209 + run_native_rebuild 210 + 211 + if ! native_module_compatible; then 212 + echo "Native module validation still failed after rebuild." 213 + echo "Try reinstalling dependencies: rm -rf node_modules package-lock.json && npm install" 214 + exit 1 215 + fi 216 + } 217 + 128 218 ensure_build_artifacts() { 129 219 if [[ ! -f "$SCRIPT_DIR/dist/index.js" ]]; then 130 220 echo "Build output not found (dist/index.js). Running build now." ··· 135 225 install_and_build() { 136 226 if [[ "$DO_INSTALL" -eq 1 ]]; then 137 227 echo "Installing dependencies" 138 - npm install 228 + npm install --no-audit --no-fund 139 229 fi 140 230 231 + ensure_node_modules_present 232 + ensure_native_compatibility 233 + 141 234 if [[ "$DO_BUILD" -eq 1 ]]; then 142 235 echo "Building server and web app" 143 236 npm run build 144 237 fi 145 238 } 146 239 240 + pid_looks_like_app() { 241 + local pid="$1" 242 + local cmd 243 + cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" 244 + [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"npm start"* || "$cmd" == *"$APP_NAME"* ]] 245 + } 246 + 247 + stop_pid_gracefully() { 248 + local pid="$1" 249 + 250 + if ! kill -0 "$pid" >/dev/null 2>&1; then 251 + return 0 252 + fi 253 + 254 + kill "$pid" >/dev/null 2>&1 || true 255 + 256 + local attempt 257 + for attempt in $(seq 1 20); do 258 + if ! kill -0 "$pid" >/dev/null 2>&1; then 259 + return 0 260 + fi 261 + sleep 0.5 262 + done 263 + 264 + kill -9 "$pid" >/dev/null 2>&1 || true 265 + } 266 + 147 267 stop_nohup_if_running() { 148 268 if [[ ! -f "$PID_FILE" ]]; then 149 269 return 1 ··· 156 276 return 1 157 277 fi 158 278 159 - if kill -0 "$pid" >/dev/null 2>&1; then 160 - kill "$pid" >/dev/null 2>&1 || true 279 + if ! kill -0 "$pid" >/dev/null 2>&1; then 161 280 rm -f "$PID_FILE" 281 + return 1 282 + fi 283 + 284 + if ! pid_looks_like_app "$pid"; then 285 + echo "PID file points to a non-app process. Removing stale PID file: $PID_FILE" 286 + rm -f "$PID_FILE" 287 + return 1 288 + fi 289 + 290 + stop_pid_gracefully "$pid" 291 + rm -f "$PID_FILE" 292 + return 0 293 + } 294 + 295 + stop_pm2_if_running() { 296 + if ! command -v pm2 >/dev/null 2>&1; then 297 + return 1 298 + fi 299 + 300 + local stopped=0 301 + 302 + if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 303 + pm2 delete "$APP_NAME" >/dev/null 2>&1 || true 304 + stopped=1 305 + fi 306 + 307 + if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then 308 + pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true 309 + stopped=1 310 + fi 311 + 312 + if [[ "$stopped" -eq 1 ]]; then 313 + pm2 save >/dev/null 2>&1 || true 162 314 return 0 163 315 fi 164 316 165 - rm -f "$PID_FILE" 166 317 return 1 167 318 } 168 319 ··· 171 322 stop_nohup_if_running >/dev/null 2>&1 || true 172 323 173 324 echo "Starting with nohup" 174 - nohup npm start > "$LOG_FILE" 2>&1 & 325 + nohup npm start >> "$LOG_FILE" 2>&1 & 175 326 echo "$!" > "$PID_FILE" 176 327 177 328 local pid ··· 180 331 if ! kill -0 "$pid" >/dev/null 2>&1; then 181 332 echo "Failed to start background process with nohup." 182 333 echo "Check logs: $LOG_FILE" 334 + tail -n 40 "$LOG_FILE" 2>/dev/null || true 183 335 exit 1 184 336 fi 185 337 } 186 338 187 - stop_pm2_if_running() { 188 - if ! command -v pm2 >/dev/null 2>&1; then 189 - return 1 190 - fi 191 - 192 - if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 193 - pm2 delete "$APP_NAME" >/dev/null 2>&1 || true 194 - pm2 save >/dev/null 2>&1 || true 195 - return 0 196 - fi 197 - 198 - return 1 199 - } 200 - 201 339 start_with_pm2() { 202 340 echo "Starting with PM2" 203 341 204 - if pm2 describe "twitter-mirror" >/dev/null 2>&1; then 205 - pm2 delete "twitter-mirror" >/dev/null 2>&1 || true 342 + if pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then 343 + pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true 206 344 fi 207 345 208 346 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 209 347 pm2 restart "$APP_NAME" --update-env >/dev/null 2>&1 210 348 else 211 - pm2 start dist/index.js --name "$APP_NAME" --update-env >/dev/null 2>&1 349 + pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1 212 350 fi 351 + 213 352 pm2 save >/dev/null 2>&1 || true 214 353 } 215 354 ··· 241 380 } 242 381 243 382 wait_for_web() { 244 - if ! command -v curl >/dev/null 2>&1; then 245 - return 0 246 - fi 247 - 248 383 local url="http://127.0.0.1:${APP_PORT}" 249 384 local attempt 250 - for ((attempt = 1; attempt <= 30; attempt++)); do 251 - if curl -fsS "$url" >/dev/null 2>&1; then 252 - return 0 385 + 386 + for attempt in $(seq 1 30); do 387 + if command -v curl >/dev/null 2>&1; then 388 + if curl -fsS "$url" >/dev/null 2>&1; then 389 + return 0 390 + fi 391 + else 392 + if node -e "const http=require('http');const req=http.get('$url',res=>{process.exit(res.statusCode && res.statusCode < 500 ? 0 : 1)});req.setTimeout(1500,()=>{req.destroy();process.exit(1)});req.on('error',()=>process.exit(1));" >/dev/null 2>&1; then 393 + return 0 394 + fi 253 395 fi 254 396 sleep 1 255 397 done ··· 260 402 print_access_info() { 261 403 echo "" 262 404 echo "Setup complete." 263 - echo "Web app URL: http://localhost:${APP_PORT}" 405 + echo "Bind host: ${APP_HOST}" 406 + echo "Web app URL (local): http://localhost:${APP_PORT}" 264 407 265 408 if [[ "$CREATED_JWT_SECRET" -eq 1 ]]; then 266 409 echo "Generated JWT_SECRET in .env" 267 410 fi 268 411 412 + if [[ "$APP_HOST" == "127.0.0.1" || "$APP_HOST" == "::1" || "$APP_HOST" == "localhost" ]]; then 413 + echo "Access scope: local-only bind (use reverse proxy or Tailscale for remote access)" 414 + else 415 + echo "Access scope: network-accessible bind" 416 + fi 417 + 269 418 if [[ "$ACTIVE_RUNNER" == "pm2" ]]; then 270 419 echo "Process manager: PM2" 271 420 echo "Status: pm2 status $APP_NAME" 272 421 echo "Logs: pm2 logs $APP_NAME" 273 - echo "Stop: pm2 delete $APP_NAME" 422 + echo "Stop: ./install.sh --stop" 274 423 elif [[ "$ACTIVE_RUNNER" == "nohup" ]]; then 275 424 echo "Process manager: nohup" 276 425 echo "PID file: $PID_FILE" ··· 288 437 show_status() { 289 438 local found=0 290 439 local configured_port 440 + local configured_host 291 441 configured_port="$(get_env_value PORT)" 442 + configured_host="$(get_env_value HOST)" 443 + 292 444 if [[ -n "$configured_port" ]]; then 293 445 echo "Configured PORT: $configured_port" 294 446 fi 447 + if [[ -n "$configured_host" ]]; then 448 + echo "Configured HOST: $configured_host" 449 + fi 295 450 296 - if command -v pm2 >/dev/null 2>&1 && pm2 describe "$APP_NAME" >/dev/null 2>&1; then 297 - found=1 298 - echo "PM2 process is running:" 299 - pm2 status "$APP_NAME" 451 + if command -v pm2 >/dev/null 2>&1; then 452 + if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 453 + found=1 454 + echo "PM2 process is running: $APP_NAME" 455 + pm2 status "$APP_NAME" 456 + elif pm2 describe "$LEGACY_APP_NAME" >/dev/null 2>&1; then 457 + found=1 458 + echo "PM2 process is running: $LEGACY_APP_NAME" 459 + pm2 status "$LEGACY_APP_NAME" 460 + fi 300 461 fi 301 462 302 463 if [[ -f "$PID_FILE" ]]; then ··· 318 479 319 480 stop_all() { 320 481 local stopped=0 482 + 321 483 if stop_pm2_if_running; then 322 484 stopped=1 323 - echo "Stopped PM2 process: $APP_NAME" 485 + echo "Stopped PM2 process(es)." 324 486 fi 325 487 326 488 if stop_nohup_if_running; then ··· 364 526 PORT_OVERRIDE="$2" 365 527 shift 366 528 ;; 529 + --host) 530 + if [[ $# -lt 2 ]]; then 531 + echo "Missing value for --host" 532 + exit 1 533 + fi 534 + HOST_OVERRIDE="$2" 535 + shift 536 + ;; 367 537 --skip-install) 368 538 DO_INSTALL=0 369 539 ;; 370 540 --skip-build) 371 541 DO_BUILD=0 542 + ;; 543 + --skip-native-rebuild) 544 + DO_NATIVE_REBUILD=0 372 545 ;; 373 546 -h|--help) 374 547 usage ··· 385 558 386 559 case "$ACTION" in 387 560 stop) 561 + acquire_lock 562 + trap cleanup EXIT 388 563 stop_all 389 564 exit 0 390 565 ;; ··· 396 571 397 572 require_command node 398 573 require_command npm 574 + check_node_version 575 + 576 + acquire_lock 577 + trap cleanup EXIT 399 578 400 579 ensure_env_defaults 401 580 402 581 if [[ "$ACTION" == "install" ]]; then 403 582 install_and_build 583 + else 584 + ensure_node_modules_present 585 + ensure_native_compatibility 404 586 fi 405 587 406 588 if [[ "$DO_START" -eq 0 ]]; then
+364 -51
src/config-manager.ts
··· 1 + import { randomUUID } from 'node:crypto'; 1 2 import fs from 'node:fs'; 2 3 import path from 'node:path'; 3 4 import { fileURLToPath } from 'node:url'; ··· 14 15 backupCt0?: string; 15 16 } 16 17 18 + export interface UserPermissions { 19 + viewAllMappings: boolean; 20 + manageOwnMappings: boolean; 21 + manageAllMappings: boolean; 22 + manageGroups: boolean; 23 + queueBackfills: boolean; 24 + runNow: boolean; 25 + } 26 + 27 + export type UserRole = 'admin' | 'user'; 28 + 17 29 export interface WebUser { 18 - email: string; 30 + id: string; 31 + username?: string; 32 + email?: string; 19 33 passwordHash: string; 34 + role: UserRole; 35 + permissions: UserPermissions; 36 + createdAt: string; 37 + updatedAt: string; 20 38 } 21 39 22 40 export interface AIConfig { ··· 36 54 owner?: string; 37 55 groupName?: string; 38 56 groupEmoji?: string; 57 + createdByUserId?: string; 39 58 } 40 59 41 60 export interface AccountGroup { ··· 53 72 ai?: AIConfig; 54 73 } 55 74 75 + const DEFAULT_TWITTER_CONFIG: TwitterConfig = { 76 + authToken: '', 77 + ct0: '', 78 + }; 79 + 80 + export const DEFAULT_USER_PERMISSIONS: UserPermissions = { 81 + viewAllMappings: false, 82 + manageOwnMappings: true, 83 + manageAllMappings: false, 84 + manageGroups: false, 85 + queueBackfills: true, 86 + runNow: true, 87 + }; 88 + 89 + export const ADMIN_USER_PERMISSIONS: UserPermissions = { 90 + viewAllMappings: true, 91 + manageOwnMappings: true, 92 + manageAllMappings: true, 93 + manageGroups: true, 94 + queueBackfills: true, 95 + runNow: true, 96 + }; 97 + 98 + const DEFAULT_CONFIG: AppConfig = { 99 + twitter: DEFAULT_TWITTER_CONFIG, 100 + mappings: [], 101 + groups: [], 102 + users: [], 103 + checkIntervalMinutes: 5, 104 + }; 105 + 106 + const normalizeString = (value: unknown): string | undefined => { 107 + if (typeof value !== 'string') { 108 + return undefined; 109 + } 110 + const trimmed = value.trim(); 111 + return trimmed.length > 0 ? trimmed : undefined; 112 + }; 113 + 114 + const normalizeEmail = (value: unknown): string | undefined => { 115 + const normalized = normalizeString(value); 116 + return normalized ? normalized.toLowerCase() : undefined; 117 + }; 118 + 119 + const normalizeUsername = (value: unknown): string | undefined => { 120 + const normalized = normalizeString(value); 121 + if (!normalized) { 122 + return undefined; 123 + } 124 + return normalized.replace(/^@/, '').toLowerCase(); 125 + }; 126 + 127 + const normalizeRole = (value: unknown): UserRole | undefined => { 128 + if (value === 'admin' || value === 'user') { 129 + return value; 130 + } 131 + return undefined; 132 + }; 133 + 134 + const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 135 + if (typeof value === 'boolean') { 136 + return value; 137 + } 138 + return fallback; 139 + }; 140 + 141 + const normalizeUserPermissions = (value: unknown, role: UserRole): UserPermissions => { 142 + if (role === 'admin') { 143 + return { ...ADMIN_USER_PERMISSIONS }; 144 + } 145 + 146 + const defaults = { ...DEFAULT_USER_PERMISSIONS }; 147 + if (!value || typeof value !== 'object') { 148 + return defaults; 149 + } 150 + 151 + const record = value as Record<string, unknown>; 152 + return { 153 + viewAllMappings: normalizeBoolean(record.viewAllMappings, defaults.viewAllMappings), 154 + manageOwnMappings: normalizeBoolean(record.manageOwnMappings, defaults.manageOwnMappings), 155 + manageAllMappings: normalizeBoolean(record.manageAllMappings, defaults.manageAllMappings), 156 + manageGroups: normalizeBoolean(record.manageGroups, defaults.manageGroups), 157 + queueBackfills: normalizeBoolean(record.queueBackfills, defaults.queueBackfills), 158 + runNow: normalizeBoolean(record.runNow, defaults.runNow), 159 + }; 160 + }; 161 + 162 + export function getDefaultUserPermissions(role: UserRole): UserPermissions { 163 + return role === 'admin' ? { ...ADMIN_USER_PERMISSIONS } : { ...DEFAULT_USER_PERMISSIONS }; 164 + } 165 + 166 + const normalizeUser = (rawUser: unknown, index: number, fallbackNowIso: string): WebUser | null => { 167 + if (!rawUser || typeof rawUser !== 'object') { 168 + return null; 169 + } 170 + 171 + const record = rawUser as Record<string, unknown>; 172 + const passwordHash = normalizeString(record.passwordHash); 173 + if (!passwordHash) { 174 + return null; 175 + } 176 + 177 + const role = normalizeRole(record.role) ?? (index === 0 ? 'admin' : 'user'); 178 + const createdAt = normalizeString(record.createdAt) ?? fallbackNowIso; 179 + const updatedAt = normalizeString(record.updatedAt) ?? createdAt; 180 + 181 + return { 182 + id: normalizeString(record.id) ?? randomUUID(), 183 + username: normalizeUsername(record.username), 184 + email: normalizeEmail(record.email), 185 + passwordHash, 186 + role, 187 + permissions: normalizeUserPermissions(record.permissions, role), 188 + createdAt, 189 + updatedAt, 190 + }; 191 + }; 192 + 193 + const normalizeTwitterUsernames = (value: unknown, legacyValue: unknown): string[] => { 194 + const seen = new Set<string>(); 195 + const usernames: string[] = []; 196 + 197 + const addUsername = (candidate: unknown) => { 198 + const normalized = normalizeUsername(candidate); 199 + if (!normalized || seen.has(normalized)) { 200 + return; 201 + } 202 + seen.add(normalized); 203 + usernames.push(normalized); 204 + }; 205 + 206 + if (Array.isArray(value)) { 207 + for (const item of value) { 208 + addUsername(item); 209 + } 210 + } else if (typeof value === 'string') { 211 + for (const item of value.split(',')) { 212 + addUsername(item); 213 + } 214 + } 215 + 216 + if (usernames.length === 0) { 217 + addUsername(legacyValue); 218 + } 219 + 220 + return usernames; 221 + }; 222 + 223 + const normalizeGroup = (group: unknown): AccountGroup | null => { 224 + if (!group || typeof group !== 'object') { 225 + return null; 226 + } 227 + const record = group as Record<string, unknown>; 228 + const name = normalizeString(record.name); 229 + if (!name) { 230 + return null; 231 + } 232 + const emoji = normalizeString(record.emoji); 233 + return { 234 + name, 235 + ...(emoji ? { emoji } : {}), 236 + }; 237 + }; 238 + 239 + const findAdminUserId = (users: WebUser[]): string | undefined => users.find((user) => user.role === 'admin')?.id; 240 + 241 + const matchOwnerToUserId = (owner: string | undefined, users: WebUser[]): string | undefined => { 242 + if (!owner) { 243 + return undefined; 244 + } 245 + 246 + const normalizedOwner = owner.trim().toLowerCase(); 247 + if (!normalizedOwner) { 248 + return undefined; 249 + } 250 + 251 + return users.find((user) => { 252 + const username = user.username?.toLowerCase(); 253 + const email = user.email?.toLowerCase(); 254 + const emailLocalPart = email?.split('@')[0]; 255 + return normalizedOwner === username || normalizedOwner === email || normalizedOwner === emailLocalPart; 256 + })?.id; 257 + }; 258 + 259 + const normalizeMapping = (rawMapping: unknown, users: WebUser[], adminUserId?: string): AccountMapping | null => { 260 + if (!rawMapping || typeof rawMapping !== 'object') { 261 + return null; 262 + } 263 + 264 + const record = rawMapping as Record<string, unknown>; 265 + const bskyIdentifier = normalizeString(record.bskyIdentifier); 266 + if (!bskyIdentifier) { 267 + return null; 268 + } 269 + 270 + const owner = normalizeString(record.owner); 271 + const usernames = normalizeTwitterUsernames(record.twitterUsernames, record.twitterUsername); 272 + const explicitCreator = normalizeString(record.createdByUserId) ?? normalizeString(record.ownerUserId); 273 + const explicitCreatorExists = explicitCreator && users.some((user) => user.id === explicitCreator); 274 + 275 + return { 276 + id: normalizeString(record.id) ?? randomUUID(), 277 + twitterUsernames: usernames, 278 + bskyIdentifier: bskyIdentifier.toLowerCase(), 279 + bskyPassword: normalizeString(record.bskyPassword) ?? '', 280 + bskyServiceUrl: normalizeString(record.bskyServiceUrl) ?? 'https://bsky.social', 281 + enabled: normalizeBoolean(record.enabled, true), 282 + owner, 283 + groupName: normalizeString(record.groupName), 284 + groupEmoji: normalizeString(record.groupEmoji), 285 + createdByUserId: 286 + (explicitCreatorExists ? explicitCreator : undefined) ?? matchOwnerToUserId(owner, users) ?? adminUserId, 287 + }; 288 + }; 289 + 290 + const normalizeUsers = (rawUsers: unknown): WebUser[] => { 291 + if (!Array.isArray(rawUsers)) { 292 + return []; 293 + } 294 + 295 + const fallbackNowIso = new Date().toISOString(); 296 + const normalized = rawUsers 297 + .map((user, index) => normalizeUser(user, index, fallbackNowIso)) 298 + .filter((user): user is WebUser => user !== null); 299 + 300 + const usedIds = new Set<string>(); 301 + for (const user of normalized) { 302 + if (usedIds.has(user.id)) { 303 + user.id = randomUUID(); 304 + } 305 + usedIds.add(user.id); 306 + } 307 + 308 + const firstUser = normalized[0]; 309 + if (firstUser && !normalized.some((user) => user.role === 'admin')) { 310 + firstUser.role = 'admin'; 311 + firstUser.permissions = { ...ADMIN_USER_PERMISSIONS }; 312 + firstUser.updatedAt = new Date().toISOString(); 313 + } 314 + 315 + for (const user of normalized) { 316 + if (user.role === 'admin') { 317 + user.permissions = { ...ADMIN_USER_PERMISSIONS }; 318 + } 319 + } 320 + 321 + return normalized; 322 + }; 323 + 324 + const normalizeAiConfig = (rawAi: unknown): AIConfig | undefined => { 325 + if (!rawAi || typeof rawAi !== 'object') { 326 + return undefined; 327 + } 328 + const record = rawAi as Record<string, unknown>; 329 + const provider = record.provider; 330 + if (provider !== 'gemini' && provider !== 'openai' && provider !== 'anthropic' && provider !== 'custom') { 331 + return undefined; 332 + } 333 + 334 + const apiKey = normalizeString(record.apiKey); 335 + const model = normalizeString(record.model); 336 + const baseUrl = normalizeString(record.baseUrl); 337 + return { 338 + provider, 339 + ...(apiKey ? { apiKey } : {}), 340 + ...(model ? { model } : {}), 341 + ...(baseUrl ? { baseUrl } : {}), 342 + }; 343 + }; 344 + 345 + const normalizeConfigShape = (rawConfig: unknown): AppConfig => { 346 + if (!rawConfig || typeof rawConfig !== 'object') { 347 + return { ...DEFAULT_CONFIG }; 348 + } 349 + 350 + const record = rawConfig as Record<string, unknown>; 351 + const rawTwitter = 352 + record.twitter && typeof record.twitter === 'object' ? (record.twitter as Record<string, unknown>) : {}; 353 + const users = normalizeUsers(record.users); 354 + const adminUserId = findAdminUserId(users); 355 + 356 + const mappings = Array.isArray(record.mappings) 357 + ? record.mappings 358 + .map((mapping) => normalizeMapping(mapping, users, adminUserId)) 359 + .filter((mapping): mapping is AccountMapping => mapping !== null) 360 + : []; 361 + 362 + const groups = Array.isArray(record.groups) 363 + ? record.groups.map(normalizeGroup).filter((group): group is AccountGroup => group !== null) 364 + : []; 365 + 366 + const seenGroups = new Set<string>(); 367 + const dedupedGroups = groups.filter((group) => { 368 + const key = group.name.toLowerCase(); 369 + if (seenGroups.has(key)) { 370 + return false; 371 + } 372 + seenGroups.add(key); 373 + return true; 374 + }); 375 + 376 + const checkIntervalCandidate = Number(record.checkIntervalMinutes); 377 + const checkIntervalMinutes = 378 + Number.isFinite(checkIntervalCandidate) && checkIntervalCandidate >= 1 ? Math.round(checkIntervalCandidate) : 5; 379 + 380 + const geminiApiKey = normalizeString(record.geminiApiKey); 381 + const ai = normalizeAiConfig(record.ai); 382 + 383 + return { 384 + twitter: { 385 + authToken: normalizeString(rawTwitter.authToken) ?? '', 386 + ct0: normalizeString(rawTwitter.ct0) ?? '', 387 + backupAuthToken: normalizeString(rawTwitter.backupAuthToken), 388 + backupCt0: normalizeString(rawTwitter.backupCt0), 389 + }, 390 + mappings, 391 + groups: dedupedGroups, 392 + users, 393 + checkIntervalMinutes, 394 + ...(geminiApiKey ? { geminiApiKey } : {}), 395 + ...(ai ? { ai } : {}), 396 + }; 397 + }; 398 + 399 + const writeConfigFile = (config: AppConfig) => { 400 + fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`); 401 + }; 402 + 56 403 export function getConfig(): AppConfig { 57 404 if (!fs.existsSync(CONFIG_FILE)) { 58 - return { 59 - twitter: { authToken: '', ct0: '' }, 60 - mappings: [], 61 - groups: [], 62 - users: [], 63 - checkIntervalMinutes: 5, 64 - }; 405 + return { ...DEFAULT_CONFIG }; 65 406 } 407 + 66 408 try { 67 - const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); 68 - if (!config.users) config.users = []; 69 - if (!Array.isArray(config.groups)) config.groups = []; 70 - return config; 409 + const rawText = fs.readFileSync(CONFIG_FILE, 'utf8'); 410 + const rawConfig = JSON.parse(rawText); 411 + const normalizedConfig = normalizeConfigShape(rawConfig); 412 + 413 + if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) { 414 + writeConfigFile(normalizedConfig); 415 + } 416 + 417 + return normalizedConfig; 71 418 } catch (err) { 72 419 console.error('Error reading config:', err); 73 - return { 74 - twitter: { authToken: '', ct0: '' }, 75 - mappings: [], 76 - groups: [], 77 - users: [], 78 - checkIntervalMinutes: 5, 79 - }; 420 + return { ...DEFAULT_CONFIG }; 80 421 } 81 422 } 423 + 82 424 export function saveConfig(config: AppConfig): void { 83 - // biome-ignore lint/suspicious/noExplicitAny: cleanup before save 84 - const configToSave = { ...config } as any; 85 - 86 - // Remove legacy field from saved file 87 - configToSave.mappings = configToSave.mappings.map((m: any) => { 88 - const { twitterUsername, ...rest } = m; 89 - return rest; 90 - }); 91 - 92 - const groups = Array.isArray(configToSave.groups) ? configToSave.groups : []; 93 - const seenGroupNames = new Set<string>(); 94 - configToSave.groups = groups 95 - .map((group: any) => ({ 96 - name: typeof group?.name === 'string' ? group.name.trim() : '', 97 - emoji: typeof group?.emoji === 'string' ? group.emoji.trim() : '', 98 - })) 99 - .filter((group: any) => group.name.length > 0) 100 - .filter((group: any) => { 101 - const key = group.name.toLowerCase(); 102 - if (seenGroupNames.has(key)) { 103 - return false; 104 - } 105 - seenGroupNames.add(key); 106 - return true; 107 - }) 108 - .map((group: any) => ({ 109 - name: group.name, 110 - ...(group.emoji ? { emoji: group.emoji } : {}), 111 - })); 112 - 113 - fs.writeFileSync(CONFIG_FILE, JSON.stringify(configToSave, null, 2)); 425 + const normalizedConfig = normalizeConfigShape(config); 426 + writeConfigFile(normalizedConfig); 114 427 } 115 428 116 429 export function addMapping(mapping: Omit<AccountMapping, 'id' | 'enabled'>): void { 117 430 const config = getConfig(); 118 431 const newMapping: AccountMapping = { 119 432 ...mapping, 120 - id: Math.random().toString(36).substring(7), 433 + id: randomUUID(), 121 434 enabled: true, 122 435 }; 123 436 config.mappings.push(newMapping);
+1050 -138
src/server.ts
··· 1 + import { execSync, spawn } from 'node:child_process'; 2 + import { randomUUID } from 'node:crypto'; 1 3 import fs from 'node:fs'; 2 4 import path from 'node:path'; 3 5 import { fileURLToPath } from 'node:url'; 4 - import { execSync, spawn } from 'node:child_process'; 5 6 import axios from 'axios'; 6 7 import bcrypt from 'bcryptjs'; 7 8 import cors from 'cors'; 8 9 import express from 'express'; 9 10 import jwt from 'jsonwebtoken'; 10 11 import { deleteAllPosts } from './bsky.js'; 11 - import { getConfig, saveConfig, type AppConfig } from './config-manager.js'; 12 + import { 13 + ADMIN_USER_PERMISSIONS, 14 + type AccountMapping, 15 + type AppConfig, 16 + type UserPermissions, 17 + type UserRole, 18 + type WebUser, 19 + getConfig, 20 + getDefaultUserPermissions, 21 + saveConfig, 22 + } from './config-manager.js'; 12 23 import { dbService } from './db.js'; 13 24 import type { ProcessedTweet } from './db.js'; 14 25 ··· 17 28 18 29 const app = express(); 19 30 const PORT = Number(process.env.PORT) || 3000; 31 + const HOST = (process.env.HOST || process.env.BIND_HOST || '0.0.0.0').trim() || '0.0.0.0'; 20 32 const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret'; 21 33 const APP_ROOT_DIR = path.join(__dirname, '..'); 22 34 const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); ··· 30 42 const PROFILE_CACHE_TTL_MS = 5 * 60_000; 31 43 const RESERVED_UNGROUPED_KEY = 'ungrouped'; 32 44 const SERVER_STARTED_AT = Date.now(); 45 + const PASSWORD_MIN_LENGTH = 8; 33 46 34 47 interface CacheEntry<T> { 35 48 value: T; ··· 176 189 config.groups = []; 177 190 } 178 191 179 - const existingIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName)); 192 + const existingIndex = config.groups.findIndex( 193 + (group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName), 194 + ); 180 195 const normalizedEmoji = normalizeGroupEmoji(emoji); 181 196 182 197 if (existingIndex === -1) { ··· 434 449 facets: Array.isArray(record.facets) ? record.facets : [], 435 450 author: { 436 451 did: typeof author.did === 'string' ? author.did : undefined, 437 - handle: 438 - typeof author.handle === 'string' && author.handle.length > 0 ? author.handle : activity.bsky_identifier, 452 + handle: typeof author.handle === 'string' && author.handle.length > 0 ? author.handle : activity.bsky_identifier, 439 453 displayName: typeof author.displayName === 'string' ? author.displayName : undefined, 440 454 avatar: typeof author.avatar === 'string' ? author.avatar : undefined, 441 455 }, ··· 488 502 489 503 app.use(express.static(staticAssetsDir)); 490 504 491 - // Middleware to protect routes 505 + interface AuthenticatedUser { 506 + id: string; 507 + username?: string; 508 + email?: string; 509 + isAdmin: boolean; 510 + permissions: UserPermissions; 511 + } 512 + 513 + interface MappingResponse extends Omit<AccountMapping, 'bskyPassword'> { 514 + createdByLabel?: string; 515 + createdByUser?: { 516 + id: string; 517 + username?: string; 518 + email?: string; 519 + role: UserRole; 520 + }; 521 + } 522 + 523 + interface UserSummaryResponse { 524 + id: string; 525 + username?: string; 526 + email?: string; 527 + role: UserRole; 528 + isAdmin: boolean; 529 + permissions: UserPermissions; 530 + createdAt: string; 531 + updatedAt: string; 532 + mappingCount: number; 533 + activeMappingCount: number; 534 + mappings: MappingResponse[]; 535 + } 536 + 537 + const normalizeEmail = (value: unknown): string | undefined => { 538 + if (typeof value !== 'string') { 539 + return undefined; 540 + } 541 + const normalized = value.trim().toLowerCase(); 542 + return normalized.length > 0 ? normalized : undefined; 543 + }; 544 + 545 + const normalizeUsername = (value: unknown): string | undefined => { 546 + if (typeof value !== 'string') { 547 + return undefined; 548 + } 549 + const normalized = value.trim().replace(/^@/, '').toLowerCase(); 550 + return normalized.length > 0 ? normalized : undefined; 551 + }; 552 + 553 + const normalizeOptionalString = (value: unknown): string | undefined => { 554 + if (typeof value !== 'string') { 555 + return undefined; 556 + } 557 + const normalized = value.trim(); 558 + return normalized.length > 0 ? normalized : undefined; 559 + }; 560 + 561 + const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 562 + if (typeof value === 'boolean') { 563 + return value; 564 + } 565 + return fallback; 566 + }; 567 + 568 + const EMAIL_LIKE_PATTERN = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/i; 569 + 570 + const getUserPublicLabel = (user: Pick<WebUser, 'id' | 'username'>): string => 571 + user.username || `user-${user.id.slice(0, 8)}`; 572 + 573 + const getUserDisplayLabel = (user: Pick<WebUser, 'id' | 'username' | 'email'>): string => 574 + user.username || user.email || `user-${user.id.slice(0, 8)}`; 575 + 576 + const getActorLabel = (actor: AuthenticatedUser): string => actor.username || actor.email || `user-${actor.id.slice(0, 8)}`; 577 + 578 + const getActorPublicLabel = (actor: AuthenticatedUser): string => actor.username || `user-${actor.id.slice(0, 8)}`; 579 + 580 + const sanitizeLabelForRequester = (label: string | undefined, requester: AuthenticatedUser): string | undefined => { 581 + if (!label) { 582 + return undefined; 583 + } 584 + if (requester.isAdmin) { 585 + return label; 586 + } 587 + return EMAIL_LIKE_PATTERN.test(label) ? 'private-user' : label; 588 + }; 589 + 590 + const createUserLookupById = (config: AppConfig): Map<string, WebUser> => 591 + new Map(config.users.map((user) => [user.id, user])); 592 + 593 + const toAuthenticatedUser = (user: WebUser): AuthenticatedUser => ({ 594 + id: user.id, 595 + username: user.username, 596 + email: user.email, 597 + isAdmin: user.role === 'admin', 598 + permissions: 599 + user.role === 'admin' 600 + ? { ...ADMIN_USER_PERMISSIONS } 601 + : { 602 + ...getDefaultUserPermissions('user'), 603 + ...user.permissions, 604 + }, 605 + }); 606 + 607 + const serializeAuthenticatedUser = (user: AuthenticatedUser) => ({ 608 + id: user.id, 609 + username: user.username, 610 + email: user.email, 611 + isAdmin: user.isAdmin, 612 + permissions: user.permissions, 613 + }); 614 + 615 + const issueTokenForUser = (user: WebUser): string => 616 + jwt.sign( 617 + { 618 + userId: user.id, 619 + email: user.email, 620 + username: user.username, 621 + }, 622 + JWT_SECRET, 623 + { expiresIn: '24h' }, 624 + ); 625 + 626 + const findUserByIdentifier = (config: AppConfig, identifier: string): WebUser | undefined => { 627 + const normalizedEmail = normalizeEmail(identifier); 628 + if (normalizedEmail) { 629 + const foundByEmail = config.users.find((user) => normalizeEmail(user.email) === normalizedEmail); 630 + if (foundByEmail) { 631 + return foundByEmail; 632 + } 633 + } 634 + 635 + const normalizedUsername = normalizeUsername(identifier); 636 + if (!normalizedUsername) { 637 + return undefined; 638 + } 639 + return config.users.find((user) => normalizeUsername(user.username) === normalizedUsername); 640 + }; 641 + 642 + const findUserFromTokenPayload = (config: AppConfig, payload: Record<string, unknown>): WebUser | undefined => { 643 + const tokenUserId = normalizeOptionalString(payload.userId) ?? normalizeOptionalString(payload.id); 644 + if (tokenUserId) { 645 + const byId = config.users.find((user) => user.id === tokenUserId); 646 + if (byId) { 647 + return byId; 648 + } 649 + } 650 + 651 + const tokenEmail = normalizeEmail(payload.email); 652 + if (tokenEmail) { 653 + const byEmail = config.users.find((user) => normalizeEmail(user.email) === tokenEmail); 654 + if (byEmail) { 655 + return byEmail; 656 + } 657 + } 658 + 659 + const tokenUsername = normalizeUsername(payload.username); 660 + if (tokenUsername) { 661 + const byUsername = config.users.find((user) => normalizeUsername(user.username) === tokenUsername); 662 + if (byUsername) { 663 + return byUsername; 664 + } 665 + } 666 + 667 + return undefined; 668 + }; 669 + 670 + const isActorAdmin = (user: AuthenticatedUser): boolean => user.isAdmin; 671 + 672 + const canViewAllMappings = (user: AuthenticatedUser): boolean => 673 + isActorAdmin(user) || user.permissions.viewAllMappings || user.permissions.manageAllMappings; 674 + 675 + const canManageAllMappings = (user: AuthenticatedUser): boolean => 676 + isActorAdmin(user) || user.permissions.manageAllMappings; 677 + 678 + const canManageOwnMappings = (user: AuthenticatedUser): boolean => 679 + isActorAdmin(user) || user.permissions.manageOwnMappings; 680 + 681 + const canManageGroups = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.manageGroups; 682 + 683 + const canQueueBackfills = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.queueBackfills; 684 + 685 + const canRunNow = (user: AuthenticatedUser): boolean => isActorAdmin(user) || user.permissions.runNow; 686 + 687 + const canManageMapping = (user: AuthenticatedUser, mapping: AccountMapping): boolean => { 688 + if (canManageAllMappings(user)) { 689 + return true; 690 + } 691 + if (!canManageOwnMappings(user)) { 692 + return false; 693 + } 694 + return mapping.createdByUserId === user.id; 695 + }; 696 + 697 + const getVisibleMappings = (config: AppConfig, user: AuthenticatedUser): AccountMapping[] => { 698 + if (canViewAllMappings(user)) { 699 + return config.mappings; 700 + } 701 + 702 + return config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 703 + }; 704 + 705 + const getVisibleMappingIdSet = (config: AppConfig, user: AuthenticatedUser): Set<string> => 706 + new Set(getVisibleMappings(config, user).map((mapping) => mapping.id)); 707 + 708 + const getVisibleMappingIdentitySets = (config: AppConfig, user: AuthenticatedUser) => { 709 + const visible = getVisibleMappings(config, user); 710 + const twitterUsernames = new Set<string>(); 711 + const bskyIdentifiers = new Set<string>(); 712 + 713 + for (const mapping of visible) { 714 + for (const username of mapping.twitterUsernames) { 715 + twitterUsernames.add(normalizeActor(username)); 716 + } 717 + bskyIdentifiers.add(normalizeActor(mapping.bskyIdentifier)); 718 + } 719 + 720 + return { 721 + twitterUsernames, 722 + bskyIdentifiers, 723 + }; 724 + }; 725 + 726 + const sanitizeMapping = ( 727 + mapping: AccountMapping, 728 + usersById: Map<string, WebUser>, 729 + requester: AuthenticatedUser, 730 + ): MappingResponse => { 731 + const { bskyPassword: _password, ...rest } = mapping; 732 + const createdBy = mapping.createdByUserId ? usersById.get(mapping.createdByUserId) : undefined; 733 + const ownerLabel = sanitizeLabelForRequester(mapping.owner, requester); 734 + 735 + const response: MappingResponse = { 736 + ...rest, 737 + owner: ownerLabel, 738 + createdByLabel: createdBy 739 + ? requester.isAdmin 740 + ? getUserDisplayLabel(createdBy) 741 + : getUserPublicLabel(createdBy) 742 + : ownerLabel, 743 + }; 744 + 745 + if (requester.isAdmin && createdBy) { 746 + response.createdByUser = { 747 + id: createdBy.id, 748 + username: createdBy.username, 749 + email: createdBy.email, 750 + role: createdBy.role, 751 + }; 752 + } 753 + 754 + return response; 755 + }; 756 + 757 + const parseTwitterUsernames = (value: unknown): string[] => { 758 + const seen = new Set<string>(); 759 + const usernames: string[] = []; 760 + const add = (candidate: unknown) => { 761 + if (typeof candidate !== 'string') { 762 + return; 763 + } 764 + const normalized = normalizeActor(candidate); 765 + if (!normalized || seen.has(normalized)) { 766 + return; 767 + } 768 + seen.add(normalized); 769 + usernames.push(normalized); 770 + }; 771 + 772 + if (Array.isArray(value)) { 773 + for (const candidate of value) { 774 + add(candidate); 775 + } 776 + } else if (typeof value === 'string') { 777 + for (const candidate of value.split(',')) { 778 + add(candidate); 779 + } 780 + } 781 + 782 + return usernames; 783 + }; 784 + 785 + const getAccessibleGroups = (config: AppConfig, user: AuthenticatedUser) => { 786 + const allGroups = Array.isArray(config.groups) 787 + ? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY) 788 + : []; 789 + 790 + if (canViewAllMappings(user)) { 791 + return allGroups; 792 + } 793 + 794 + const visibleMappings = getVisibleMappings(config, user); 795 + const allowedKeys = new Set<string>(); 796 + for (const mapping of visibleMappings) { 797 + const key = getNormalizedGroupKey(mapping.groupName); 798 + if (key && key !== RESERVED_UNGROUPED_KEY) { 799 + allowedKeys.add(key); 800 + } 801 + } 802 + 803 + const merged = new Map<string, { name: string; emoji?: string }>(); 804 + for (const group of allGroups) { 805 + const key = getNormalizedGroupKey(group.name); 806 + if (!allowedKeys.has(key)) { 807 + continue; 808 + } 809 + merged.set(key, group); 810 + } 811 + 812 + for (const mapping of visibleMappings) { 813 + const groupName = normalizeGroupName(mapping.groupName); 814 + if (!groupName || getNormalizedGroupKey(groupName) === RESERVED_UNGROUPED_KEY) { 815 + continue; 816 + } 817 + const key = getNormalizedGroupKey(groupName); 818 + if (!merged.has(key)) { 819 + merged.set(key, { 820 + name: groupName, 821 + ...(mapping.groupEmoji ? { emoji: mapping.groupEmoji } : {}), 822 + }); 823 + } 824 + } 825 + 826 + return [...merged.values()]; 827 + }; 828 + 829 + const parsePermissionsInput = (rawPermissions: unknown, role: UserRole): UserPermissions => { 830 + if (role === 'admin') { 831 + return { ...ADMIN_USER_PERMISSIONS }; 832 + } 833 + 834 + const defaults = getDefaultUserPermissions(role); 835 + if (!rawPermissions || typeof rawPermissions !== 'object') { 836 + return defaults; 837 + } 838 + 839 + const record = rawPermissions as Record<string, unknown>; 840 + return { 841 + viewAllMappings: normalizeBoolean(record.viewAllMappings, defaults.viewAllMappings), 842 + manageOwnMappings: normalizeBoolean(record.manageOwnMappings, defaults.manageOwnMappings), 843 + manageAllMappings: normalizeBoolean(record.manageAllMappings, defaults.manageAllMappings), 844 + manageGroups: normalizeBoolean(record.manageGroups, defaults.manageGroups), 845 + queueBackfills: normalizeBoolean(record.queueBackfills, defaults.queueBackfills), 846 + runNow: normalizeBoolean(record.runNow, defaults.runNow), 847 + }; 848 + }; 849 + 850 + const validatePassword = (password: unknown): string | undefined => { 851 + if (typeof password !== 'string' || password.length < PASSWORD_MIN_LENGTH) { 852 + return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`; 853 + } 854 + return undefined; 855 + }; 856 + 857 + const buildUserSummary = (config: AppConfig, requester: AuthenticatedUser): UserSummaryResponse[] => { 858 + const usersById = createUserLookupById(config); 859 + return config.users 860 + .map((user) => { 861 + const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 862 + const activeMappings = ownedMappings.filter((mapping) => mapping.enabled); 863 + return { 864 + id: user.id, 865 + username: user.username, 866 + email: user.email, 867 + role: user.role, 868 + isAdmin: user.role === 'admin', 869 + permissions: user.permissions, 870 + createdAt: user.createdAt, 871 + updatedAt: user.updatedAt, 872 + mappingCount: ownedMappings.length, 873 + activeMappingCount: activeMappings.length, 874 + mappings: ownedMappings.map((mapping) => sanitizeMapping(mapping, usersById, requester)), 875 + }; 876 + }) 877 + .sort((a, b) => { 878 + if (a.isAdmin && !b.isAdmin) { 879 + return -1; 880 + } 881 + if (!a.isAdmin && b.isAdmin) { 882 + return 1; 883 + } 884 + 885 + const aLabel = (a.username || a.email || '').toLowerCase(); 886 + const bLabel = (b.username || b.email || '').toLowerCase(); 887 + return aLabel.localeCompare(bLabel); 888 + }); 889 + }; 890 + 891 + const ensureUniqueIdentity = ( 892 + config: AppConfig, 893 + userId: string | undefined, 894 + username?: string, 895 + email?: string, 896 + ): string | null => { 897 + if (username) { 898 + const usernameTaken = config.users.some( 899 + (user) => user.id !== userId && normalizeUsername(user.username) === username, 900 + ); 901 + if (usernameTaken) { 902 + return 'Username already exists.'; 903 + } 904 + } 905 + if (email) { 906 + const emailTaken = config.users.some((user) => user.id !== userId && normalizeEmail(user.email) === email); 907 + if (emailTaken) { 908 + return 'Email already exists.'; 909 + } 910 + } 911 + return null; 912 + }; 913 + 492 914 const authenticateToken = (req: any, res: any, next: any) => { 493 915 const authHeader = req.headers.authorization; 494 916 const token = authHeader?.split(' ')[1]; 495 917 496 - if (!token) return res.sendStatus(401); 918 + if (!token) { 919 + res.sendStatus(401); 920 + return; 921 + } 922 + 923 + try { 924 + const decoded = jwt.verify(token, JWT_SECRET); 925 + if (!decoded || typeof decoded !== 'object') { 926 + res.sendStatus(403); 927 + return; 928 + } 929 + 930 + const config = getConfig(); 931 + const user = findUserFromTokenPayload(config, decoded as Record<string, unknown>); 932 + if (!user) { 933 + res.sendStatus(401); 934 + return; 935 + } 497 936 498 - jwt.verify(token, JWT_SECRET, (err: any, user: any) => { 499 - if (err) return res.sendStatus(403); 500 - req.user = user; 937 + req.user = toAuthenticatedUser(user); 501 938 next(); 502 - }); 939 + } catch { 940 + res.sendStatus(403); 941 + } 503 942 }; 504 943 505 - // Middleware to require admin access 506 944 const requireAdmin = (req: any, res: any, next: any) => { 507 - if (!req.user.isAdmin) { 508 - return res.status(403).json({ error: 'Admin access required' }); 945 + if (!req.user?.isAdmin) { 946 + res.status(403).json({ error: 'Admin access required' }); 947 + return; 509 948 } 510 949 next(); 511 950 }; ··· 607 1046 608 1047 // --- Auth Routes --- 609 1048 1049 + app.get('/api/auth/bootstrap-status', (_req, res) => { 1050 + const config = getConfig(); 1051 + res.json({ bootstrapOpen: config.users.length === 0 }); 1052 + }); 1053 + 610 1054 app.post('/api/register', async (req, res) => { 611 - const { email, password } = req.body; 612 1055 const config = getConfig(); 1056 + if (config.users.length > 0) { 1057 + res.status(403).json({ error: 'Registration is disabled. Ask an admin to create your account.' }); 1058 + return; 1059 + } 1060 + 1061 + const email = normalizeEmail(req.body?.email); 1062 + const username = normalizeUsername(req.body?.username); 1063 + const password = req.body?.password; 613 1064 614 - if (config.users.find((u) => u.email === email)) { 615 - res.status(400).json({ error: 'User already exists' }); 1065 + if (!email && !username) { 1066 + res.status(400).json({ error: 'Username or email is required.' }); 1067 + return; 1068 + } 1069 + 1070 + const passwordError = validatePassword(password); 1071 + if (passwordError) { 1072 + res.status(400).json({ error: passwordError }); 1073 + return; 1074 + } 1075 + 1076 + const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1077 + if (uniqueIdentityError) { 1078 + res.status(400).json({ error: uniqueIdentityError }); 616 1079 return; 617 1080 } 618 1081 619 - const passwordHash = await bcrypt.hash(password, 10); 620 - config.users.push({ email, passwordHash }); 1082 + const nowIso = new Date().toISOString(); 1083 + const newUser: WebUser = { 1084 + id: randomUUID(), 1085 + username, 1086 + email, 1087 + passwordHash: await bcrypt.hash(password, 10), 1088 + role: 'admin', 1089 + permissions: { ...ADMIN_USER_PERMISSIONS }, 1090 + createdAt: nowIso, 1091 + updatedAt: nowIso, 1092 + }; 1093 + 1094 + config.users.push(newUser); 1095 + 1096 + if (config.mappings.length > 0) { 1097 + config.mappings = config.mappings.map((mapping) => ({ 1098 + ...mapping, 1099 + createdByUserId: mapping.createdByUserId || newUser.id, 1100 + owner: mapping.owner || getUserPublicLabel(newUser), 1101 + })); 1102 + } 1103 + 621 1104 saveConfig(config); 622 1105 623 1106 res.json({ success: true }); 624 1107 }); 625 1108 626 1109 app.post('/api/login', async (req, res) => { 627 - const { email, password } = req.body; 1110 + const password = req.body?.password; 1111 + const identifier = normalizeOptionalString(req.body?.identifier) ?? normalizeOptionalString(req.body?.email); 1112 + if (!identifier || typeof password !== 'string') { 1113 + res.status(400).json({ error: 'Username/email and password are required.' }); 1114 + return; 1115 + } 1116 + 628 1117 const config = getConfig(); 629 - const user = config.users.find((u) => u.email === email); 1118 + const user = findUserByIdentifier(config, identifier); 630 1119 631 1120 if (!user || !(await bcrypt.compare(password, user.passwordHash))) { 632 1121 res.status(401).json({ error: 'Invalid credentials' }); 633 1122 return; 634 1123 } 635 1124 636 - const userIndex = config.users.findIndex((u) => u.email === email); 637 - const isAdmin = userIndex === 0; 638 - const token = jwt.sign({ email: user.email, isAdmin }, JWT_SECRET, { expiresIn: '24h' }); 639 - res.json({ token, isAdmin }); 1125 + const token = issueTokenForUser(user); 1126 + res.json({ token, isAdmin: user.role === 'admin' }); 640 1127 }); 641 1128 642 1129 app.get('/api/me', authenticateToken, (req: any, res) => { 643 - res.json({ email: req.user.email, isAdmin: req.user.isAdmin }); 1130 + res.json(serializeAuthenticatedUser(req.user)); 1131 + }); 1132 + 1133 + app.post('/api/me/change-email', authenticateToken, async (req: any, res) => { 1134 + const config = getConfig(); 1135 + const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1136 + const user = config.users[userIndex]; 1137 + if (userIndex === -1 || !user) { 1138 + res.status(404).json({ error: 'User not found.' }); 1139 + return; 1140 + } 1141 + 1142 + const currentEmail = normalizeEmail(req.body?.currentEmail); 1143 + const newEmail = normalizeEmail(req.body?.newEmail); 1144 + const password = req.body?.password; 1145 + if (!newEmail) { 1146 + res.status(400).json({ error: 'A new email is required.' }); 1147 + return; 1148 + } 1149 + if (typeof password !== 'string') { 1150 + res.status(400).json({ error: 'Password is required.' }); 1151 + return; 1152 + } 1153 + 1154 + const existingEmail = normalizeEmail(user.email); 1155 + if (existingEmail && currentEmail !== existingEmail) { 1156 + res.status(400).json({ error: 'Current email does not match.' }); 1157 + return; 1158 + } 1159 + 1160 + if (!(await bcrypt.compare(password, user.passwordHash))) { 1161 + res.status(401).json({ error: 'Password verification failed.' }); 1162 + return; 1163 + } 1164 + 1165 + const uniqueIdentityError = ensureUniqueIdentity(config, user.id, normalizeUsername(user.username), newEmail); 1166 + if (uniqueIdentityError) { 1167 + res.status(400).json({ error: uniqueIdentityError }); 1168 + return; 1169 + } 1170 + 1171 + const updatedUser: WebUser = { 1172 + ...user, 1173 + email: newEmail, 1174 + updatedAt: new Date().toISOString(), 1175 + }; 1176 + config.users[userIndex] = updatedUser; 1177 + saveConfig(config); 1178 + 1179 + const token = issueTokenForUser(updatedUser); 1180 + res.json({ 1181 + success: true, 1182 + token, 1183 + me: serializeAuthenticatedUser(toAuthenticatedUser(updatedUser)), 1184 + }); 1185 + }); 1186 + 1187 + app.post('/api/me/change-password', authenticateToken, async (req: any, res) => { 1188 + const config = getConfig(); 1189 + const userIndex = config.users.findIndex((user) => user.id === req.user.id); 1190 + const user = config.users[userIndex]; 1191 + if (userIndex === -1 || !user) { 1192 + res.status(404).json({ error: 'User not found.' }); 1193 + return; 1194 + } 1195 + 1196 + const currentPassword = req.body?.currentPassword; 1197 + const newPassword = req.body?.newPassword; 1198 + if (typeof currentPassword !== 'string') { 1199 + res.status(400).json({ error: 'Current password is required.' }); 1200 + return; 1201 + } 1202 + 1203 + const passwordError = validatePassword(newPassword); 1204 + if (passwordError) { 1205 + res.status(400).json({ error: passwordError }); 1206 + return; 1207 + } 1208 + 1209 + if (!(await bcrypt.compare(currentPassword, user.passwordHash))) { 1210 + res.status(401).json({ error: 'Current password is incorrect.' }); 1211 + return; 1212 + } 1213 + 1214 + config.users[userIndex] = { 1215 + ...user, 1216 + passwordHash: await bcrypt.hash(newPassword, 10), 1217 + updatedAt: new Date().toISOString(), 1218 + }; 1219 + saveConfig(config); 1220 + res.json({ success: true }); 1221 + }); 1222 + 1223 + app.get('/api/admin/users', authenticateToken, requireAdmin, (req: any, res) => { 1224 + const config = getConfig(); 1225 + res.json(buildUserSummary(config, req.user)); 1226 + }); 1227 + 1228 + app.post('/api/admin/users', authenticateToken, requireAdmin, async (req: any, res) => { 1229 + const config = getConfig(); 1230 + const username = normalizeUsername(req.body?.username); 1231 + const email = normalizeEmail(req.body?.email); 1232 + const password = req.body?.password; 1233 + const role: UserRole = req.body?.isAdmin ? 'admin' : 'user'; 1234 + const permissions = parsePermissionsInput(req.body?.permissions, role); 1235 + 1236 + if (!username && !email) { 1237 + res.status(400).json({ error: 'Username or email is required.' }); 1238 + return; 1239 + } 1240 + 1241 + const passwordError = validatePassword(password); 1242 + if (passwordError) { 1243 + res.status(400).json({ error: passwordError }); 1244 + return; 1245 + } 1246 + 1247 + const uniqueIdentityError = ensureUniqueIdentity(config, undefined, username, email); 1248 + if (uniqueIdentityError) { 1249 + res.status(400).json({ error: uniqueIdentityError }); 1250 + return; 1251 + } 1252 + 1253 + const nowIso = new Date().toISOString(); 1254 + const newUser: WebUser = { 1255 + id: randomUUID(), 1256 + username, 1257 + email, 1258 + passwordHash: await bcrypt.hash(password, 10), 1259 + role, 1260 + permissions, 1261 + createdAt: nowIso, 1262 + updatedAt: nowIso, 1263 + }; 1264 + 1265 + config.users.push(newUser); 1266 + saveConfig(config); 1267 + 1268 + const summary = buildUserSummary(config, req.user).find((user) => user.id === newUser.id); 1269 + res.json(summary || null); 1270 + }); 1271 + 1272 + app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1273 + const { id } = req.params; 1274 + const config = getConfig(); 1275 + const userIndex = config.users.findIndex((user) => user.id === id); 1276 + const user = config.users[userIndex]; 1277 + if (userIndex === -1 || !user) { 1278 + res.status(404).json({ error: 'User not found.' }); 1279 + return; 1280 + } 1281 + 1282 + const requestedRole: UserRole = 1283 + req.body?.isAdmin === true ? 'admin' : req.body?.isAdmin === false ? 'user' : user.role; 1284 + 1285 + if (user.id === req.user.id && requestedRole !== 'admin') { 1286 + res.status(400).json({ error: 'You cannot remove your own admin access.' }); 1287 + return; 1288 + } 1289 + 1290 + if (user.role === 'admin' && requestedRole !== 'admin') { 1291 + const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1292 + if (adminCount <= 1) { 1293 + res.status(400).json({ error: 'At least one admin must remain.' }); 1294 + return; 1295 + } 1296 + } 1297 + 1298 + const username = 1299 + req.body?.username !== undefined ? normalizeUsername(req.body?.username) : normalizeUsername(user.username); 1300 + const email = req.body?.email !== undefined ? normalizeEmail(req.body?.email) : normalizeEmail(user.email); 1301 + 1302 + if (!username && !email) { 1303 + res.status(400).json({ error: 'User must keep at least a username or email.' }); 1304 + return; 1305 + } 1306 + 1307 + const uniqueIdentityError = ensureUniqueIdentity(config, user.id, username, email); 1308 + if (uniqueIdentityError) { 1309 + res.status(400).json({ error: uniqueIdentityError }); 1310 + return; 1311 + } 1312 + 1313 + const permissions = 1314 + req.body?.permissions !== undefined || req.body?.isAdmin !== undefined 1315 + ? parsePermissionsInput(req.body?.permissions, requestedRole) 1316 + : requestedRole === 'admin' 1317 + ? { ...ADMIN_USER_PERMISSIONS } 1318 + : user.permissions; 1319 + 1320 + config.users[userIndex] = { 1321 + ...user, 1322 + username, 1323 + email, 1324 + role: requestedRole, 1325 + permissions, 1326 + updatedAt: new Date().toISOString(), 1327 + }; 1328 + 1329 + saveConfig(config); 1330 + const summary = buildUserSummary(config, req.user).find((entry) => entry.id === id); 1331 + res.json(summary || null); 1332 + }); 1333 + 1334 + app.post('/api/admin/users/:id/reset-password', authenticateToken, requireAdmin, async (req, res) => { 1335 + const { id } = req.params; 1336 + const config = getConfig(); 1337 + const userIndex = config.users.findIndex((user) => user.id === id); 1338 + const user = config.users[userIndex]; 1339 + if (userIndex === -1 || !user) { 1340 + res.status(404).json({ error: 'User not found.' }); 1341 + return; 1342 + } 1343 + 1344 + const newPassword = req.body?.newPassword; 1345 + const passwordError = validatePassword(newPassword); 1346 + if (passwordError) { 1347 + res.status(400).json({ error: passwordError }); 1348 + return; 1349 + } 1350 + 1351 + config.users[userIndex] = { 1352 + ...user, 1353 + passwordHash: await bcrypt.hash(newPassword, 10), 1354 + updatedAt: new Date().toISOString(), 1355 + }; 1356 + saveConfig(config); 1357 + res.json({ success: true }); 1358 + }); 1359 + 1360 + app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req: any, res) => { 1361 + const { id } = req.params; 1362 + const config = getConfig(); 1363 + const userIndex = config.users.findIndex((user) => user.id === id); 1364 + const user = config.users[userIndex]; 1365 + 1366 + if (userIndex === -1 || !user) { 1367 + res.status(404).json({ error: 'User not found.' }); 1368 + return; 1369 + } 1370 + 1371 + if (user.id === req.user.id) { 1372 + res.status(400).json({ error: 'You cannot delete your own account.' }); 1373 + return; 1374 + } 1375 + 1376 + if (user.role === 'admin') { 1377 + const adminCount = config.users.filter((entry) => entry.role === 'admin').length; 1378 + if (adminCount <= 1) { 1379 + res.status(400).json({ error: 'At least one admin must remain.' }); 1380 + return; 1381 + } 1382 + } 1383 + 1384 + const ownedMappings = config.mappings.filter((mapping) => mapping.createdByUserId === user.id); 1385 + const ownedMappingIds = new Set(ownedMappings.map((mapping) => mapping.id)); 1386 + config.mappings = config.mappings.map((mapping) => 1387 + mapping.createdByUserId === user.id 1388 + ? { 1389 + ...mapping, 1390 + enabled: false, 1391 + } 1392 + : mapping, 1393 + ); 1394 + 1395 + config.users.splice(userIndex, 1); 1396 + pendingBackfills = pendingBackfills.filter((backfill) => !ownedMappingIds.has(backfill.id)); 1397 + saveConfig(config); 1398 + 1399 + res.json({ 1400 + success: true, 1401 + disabledMappings: ownedMappings.length, 1402 + }); 644 1403 }); 645 1404 646 1405 // --- Mapping Routes --- 647 1406 648 - app.get('/api/mappings', authenticateToken, (_req, res) => { 1407 + app.get('/api/mappings', authenticateToken, (req: any, res) => { 649 1408 const config = getConfig(); 650 - res.json(config.mappings); 1409 + const usersById = createUserLookupById(config); 1410 + const visibleMappings = getVisibleMappings(config, req.user); 1411 + res.json(visibleMappings.map((mapping) => sanitizeMapping(mapping, usersById, req.user))); 651 1412 }); 652 1413 653 - app.get('/api/groups', authenticateToken, (_req, res) => { 1414 + app.get('/api/groups', authenticateToken, (req: any, res) => { 654 1415 const config = getConfig(); 655 - const groups = Array.isArray(config.groups) 656 - ? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY) 657 - : []; 658 - res.json(groups); 1416 + res.json(getAccessibleGroups(config, req.user)); 659 1417 }); 660 1418 661 - app.post('/api/groups', authenticateToken, (req, res) => { 1419 + app.post('/api/groups', authenticateToken, (req: any, res) => { 1420 + if (!canManageGroups(req.user)) { 1421 + res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1422 + return; 1423 + } 1424 + 662 1425 const config = getConfig(); 663 1426 const normalizedName = normalizeGroupName(req.body?.name); 664 1427 const normalizedEmoji = normalizeGroupEmoji(req.body?.emoji); ··· 676 1439 ensureGroupExists(config, normalizedName, normalizedEmoji); 677 1440 saveConfig(config); 678 1441 679 - const group = config.groups.find((entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName)); 1442 + const group = config.groups.find( 1443 + (entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName), 1444 + ); 680 1445 res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); 681 1446 }); 682 1447 683 - app.put('/api/groups/:groupKey', authenticateToken, (req, res) => { 1448 + app.put('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1449 + if (!canManageGroups(req.user)) { 1450 + res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1451 + return; 1452 + } 1453 + 684 1454 const currentGroupKey = getNormalizedGroupKey(req.params.groupKey); 685 1455 if (!currentGroupKey || currentGroupKey === RESERVED_UNGROUPED_KEY) { 686 1456 res.status(400).json({ error: 'Invalid group key.' }); ··· 753 1523 }); 754 1524 }); 755 1525 756 - app.delete('/api/groups/:groupKey', authenticateToken, (req, res) => { 1526 + app.delete('/api/groups/:groupKey', authenticateToken, (req: any, res) => { 1527 + if (!canManageGroups(req.user)) { 1528 + res.status(403).json({ error: 'You do not have permission to manage groups.' }); 1529 + return; 1530 + } 1531 + 757 1532 const groupKey = getNormalizedGroupKey(req.params.groupKey); 758 1533 if (!groupKey || groupKey === RESERVED_UNGROUPED_KEY) { 759 1534 res.status(400).json({ error: 'Invalid group key.' }); ··· 789 1564 res.json({ success: true, reassignedCount: reassigned }); 790 1565 }); 791 1566 792 - app.post('/api/mappings', authenticateToken, (req, res) => { 793 - const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 1567 + app.post('/api/mappings', authenticateToken, (req: any, res) => { 1568 + if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 1569 + res.status(403).json({ error: 'You do not have permission to create mappings.' }); 1570 + return; 1571 + } 1572 + 794 1573 const config = getConfig(); 1574 + const usersById = createUserLookupById(config); 1575 + const twitterUsernames = parseTwitterUsernames(req.body?.twitterUsernames); 1576 + if (twitterUsernames.length === 0) { 1577 + res.status(400).json({ error: 'At least one Twitter username is required.' }); 1578 + return; 1579 + } 795 1580 796 - // Handle both array and comma-separated string 797 - let usernames: string[] = []; 798 - if (Array.isArray(twitterUsernames)) { 799 - usernames = twitterUsernames; 800 - } else if (typeof twitterUsernames === 'string') { 801 - usernames = twitterUsernames 802 - .split(',') 803 - .map((u) => u.trim()) 804 - .filter((u) => u.length > 0); 1581 + const bskyIdentifier = normalizeActor(req.body?.bskyIdentifier || ''); 1582 + const bskyPassword = normalizeOptionalString(req.body?.bskyPassword); 1583 + if (!bskyIdentifier || !bskyPassword) { 1584 + res.status(400).json({ error: 'Bluesky identifier and app password are required.' }); 1585 + return; 805 1586 } 806 1587 807 - const normalizedGroupName = normalizeGroupName(groupName); 808 - const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); 1588 + let createdByUserId = req.user.id; 1589 + const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 1590 + if (requestedCreatorId && requestedCreatorId !== req.user.id) { 1591 + if (!canManageAllMappings(req.user)) { 1592 + res.status(403).json({ error: 'You cannot assign mappings to another user.' }); 1593 + return; 1594 + } 1595 + if (!usersById.has(requestedCreatorId)) { 1596 + res.status(400).json({ error: 'Selected account owner does not exist.' }); 1597 + return; 1598 + } 1599 + createdByUserId = requestedCreatorId; 1600 + } 809 1601 810 - const newMapping = { 811 - id: Math.random().toString(36).substring(7), 812 - twitterUsernames: usernames, 1602 + const ownerUser = usersById.get(createdByUserId); 1603 + const owner = 1604 + normalizeOptionalString(req.body?.owner) || (ownerUser ? getUserPublicLabel(ownerUser) : getActorPublicLabel(req.user)); 1605 + const normalizedGroupName = normalizeGroupName(req.body?.groupName); 1606 + const normalizedGroupEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 1607 + 1608 + const newMapping: AccountMapping = { 1609 + id: randomUUID(), 1610 + twitterUsernames, 813 1611 bskyIdentifier, 814 1612 bskyPassword, 815 - bskyServiceUrl: bskyServiceUrl || 'https://bsky.social', 1613 + bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || 'https://bsky.social', 816 1614 enabled: true, 817 1615 owner, 818 1616 groupName: normalizedGroupName || undefined, 819 1617 groupEmoji: normalizedGroupEmoji || undefined, 1618 + createdByUserId, 820 1619 }; 821 1620 822 1621 ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); 823 1622 config.mappings.push(newMapping); 824 1623 saveConfig(config); 825 - res.json(newMapping); 1624 + res.json(sanitizeMapping(newMapping, createUserLookupById(config), req.user)); 826 1625 }); 827 1626 828 - app.put('/api/mappings/:id', authenticateToken, (req, res) => { 1627 + app.put('/api/mappings/:id', authenticateToken, (req: any, res) => { 829 1628 const { id } = req.params; 830 - const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 831 1629 const config = getConfig(); 832 - 833 - const index = config.mappings.findIndex((m) => m.id === id); 1630 + const usersById = createUserLookupById(config); 1631 + const index = config.mappings.findIndex((mapping) => mapping.id === id); 834 1632 const existingMapping = config.mappings[index]; 835 1633 836 1634 if (index === -1 || !existingMapping) { ··· 838 1636 return; 839 1637 } 840 1638 841 - let usernames: string[] = existingMapping.twitterUsernames; 842 - if (twitterUsernames !== undefined) { 843 - if (Array.isArray(twitterUsernames)) { 844 - usernames = twitterUsernames; 845 - } else if (typeof twitterUsernames === 'string') { 846 - usernames = twitterUsernames 847 - .split(',') 848 - .map((u) => u.trim()) 849 - .filter((u) => u.length > 0); 1639 + if (!canManageMapping(req.user, existingMapping)) { 1640 + res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 1641 + return; 1642 + } 1643 + 1644 + let twitterUsernames: string[] = existingMapping.twitterUsernames; 1645 + if (req.body?.twitterUsernames !== undefined) { 1646 + twitterUsernames = parseTwitterUsernames(req.body.twitterUsernames); 1647 + if (twitterUsernames.length === 0) { 1648 + res.status(400).json({ error: 'At least one Twitter username is required.' }); 1649 + return; 1650 + } 1651 + } 1652 + 1653 + let bskyIdentifier = existingMapping.bskyIdentifier; 1654 + if (req.body?.bskyIdentifier !== undefined) { 1655 + const normalizedIdentifier = normalizeActor(req.body?.bskyIdentifier); 1656 + if (!normalizedIdentifier) { 1657 + res.status(400).json({ error: 'Invalid Bluesky identifier.' }); 1658 + return; 1659 + } 1660 + bskyIdentifier = normalizedIdentifier; 1661 + } 1662 + 1663 + let createdByUserId = existingMapping.createdByUserId || req.user.id; 1664 + if (req.body?.createdByUserId !== undefined) { 1665 + if (!canManageAllMappings(req.user)) { 1666 + res.status(403).json({ error: 'You cannot reassign mapping ownership.' }); 1667 + return; 1668 + } 1669 + 1670 + const requestedCreatorId = normalizeOptionalString(req.body?.createdByUserId); 1671 + if (!requestedCreatorId || !usersById.has(requestedCreatorId)) { 1672 + res.status(400).json({ error: 'Selected account owner does not exist.' }); 1673 + return; 850 1674 } 1675 + createdByUserId = requestedCreatorId; 851 1676 } 852 1677 853 1678 let nextGroupName = existingMapping.groupName; 854 - if (groupName !== undefined) { 855 - const normalizedGroupName = normalizeGroupName(groupName); 856 - nextGroupName = normalizedGroupName || undefined; 1679 + if (req.body?.groupName !== undefined) { 1680 + const normalizedName = normalizeGroupName(req.body?.groupName); 1681 + nextGroupName = normalizedName || undefined; 857 1682 } 858 1683 859 1684 let nextGroupEmoji = existingMapping.groupEmoji; 860 - if (groupEmoji !== undefined) { 861 - const normalizedGroupEmoji = normalizeGroupEmoji(groupEmoji); 862 - nextGroupEmoji = normalizedGroupEmoji || undefined; 1685 + if (req.body?.groupEmoji !== undefined) { 1686 + const normalizedEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 1687 + nextGroupEmoji = normalizedEmoji || undefined; 863 1688 } 864 1689 865 - const updatedMapping = { 1690 + const ownerUser = usersById.get(createdByUserId); 1691 + const owner = 1692 + req.body?.owner !== undefined 1693 + ? normalizeOptionalString(req.body?.owner) || existingMapping.owner 1694 + : existingMapping.owner || (ownerUser ? getUserPublicLabel(ownerUser) : undefined); 1695 + 1696 + const updatedMapping: AccountMapping = { 866 1697 ...existingMapping, 867 - twitterUsernames: usernames, 868 - bskyIdentifier: bskyIdentifier || existingMapping.bskyIdentifier, 869 - // Only update password if provided 870 - bskyPassword: bskyPassword || existingMapping.bskyPassword, 871 - bskyServiceUrl: bskyServiceUrl || existingMapping.bskyServiceUrl, 872 - owner: owner || existingMapping.owner, 1698 + twitterUsernames, 1699 + bskyIdentifier, 1700 + bskyPassword: normalizeOptionalString(req.body?.bskyPassword) || existingMapping.bskyPassword, 1701 + bskyServiceUrl: normalizeOptionalString(req.body?.bskyServiceUrl) || existingMapping.bskyServiceUrl, 1702 + owner, 873 1703 groupName: nextGroupName, 874 1704 groupEmoji: nextGroupEmoji, 1705 + createdByUserId, 875 1706 }; 876 1707 877 1708 ensureGroupExists(config, nextGroupName, nextGroupEmoji); 878 1709 config.mappings[index] = updatedMapping; 879 1710 saveConfig(config); 880 - res.json(updatedMapping); 1711 + res.json(sanitizeMapping(updatedMapping, createUserLookupById(config), req.user)); 881 1712 }); 882 1713 883 - app.delete('/api/mappings/:id', authenticateToken, (req, res) => { 1714 + app.delete('/api/mappings/:id', authenticateToken, (req: any, res) => { 884 1715 const { id } = req.params; 885 1716 const config = getConfig(); 886 - config.mappings = config.mappings.filter((m) => m.id !== id); 1717 + const mapping = config.mappings.find((entry) => entry.id === id); 1718 + 1719 + if (!mapping) { 1720 + res.status(404).json({ error: 'Mapping not found' }); 1721 + return; 1722 + } 1723 + 1724 + if (!canManageMapping(req.user, mapping)) { 1725 + res.status(403).json({ error: 'You do not have permission to delete this mapping.' }); 1726 + return; 1727 + } 1728 + 1729 + config.mappings = config.mappings.filter((entry) => entry.id !== id); 1730 + pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 887 1731 saveConfig(config); 888 1732 res.json({ success: true }); 889 1733 }); ··· 915 1759 916 1760 try { 917 1761 const deletedCount = await deleteAllPosts(id); 918 - 919 - // Clear local cache to stay in sync 1762 + 920 1763 dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 921 - 922 - res.json({ 923 - success: true, 924 - message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.` 1764 + 1765 + res.json({ 1766 + success: true, 1767 + message: `Deleted ${deletedCount} posts from ${mapping.bskyIdentifier} and cleared local cache.`, 925 1768 }); 926 1769 } catch (err) { 927 1770 console.error('Failed to delete all posts:', err); ··· 946 1789 947 1790 app.get('/api/ai-config', authenticateToken, requireAdmin, (_req, res) => { 948 1791 const config = getConfig(); 949 - // Return legacy gemini key as part of new structure if needed 950 1792 const aiConfig = config.ai || { 951 1793 provider: 'gemini', 952 1794 apiKey: config.geminiApiKey || '', ··· 965 1807 baseUrl: baseUrl || undefined, 966 1808 }; 967 1809 968 - // Clear legacy key to avoid confusion 969 1810 delete config.geminiApiKey; 970 1811 971 1812 saveConfig(config); ··· 974 1815 975 1816 // --- Status & Actions Routes --- 976 1817 977 - app.get('/api/status', authenticateToken, (_req, res) => { 1818 + app.get('/api/status', authenticateToken, (req: any, res) => { 978 1819 const config = getConfig(); 979 1820 const now = Date.now(); 980 - const checkIntervalMs = (config.checkIntervalMinutes || 5) * 60 * 1000; 981 1821 const nextRunMs = Math.max(0, nextCheckTime - now); 1822 + const visibleMappingIds = getVisibleMappingIdSet(config, req.user); 1823 + const scopedPendingBackfills = pendingBackfills 1824 + .filter((backfill) => visibleMappingIds.has(backfill.id)) 1825 + .sort((a, b) => a.sequence - b.sequence); 1826 + 1827 + const scopedStatus = 1828 + currentAppStatus.state === 'backfilling' && 1829 + currentAppStatus.backfillMappingId && 1830 + !visibleMappingIds.has(currentAppStatus.backfillMappingId) 1831 + ? { 1832 + state: 'idle', 1833 + message: 'Idle', 1834 + lastUpdate: currentAppStatus.lastUpdate, 1835 + } 1836 + : currentAppStatus; 982 1837 983 1838 res.json({ 984 1839 lastCheckTime, 985 1840 nextCheckTime, 986 1841 nextCheckMinutes: Math.ceil(nextRunMs / 60000), 987 1842 checkIntervalMinutes: config.checkIntervalMinutes, 988 - pendingBackfills: pendingBackfills 989 - .slice() 990 - .sort((a, b) => a.sequence - b.sequence) 991 - .map((backfill, index) => ({ 992 - ...backfill, 993 - position: index + 1, 994 - })), 995 - currentStatus: currentAppStatus, 1843 + pendingBackfills: scopedPendingBackfills.map((backfill, index) => ({ 1844 + ...backfill, 1845 + position: index + 1, 1846 + })), 1847 + currentStatus: scopedStatus, 996 1848 }); 997 1849 }); 998 1850 ··· 1005 1857 }); 1006 1858 1007 1859 app.post('/api/update', authenticateToken, requireAdmin, (req: any, res) => { 1008 - const startedBy = typeof req.user?.email === 'string' && req.user.email.length > 0 ? req.user.email : 'admin'; 1860 + const startedBy = getActorLabel(req.user); 1009 1861 const result = startUpdateJob(startedBy); 1010 1862 if (!result.ok) { 1011 1863 const message = result.message; ··· 1022 1874 }); 1023 1875 }); 1024 1876 1025 - app.post('/api/run-now', authenticateToken, (_req, res) => { 1877 + app.post('/api/run-now', authenticateToken, (req: any, res) => { 1878 + if (!canRunNow(req.user)) { 1879 + res.status(403).json({ error: 'You do not have permission to run checks manually.' }); 1880 + return; 1881 + } 1882 + 1026 1883 lastCheckTime = 0; 1027 1884 nextCheckTime = Date.now() + 1000; 1028 1885 res.json({ success: true, message: 'Check triggered' }); ··· 1039 1896 res.json({ success: true, message: 'All backfills cleared' }); 1040 1897 }); 1041 1898 1042 - app.post('/api/backfill/:id', authenticateToken, requireAdmin, (req, res) => { 1899 + app.post('/api/backfill/:id', authenticateToken, (req: any, res) => { 1900 + if (!canQueueBackfills(req.user)) { 1901 + res.status(403).json({ error: 'You do not have permission to queue backfills.' }); 1902 + return; 1903 + } 1904 + 1043 1905 const { id } = req.params; 1044 1906 const { limit } = req.body; 1045 1907 const config = getConfig(); ··· 1050 1912 return; 1051 1913 } 1052 1914 1915 + if (!canManageMapping(req.user, mapping)) { 1916 + res.status(403).json({ error: 'You do not have access to this mapping.' }); 1917 + return; 1918 + } 1919 + 1920 + const parsedLimit = Number(limit); 1921 + const safeLimit = Number.isFinite(parsedLimit) ? Math.max(1, Math.min(parsedLimit, 200)) : undefined; 1053 1922 const queuedAt = Date.now(); 1054 1923 const sequence = backfillSequence++; 1055 - const requestId = Math.random().toString(36).slice(2); 1056 - pendingBackfills = pendingBackfills.filter((b) => b.id !== id); 1924 + const requestId = randomUUID(); 1925 + pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 1057 1926 pendingBackfills.push({ 1058 1927 id, 1059 - limit: limit ? Number(limit) : undefined, 1928 + limit: safeLimit, 1060 1929 queuedAt, 1061 1930 sequence, 1062 1931 requestId, 1063 1932 }); 1064 1933 pendingBackfills.sort((a, b) => a.sequence - b.sequence); 1065 1934 1066 - // Do not force a global run; the scheduler loop will pick up the pending backfill in ~5s 1067 1935 res.json({ 1068 1936 success: true, 1069 1937 message: `Backfill queued for @${mapping.twitterUsernames.join(', ')}`, ··· 1071 1939 }); 1072 1940 }); 1073 1941 1074 - app.delete('/api/backfill/:id', authenticateToken, (req, res) => { 1942 + app.delete('/api/backfill/:id', authenticateToken, (req: any, res) => { 1075 1943 const { id } = req.params; 1076 - pendingBackfills = pendingBackfills.filter((bid) => bid.id !== id); 1944 + const config = getConfig(); 1945 + const mapping = config.mappings.find((entry) => entry.id === id); 1946 + 1947 + if (!mapping) { 1948 + res.status(404).json({ error: 'Mapping not found' }); 1949 + return; 1950 + } 1951 + 1952 + if (!canManageMapping(req.user, mapping)) { 1953 + res.status(403).json({ error: 'You do not have permission to update this queue entry.' }); 1954 + return; 1955 + } 1956 + 1957 + pendingBackfills = pendingBackfills.filter((entry) => entry.id !== id); 1077 1958 res.json({ success: true }); 1078 1959 }); 1079 1960 ··· 1081 1962 1082 1963 app.get('/api/config/export', authenticateToken, requireAdmin, (_req, res) => { 1083 1964 const config = getConfig(); 1084 - // Create a copy without user data (passwords) 1085 1965 const { users, ...cleanConfig } = config; 1086 - 1966 + 1087 1967 res.setHeader('Content-Type', 'application/json'); 1088 1968 res.setHeader('Content-Disposition', 'attachment; filename=tweets-2-bsky-config.json'); 1089 1969 res.json(cleanConfig); ··· 1094 1974 const importData = req.body; 1095 1975 const currentConfig = getConfig(); 1096 1976 1097 - // Validate minimal structure 1098 1977 if (!importData.mappings || !Array.isArray(importData.mappings)) { 1099 - res.status(400).json({ error: 'Invalid config format: missing mappings array' }); 1100 - return; 1978 + res.status(400).json({ error: 'Invalid config format: missing mappings array' }); 1979 + return; 1101 1980 } 1102 1981 1103 - // Merge logic: 1104 - // 1. Keep current users (don't overwrite admin/passwords) 1105 - // 2. Overwrite mappings, twitter, ai config from import 1106 - // 3. Keep current values if import is missing them (optional, but safer to just replace sections) 1107 - 1108 1982 const newConfig = { 1109 - ...currentConfig, 1110 - mappings: importData.mappings, 1111 - groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, 1112 - twitter: importData.twitter || currentConfig.twitter, 1113 - ai: importData.ai || currentConfig.ai, 1114 - checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes 1983 + ...currentConfig, 1984 + mappings: importData.mappings, 1985 + groups: Array.isArray(importData.groups) ? importData.groups : currentConfig.groups, 1986 + twitter: importData.twitter || currentConfig.twitter, 1987 + ai: importData.ai || currentConfig.ai, 1988 + checkIntervalMinutes: importData.checkIntervalMinutes || currentConfig.checkIntervalMinutes, 1115 1989 }; 1116 1990 1117 1991 saveConfig(newConfig); ··· 1122 1996 } 1123 1997 }); 1124 1998 1125 - app.get('/api/recent-activity', authenticateToken, (req, res) => { 1126 - const limit = req.query.limit ? Number(req.query.limit) : 50; 1127 - const tweets = dbService.getRecentProcessedTweets(limit); 1128 - res.json(tweets); 1999 + app.get('/api/recent-activity', authenticateToken, (req: any, res) => { 2000 + const limitCandidate = req.query.limit ? Number(req.query.limit) : 50; 2001 + const limit = Number.isFinite(limitCandidate) ? Math.max(1, Math.min(limitCandidate, 200)) : 50; 2002 + const config = getConfig(); 2003 + const visibleSets = getVisibleMappingIdentitySets(config, req.user); 2004 + const scanLimit = canViewAllMappings(req.user) ? limit : Math.max(limit * 6, 150); 2005 + 2006 + const tweets = dbService.getRecentProcessedTweets(scanLimit); 2007 + const filtered = canViewAllMappings(req.user) 2008 + ? tweets 2009 + : tweets.filter( 2010 + (tweet) => 2011 + visibleSets.twitterUsernames.has(normalizeActor(tweet.twitter_username)) || 2012 + visibleSets.bskyIdentifiers.has(normalizeActor(tweet.bsky_identifier)), 2013 + ); 2014 + 2015 + res.json(filtered.slice(0, limit)); 1129 2016 }); 1130 2017 1131 2018 app.post('/api/bsky/profiles', authenticateToken, async (req, res) => { ··· 1143 2030 res.json(profiles); 1144 2031 }); 1145 2032 1146 - app.get('/api/posts/search', authenticateToken, (req, res) => { 2033 + app.get('/api/posts/search', authenticateToken, (req: any, res) => { 1147 2034 const query = typeof req.query.q === 'string' ? req.query.q : ''; 1148 2035 if (!query.trim()) { 1149 2036 res.json([]); ··· 1152 2039 1153 2040 const requestedLimit = req.query.limit ? Number(req.query.limit) : 80; 1154 2041 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 200)) : 80; 2042 + const searchLimit = Math.min(200, Math.max(80, limit * 4)); 2043 + const config = getConfig(); 2044 + const visibleSets = getVisibleMappingIdentitySets(config, req.user); 1155 2045 1156 - const results = dbService.searchMigratedTweets(query, limit).map<LocalPostSearchResult>((row) => ({ 2046 + const scopedRows = dbService 2047 + .searchMigratedTweets(query, searchLimit) 2048 + .filter( 2049 + (row) => 2050 + canViewAllMappings(req.user) || 2051 + visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 2052 + visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier)), 2053 + ) 2054 + .slice(0, limit); 2055 + 2056 + const results = scopedRows.map<LocalPostSearchResult>((row) => ({ 1157 2057 twitterId: row.twitter_id, 1158 2058 twitterUsername: row.twitter_username, 1159 2059 bskyIdentifier: row.bsky_identifier, ··· 1169 2069 res.json(results); 1170 2070 }); 1171 2071 1172 - app.get('/api/posts/enriched', authenticateToken, async (req, res) => { 2072 + app.get('/api/posts/enriched', authenticateToken, async (req: any, res) => { 1173 2073 const requestedLimit = req.query.limit ? Number(req.query.limit) : 24; 1174 2074 const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 80)) : 24; 2075 + const config = getConfig(); 2076 + const visibleSets = getVisibleMappingIdentitySets(config, req.user); 1175 2077 1176 - const recent = dbService.getRecentProcessedTweets(limit * 4); 1177 - const migratedWithUri = recent.filter((row) => row.status === 'migrated' && row.bsky_uri); 2078 + const recent = dbService.getRecentProcessedTweets(limit * 8); 2079 + const migratedWithUri = recent.filter( 2080 + (row) => 2081 + row.status === 'migrated' && 2082 + row.bsky_uri && 2083 + (canViewAllMappings(req.user) || 2084 + visibleSets.twitterUsernames.has(normalizeActor(row.twitter_username)) || 2085 + visibleSets.bskyIdentifiers.has(normalizeActor(row.bsky_identifier))), 2086 + ); 1178 2087 1179 2088 const deduped: ProcessedTweet[] = []; 1180 2089 const seenUris = new Set<string>(); ··· 1192 2101 1193 2102 res.json(enriched); 1194 2103 }); 1195 - 1196 2104 // Export for use by index.ts 1197 2105 export function updateLastCheckTime() { 1198 2106 const config = getConfig(); ··· 1230 2138 }); 1231 2139 1232 2140 export function startServer() { 1233 - app.listen(PORT, '0.0.0.0' as any, () => { 2141 + app.listen(PORT, HOST as any, () => { 1234 2142 console.log(`🚀 Web interface running at http://localhost:${PORT}`); 2143 + if (HOST === '127.0.0.1' || HOST === '::1' || HOST === 'localhost') { 2144 + console.log(`🔒 Bound to ${HOST} (local-only). Use Tailscale Serve or a reverse proxy for remote access.`); 2145 + return; 2146 + } 1235 2147 console.log('📡 Accessible on your local network/Tailscale via your IP.'); 1236 2148 }); 1237 2149 }
+442 -56
update.sh
··· 1 - #!/bin/bash 1 + #!/usr/bin/env bash 2 2 3 3 set -euo pipefail 4 4 5 + APP_NAME="tweets-2-bsky" 6 + LEGACY_APP_NAME="twitter-mirror" 5 7 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 8 cd "$SCRIPT_DIR" 7 9 8 - echo "🔄 Tweets-2-Bsky Updater" 9 - echo "=========================" 10 + RUNTIME_DIR="$SCRIPT_DIR/data/runtime" 11 + PID_FILE="$RUNTIME_DIR/${APP_NAME}.pid" 12 + LOCK_DIR="$RUNTIME_DIR/.update.lock" 10 13 11 14 CONFIG_FILE="$SCRIPT_DIR/config.json" 12 - CONFIG_BACKUP="" 15 + ENV_FILE="$SCRIPT_DIR/.env" 16 + 17 + DO_INSTALL=1 18 + DO_BUILD=1 19 + DO_NATIVE_REBUILD=1 20 + DO_RESTART=1 21 + REMOTE_OVERRIDE="" 22 + BRANCH_OVERRIDE="" 23 + 24 + STASH_REF="" 25 + STASH_CREATED=0 26 + STASH_RESTORED=0 27 + 28 + BACKUP_SOURCES=() 29 + BACKUP_PATHS=() 30 + 31 + usage() { 32 + cat <<'USAGE' 33 + Usage: ./update.sh [options] 34 + 35 + Default behavior: 36 + - Pull latest git changes safely 37 + - Install dependencies 38 + - Rebuild native modules if needed 39 + - Build server + web dashboard 40 + - Restart existing runtime (PM2 or nohup) when possible 41 + 42 + Options: 43 + --remote <name> Git remote to pull from (default: origin or first remote) 44 + --branch <name> Git branch to pull (default: current branch or remote HEAD) 45 + --skip-install Skip npm install 46 + --skip-build Skip npm run build 47 + --skip-native-rebuild Skip native-module rebuild checks 48 + --no-restart Do not restart process after update 49 + -h, --help Show this help 50 + USAGE 51 + } 52 + 53 + require_command() { 54 + local command_name="$1" 55 + if ! command -v "$command_name" >/dev/null 2>&1; then 56 + echo "❌ Required command not found: $command_name" 57 + exit 1 58 + fi 59 + } 60 + 61 + check_node_version() { 62 + local node_major 63 + node_major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)" 64 + if [[ "$node_major" -lt 22 ]]; then 65 + echo "❌ Node.js 22+ is required. Current: $(node -v 2>/dev/null || echo 'unknown')" 66 + exit 1 67 + fi 68 + } 69 + 70 + acquire_lock() { 71 + mkdir -p "$RUNTIME_DIR" 72 + if ! mkdir "$LOCK_DIR" 2>/dev/null; then 73 + echo "❌ Another update appears to be running." 74 + echo " If this is stale, remove: $LOCK_DIR" 75 + exit 1 76 + fi 77 + } 78 + 79 + release_lock() { 80 + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true 81 + } 82 + 83 + backup_file() { 84 + local file="$1" 85 + if [[ ! -f "$file" ]]; then 86 + return 0 87 + fi 88 + 89 + local base 90 + base="$(basename "$file")" 91 + local backup_path 92 + backup_path="$(mktemp "${TMPDIR:-/tmp}/tweets2bsky-${base}.XXXXXX")" 93 + cp "$file" "$backup_path" 94 + BACKUP_SOURCES+=("$file") 95 + BACKUP_PATHS+=("$backup_path") 96 + } 97 + 98 + restore_backups() { 99 + local idx 100 + for idx in "${!BACKUP_SOURCES[@]}"; do 101 + local src="${BACKUP_SOURCES[$idx]}" 102 + local bak="${BACKUP_PATHS[$idx]}" 103 + if [[ -f "$bak" ]]; then 104 + cp "$bak" "$src" 105 + rm -f "$bak" 106 + fi 107 + done 108 + } 109 + 110 + cleanup() { 111 + restore_backups 112 + release_lock 113 + } 114 + 115 + ensure_git_repo() { 116 + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then 117 + echo "❌ This directory is not a git repository: $SCRIPT_DIR" 118 + exit 1 119 + fi 120 + } 121 + 122 + resolve_remote() { 123 + if [[ -n "$REMOTE_OVERRIDE" ]]; then 124 + if ! git remote | grep -qx "$REMOTE_OVERRIDE"; then 125 + echo "❌ Remote '$REMOTE_OVERRIDE' does not exist." 126 + exit 1 127 + fi 128 + printf '%s\n' "$REMOTE_OVERRIDE" 129 + return 0 130 + fi 13 131 14 - if [ -f "$CONFIG_FILE" ]; then 15 - CONFIG_BACKUP="$(mktemp "${TMPDIR:-/tmp}/tweets2bsky-config.XXXXXX")" 16 - cp "$CONFIG_FILE" "$CONFIG_BACKUP" 17 - echo "🛡️ Backed up config.json to protect local settings." 18 - fi 132 + if git remote | grep -qx "origin"; then 133 + printf '%s\n' "origin" 134 + return 0 135 + fi 19 136 20 - restore_config() { 21 - if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then 22 - cp "$CONFIG_BACKUP" "$CONFIG_FILE" 23 - rm -f "$CONFIG_BACKUP" 24 - echo "🔐 Restored config.json." 137 + local first_remote 138 + first_remote="$(git remote | head -n 1)" 139 + if [[ -z "$first_remote" ]]; then 140 + echo "❌ No git remote configured." 141 + exit 1 25 142 fi 143 + 144 + printf '%s\n' "$first_remote" 26 145 } 27 146 28 - trap restore_config EXIT 147 + resolve_branch() { 148 + local remote="$1" 29 149 30 - if ! command -v git >/dev/null 2>&1; then 31 - echo "❌ Git is not installed. Please install git to update." 32 - exit 1 33 - fi 150 + if [[ -n "$BRANCH_OVERRIDE" ]]; then 151 + printf '%s\n' "$BRANCH_OVERRIDE" 152 + return 0 153 + fi 154 + 155 + local current_branch 156 + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" 157 + if [[ -n "$current_branch" ]] && git show-ref --verify --quiet "refs/remotes/${remote}/${current_branch}"; then 158 + printf '%s\n' "$current_branch" 159 + return 0 160 + fi 161 + 162 + local remote_head 163 + remote_head="$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null || true)" 164 + if [[ -n "$remote_head" ]]; then 165 + printf '%s\n' "${remote_head#${remote}/}" 166 + return 0 167 + fi 34 168 35 - if ! command -v npm >/dev/null 2>&1; then 36 - echo "❌ npm is not installed. Please install Node.js/npm to update." 169 + if git show-ref --verify --quiet "refs/remotes/${remote}/main"; then 170 + printf '%s\n' "main" 171 + return 0 172 + fi 173 + 174 + if git show-ref --verify --quiet "refs/remotes/${remote}/master"; then 175 + printf '%s\n' "master" 176 + return 0 177 + fi 178 + 179 + if [[ -n "$current_branch" ]]; then 180 + printf '%s\n' "$current_branch" 181 + return 0 182 + fi 183 + 184 + echo "❌ Could not determine target branch for remote '$remote'." 37 185 exit 1 38 - fi 186 + } 187 + 188 + working_tree_dirty() { 189 + [[ -n "$(git status --porcelain --untracked-files=normal)" ]] 190 + } 191 + 192 + stash_local_changes() { 193 + if ! working_tree_dirty; then 194 + return 0 195 + fi 196 + 197 + echo "🧳 Stashing local changes before update..." 198 + 199 + local before after message 200 + before="$(git stash list -n 1 --format=%gd || true)" 201 + message="tweets-2-bsky-update-autostash-$(date -u +%Y%m%d-%H%M%S)" 202 + git stash push -u -m "$message" >/dev/null 203 + after="$(git stash list -n 1 --format=%gd || true)" 39 204 40 - echo "⬇️ Pulling latest changes..." 41 - if ! git pull --autostash; then 42 - echo "⚠️ Standard pull failed. Attempting to stash local changes and retry..." 43 - git stash push -u -m "tweets-2-bsky-update-autostash" 44 - if ! git pull; then 45 - echo "❌ Git pull failed even after stashing. Resolve conflicts manually and rerun ./update.sh." 205 + if [[ -n "$after" && "$after" != "$before" ]]; then 206 + STASH_REF="$after" 207 + STASH_CREATED=1 208 + echo "✅ Saved local changes to $STASH_REF" 209 + fi 210 + } 211 + 212 + restore_stash_if_needed() { 213 + if [[ "$STASH_CREATED" -ne 1 || -z "$STASH_REF" ]]; then 214 + return 0 215 + fi 216 + 217 + echo "🔁 Restoring stashed local changes ($STASH_REF)..." 218 + if git stash apply --index "$STASH_REF" >/dev/null 2>&1; then 219 + git stash drop "$STASH_REF" >/dev/null 2>&1 || true 220 + STASH_RESTORED=1 221 + echo "✅ Restored local changes from stash." 222 + else 223 + echo "⚠️ Could not auto-apply $STASH_REF cleanly." 224 + echo " Your changes are still preserved in stash." 225 + echo " Review manually with: git stash show -p $STASH_REF" 226 + fi 227 + } 228 + 229 + checkout_branch() { 230 + local remote="$1" 231 + local target_branch="$2" 232 + 233 + if ! git show-ref --verify --quiet "refs/remotes/${remote}/${target_branch}"; then 234 + echo "❌ Remote branch not found: ${remote}/${target_branch}" 46 235 exit 1 47 236 fi 48 - fi 49 237 50 - echo "📦 Installing dependencies..." 51 - npm install 238 + local current_branch 239 + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" 52 240 53 - echo "🔧 Verifying native modules..." 54 - if ! npm run rebuild:native; then 55 - echo "⚠️ rebuild:native failed (or missing). Falling back to direct better-sqlite3 rebuild..." 56 - if ! npm rebuild better-sqlite3; then 57 - npm rebuild better-sqlite3 --build-from-source 241 + if [[ "$current_branch" == "$target_branch" ]]; then 242 + return 0 243 + fi 244 + 245 + if git show-ref --verify --quiet "refs/heads/${target_branch}"; then 246 + git switch "$target_branch" >/dev/null 2>&1 || git checkout "$target_branch" >/dev/null 2>&1 247 + else 248 + git switch -c "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1 || \ 249 + git checkout -b "$target_branch" --track "${remote}/${target_branch}" >/dev/null 2>&1 250 + fi 251 + } 252 + 253 + pull_latest() { 254 + local remote="$1" 255 + local branch="$2" 256 + 257 + echo "⬇️ Fetching latest changes from $remote..." 258 + git fetch "$remote" --prune 259 + 260 + checkout_branch "$remote" "$branch" 261 + git branch --set-upstream-to="${remote}/${branch}" "$branch" >/dev/null 2>&1 || true 262 + 263 + echo "🔄 Pulling latest changes from ${remote}/${branch}..." 264 + if ! git pull --ff-only "$remote" "$branch"; then 265 + echo "ℹ️ Fast-forward pull failed, retrying with rebase..." 266 + git pull --rebase "$remote" "$branch" 267 + fi 268 + } 269 + 270 + native_module_compatible() { 271 + node -e "try{require('better-sqlite3');process.exit(0)}catch(e){console.error(e && e.message ? e.message : e);process.exit(1)}" >/dev/null 2>&1 272 + } 273 + 274 + rebuild_native_modules() { 275 + if [[ "$DO_NATIVE_REBUILD" -eq 0 ]]; then 276 + return 0 277 + fi 278 + 279 + if native_module_compatible; then 280 + return 0 281 + fi 282 + 283 + echo "🔧 Native module mismatch detected, rebuilding..." 284 + if npm run rebuild:native; then 285 + return 0 286 + fi 287 + 288 + echo "⚠️ rebuild:native failed, trying npm rebuild better-sqlite3..." 289 + if npm rebuild better-sqlite3; then 290 + return 0 291 + fi 292 + 293 + npm rebuild better-sqlite3 --build-from-source 294 + } 295 + 296 + install_dependencies() { 297 + if [[ "$DO_INSTALL" -ne 1 ]]; then 298 + return 0 299 + fi 300 + 301 + echo "📦 Installing dependencies..." 302 + npm install --no-audit --no-fund 303 + } 304 + 305 + build_project() { 306 + if [[ "$DO_BUILD" -ne 1 ]]; then 307 + return 0 308 + fi 309 + 310 + echo "🏗️ Building server + web dashboard..." 311 + npm run build 312 + } 313 + 314 + pm2_has_process() { 315 + local name="$1" 316 + command -v pm2 >/dev/null 2>&1 && pm2 describe "$name" >/dev/null 2>&1 317 + } 318 + 319 + nohup_process_running() { 320 + if [[ ! -f "$PID_FILE" ]]; then 321 + return 1 322 + fi 323 + 324 + local pid 325 + pid="$(cat "$PID_FILE" 2>/dev/null || true)" 326 + if [[ -z "$pid" ]]; then 327 + return 1 58 328 fi 59 - fi 60 329 61 - echo "🏗️ Building server + web dashboard..." 62 - npm run build 330 + if ! kill -0 "$pid" >/dev/null 2>&1; then 331 + return 1 332 + fi 333 + 334 + local cmd 335 + cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" 336 + [[ "$cmd" == *"dist/index.js"* || "$cmd" == *"npm start"* || "$cmd" == *"$APP_NAME"* ]] 337 + } 338 + 339 + restart_runtime() { 340 + if [[ "$DO_RESTART" -ne 1 ]]; then 341 + echo "⏭️ Skipping restart (--no-restart)." 342 + return 0 343 + fi 344 + 345 + echo "🔄 Restarting runtime..." 346 + 347 + if pm2_has_process "$APP_NAME"; then 348 + pm2 restart "$APP_NAME" --update-env >/dev/null 2>&1 || { 349 + echo "⚠️ PM2 restart failed for $APP_NAME. Recreating process..." 350 + pm2 delete "$APP_NAME" >/dev/null 2>&1 || true 351 + pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1 352 + } 353 + pm2 save >/dev/null 2>&1 || true 354 + echo "✅ Restarted PM2 process: $APP_NAME" 355 + return 0 356 + fi 357 + 358 + if pm2_has_process "$LEGACY_APP_NAME"; then 359 + pm2 restart "$LEGACY_APP_NAME" --update-env >/dev/null 2>&1 || { 360 + echo "⚠️ PM2 restart failed for $LEGACY_APP_NAME. Recreating under $APP_NAME..." 361 + pm2 delete "$LEGACY_APP_NAME" >/dev/null 2>&1 || true 362 + pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env >/dev/null 2>&1 363 + } 364 + pm2 save >/dev/null 2>&1 || true 365 + echo "✅ Restarted PM2 process." 366 + return 0 367 + fi 63 368 64 - echo "✅ Update complete!" 369 + if nohup_process_running; then 370 + bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null 371 + echo "✅ Restarted nohup runtime." 372 + return 0 373 + fi 65 374 66 - if command -v pm2 >/dev/null 2>&1; then 67 - PROCESS_NAME="tweets-2-bsky" 68 - if pm2 describe twitter-mirror >/dev/null 2>&1; then 69 - PROCESS_NAME="twitter-mirror" 70 - elif pm2 describe tweets-2-bsky >/dev/null 2>&1; then 71 - PROCESS_NAME="tweets-2-bsky" 375 + if command -v pm2 >/dev/null 2>&1; then 376 + bash "$SCRIPT_DIR/install.sh" --start-only --pm2 --skip-native-rebuild >/dev/null 377 + echo "✅ Started PM2 runtime (was not running)." 378 + return 0 72 379 fi 73 380 74 - echo "🔄 Restarting PM2 process '$PROCESS_NAME'..." 75 - if ! pm2 restart "$PROCESS_NAME" --update-env >/dev/null 2>&1; then 76 - echo "ℹ️ PM2 process '$PROCESS_NAME' not found or restart failed. Recreating..." 77 - pm2 delete "$PROCESS_NAME" >/dev/null 2>&1 || true 78 - pm2 start dist/index.js --name "$PROCESS_NAME" 381 + bash "$SCRIPT_DIR/install.sh" --start-only --nohup --skip-native-rebuild >/dev/null 382 + echo "✅ Started nohup runtime (was not running)." 383 + } 384 + 385 + print_summary() { 386 + echo "" 387 + echo "✅ Update complete!" 388 + echo "" 389 + echo "Current commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" 390 + echo "Current branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')" 391 + 392 + if [[ "$STASH_CREATED" -eq 1 ]]; then 393 + if [[ "$STASH_RESTORED" -eq 1 ]]; then 394 + echo "Stash restore: restored" 395 + else 396 + echo "Stash restore: pending manual apply ($STASH_REF)" 397 + fi 79 398 fi 80 - pm2 save 81 - echo "✅ PM2 process restarted and saved." 82 - else 83 - echo "⚠️ PM2 not found. Please restart your application manually." 84 - fi 399 + } 400 + 401 + while [[ $# -gt 0 ]]; do 402 + case "$1" in 403 + --remote) 404 + if [[ $# -lt 2 ]]; then 405 + echo "Missing value for --remote" 406 + exit 1 407 + fi 408 + REMOTE_OVERRIDE="$2" 409 + shift 410 + ;; 411 + --branch) 412 + if [[ $# -lt 2 ]]; then 413 + echo "Missing value for --branch" 414 + exit 1 415 + fi 416 + BRANCH_OVERRIDE="$2" 417 + shift 418 + ;; 419 + --skip-install) 420 + DO_INSTALL=0 421 + ;; 422 + --skip-build) 423 + DO_BUILD=0 424 + ;; 425 + --skip-native-rebuild) 426 + DO_NATIVE_REBUILD=0 427 + ;; 428 + --no-restart) 429 + DO_RESTART=0 430 + ;; 431 + -h|--help) 432 + usage 433 + exit 0 434 + ;; 435 + *) 436 + echo "Unknown option: $1" 437 + usage 438 + exit 1 439 + ;; 440 + esac 441 + shift 442 + done 443 + 444 + echo "🔄 Tweets-2-Bsky Updater" 445 + echo "=========================" 446 + 447 + require_command git 448 + require_command node 449 + require_command npm 450 + check_node_version 451 + ensure_git_repo 452 + 453 + acquire_lock 454 + trap cleanup EXIT 455 + 456 + backup_file "$CONFIG_FILE" 457 + backup_file "$ENV_FILE" 458 + 459 + stash_local_changes 460 + 461 + remote="$(resolve_remote)" 462 + branch="$(resolve_branch "$remote")" 463 + 464 + pull_latest "$remote" "$branch" 465 + install_dependencies 466 + rebuild_native_modules 467 + build_project 468 + restart_runtime 469 + restore_stash_if_needed 470 + print_summary
+1507 -496
web/src/App.tsx
··· 1 1 import axios from 'axios'; 2 2 import { 3 - AlertTriangle, 4 3 ArrowUpRight, 5 4 Bot, 5 + ChevronDown, 6 6 ChevronLeft, 7 - ChevronDown, 8 7 ChevronRight, 9 8 Clock3, 10 9 Download, ··· 43 42 type ThemeMode = 'system' | 'light' | 'dark'; 44 43 type AuthView = 'login' | 'register'; 45 44 type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings'; 46 - type SettingsSection = 'twitter' | 'ai' | 'data'; 45 + type SettingsSection = 'account' | 'users' | 'twitter' | 'ai' | 'data'; 47 46 48 47 type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 49 48 ··· 51 50 id: string; 52 51 twitterUsernames: string[]; 53 52 bskyIdentifier: string; 54 - bskyPassword: string; 53 + bskyPassword?: string; 55 54 bskyServiceUrl?: string; 56 55 enabled: boolean; 57 56 owner?: string; 58 57 groupName?: string; 59 58 groupEmoji?: string; 59 + createdByUserId?: string; 60 + createdByLabel?: string; 61 + createdByUser?: { 62 + id: string; 63 + username?: string; 64 + email?: string; 65 + role: 'admin' | 'user'; 66 + }; 60 67 } 61 68 62 69 interface AccountGroup { ··· 200 207 currentStatus: StatusState; 201 208 } 202 209 210 + interface UserPermissions { 211 + viewAllMappings: boolean; 212 + manageOwnMappings: boolean; 213 + manageAllMappings: boolean; 214 + manageGroups: boolean; 215 + queueBackfills: boolean; 216 + runNow: boolean; 217 + } 218 + 203 219 interface AuthUser { 204 - email: string; 220 + id: string; 221 + username?: string; 222 + email?: string; 223 + isAdmin: boolean; 224 + permissions: UserPermissions; 225 + } 226 + 227 + interface ManagedUser { 228 + id: string; 229 + username?: string; 230 + email?: string; 231 + role: 'admin' | 'user'; 205 232 isAdmin: boolean; 233 + permissions: UserPermissions; 234 + createdAt: string; 235 + updatedAt: string; 236 + mappingCount: number; 237 + activeMappingCount: number; 238 + mappings: AccountMapping[]; 239 + } 240 + 241 + interface BootstrapStatus { 242 + bootstrapOpen: boolean; 206 243 } 207 244 208 245 interface RuntimeVersionInfo { ··· 238 275 groupEmoji: string; 239 276 } 240 277 278 + interface UserFormState { 279 + username: string; 280 + email: string; 281 + password: string; 282 + isAdmin: boolean; 283 + permissions: UserPermissions; 284 + } 285 + 286 + interface AccountSecurityEmailState { 287 + currentEmail: string; 288 + newEmail: string; 289 + password: string; 290 + } 291 + 292 + interface AccountSecurityPasswordState { 293 + currentPassword: string; 294 + newPassword: string; 295 + confirmPassword: string; 296 + } 297 + 241 298 const defaultMappingForm = (): MappingFormState => ({ 242 299 owner: '', 243 300 bskyIdentifier: '', ··· 247 304 groupEmoji: '📁', 248 305 }); 249 306 307 + const defaultUserForm = (): UserFormState => ({ 308 + username: '', 309 + email: '', 310 + password: '', 311 + isAdmin: false, 312 + permissions: { ...DEFAULT_USER_PERMISSIONS }, 313 + }); 314 + 250 315 const DEFAULT_GROUP_NAME = 'Ungrouped'; 251 316 const DEFAULT_GROUP_EMOJI = '📁'; 252 317 const DEFAULT_GROUP_KEY = 'ungrouped'; ··· 261 326 const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const; 262 327 const ACCOUNT_SEARCH_MIN_SCORE = 22; 263 328 const DEFAULT_BACKFILL_LIMIT = 15; 329 + const DEFAULT_USER_PERMISSIONS: UserPermissions = { 330 + viewAllMappings: false, 331 + manageOwnMappings: true, 332 + manageAllMappings: false, 333 + manageGroups: false, 334 + queueBackfills: true, 335 + runNow: true, 336 + }; 337 + const PERMISSION_OPTIONS: Array<{ 338 + key: keyof UserPermissions; 339 + label: string; 340 + help: string; 341 + }> = [ 342 + { key: 'viewAllMappings', label: 'View all mappings', help: 'See every mapped account, post, and activity row.' }, 343 + { key: 'manageOwnMappings', label: 'Manage own mappings', help: 'Create, edit, and delete mappings this user owns.' }, 344 + { key: 'manageAllMappings', label: 'Manage all mappings', help: 'Edit/delete mappings created by any user.' }, 345 + { key: 'manageGroups', label: 'Manage groups', help: 'Create, rename, and delete account groups.' }, 346 + { key: 'queueBackfills', label: 'Queue backfills', help: 'Queue backfills for mappings they can manage.' }, 347 + { key: 'runNow', label: 'Run checks now', help: 'Trigger an immediate scheduler run.' }, 348 + ]; 264 349 265 350 const selectClassName = 266 351 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'; ··· 356 441 return entry ? entry[0] : null; 357 442 } 358 443 444 + function normalizeEmail(value: string): string { 445 + return value.trim().toLowerCase(); 446 + } 447 + 448 + function normalizeUsername(value: string): string { 449 + return value.trim().replace(/^@/, '').toLowerCase(); 450 + } 451 + 452 + function getUserLabel(user?: Pick<AuthUser, 'username' | 'email'>): string { 453 + return user?.username || user?.email || 'user'; 454 + } 455 + 456 + function normalizePermissions(permissions?: Partial<UserPermissions>): UserPermissions { 457 + return { 458 + ...DEFAULT_USER_PERMISSIONS, 459 + ...(permissions || {}), 460 + }; 461 + } 462 + 359 463 function addTwitterUsernames(current: string[], value: string): string[] { 360 464 const candidates = value 361 465 .split(/[\s,]+/) ··· 522 626 } else if (feature.$type === 'app.bsky.richtext.facet#mention' && feature.did) { 523 627 segments.push({ type: 'mention', text: rawText, href: `https://bsky.app/profile/${feature.did}` }); 524 628 } else if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { 525 - segments.push({ type: 'tag', text: rawText, href: `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}` }); 629 + segments.push({ 630 + type: 'tag', 631 + text: rawText, 632 + href: `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`, 633 + }); 526 634 } else { 527 635 segments.push({ type: 'text', text: rawText }); 528 636 } ··· 548 656 function App() { 549 657 const [token, setToken] = useState<string | null>(() => localStorage.getItem('token')); 550 658 const [authView, setAuthView] = useState<AuthView>('login'); 659 + const [bootstrapOpen, setBootstrapOpen] = useState(false); 551 660 const [themeMode, setThemeMode] = useState<ThemeMode>(() => { 552 661 const saved = localStorage.getItem('theme-mode'); 553 662 if (saved === 'light' || saved === 'dark' || saved === 'system') { ··· 599 708 const [newGroupEmoji, setNewGroupEmoji] = useState(DEFAULT_GROUP_EMOJI); 600 709 const [isAddAccountSheetOpen, setIsAddAccountSheetOpen] = useState(false); 601 710 const [addAccountStep, setAddAccountStep] = useState(1); 602 - const [settingsSectionOverrides, setSettingsSectionOverrides] = useState<Partial<Record<SettingsSection, boolean>>>({}); 711 + const [settingsSectionOverrides, setSettingsSectionOverrides] = useState<Partial<Record<SettingsSection, boolean>>>( 712 + {}, 713 + ); 603 714 const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Record<string, boolean>>(() => { 604 715 const raw = localStorage.getItem('accounts-collapsed-groups'); 605 716 if (!raw) return {}; ··· 620 731 const [groupDraftsByKey, setGroupDraftsByKey] = useState<Record<string, { name: string; emoji: string }>>({}); 621 732 const [isGroupActionBusy, setIsGroupActionBusy] = useState(false); 622 733 const [notice, setNotice] = useState<Notice | null>(null); 734 + const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]); 735 + const [accountsCreatorFilter, setAccountsCreatorFilter] = useState('all'); 736 + const [newUserForm, setNewUserForm] = useState<UserFormState>(defaultUserForm); 737 + const [editingUserId, setEditingUserId] = useState<string | null>(null); 738 + const [editingUserForm, setEditingUserForm] = useState<UserFormState>(defaultUserForm); 739 + const [emailForm, setEmailForm] = useState<AccountSecurityEmailState>({ 740 + currentEmail: '', 741 + newEmail: '', 742 + password: '', 743 + }); 744 + const [passwordForm, setPasswordForm] = useState<AccountSecurityPasswordState>({ 745 + currentPassword: '', 746 + newPassword: '', 747 + confirmPassword: '', 748 + }); 623 749 624 750 const [isBusy, setIsBusy] = useState(false); 625 751 const [isUpdateBusy, setIsUpdateBusy] = useState(false); ··· 630 756 const postsSearchRequestRef = useRef(0); 631 757 632 758 const isAdmin = me?.isAdmin ?? false; 759 + const effectivePermissions = useMemo<UserPermissions>(() => normalizePermissions(me?.permissions), [me?.permissions]); 760 + const canManageAllMappings = isAdmin || effectivePermissions.manageAllMappings; 761 + const canManageOwnMappings = isAdmin || effectivePermissions.manageOwnMappings; 762 + const canCreateMappings = canManageAllMappings || canManageOwnMappings; 763 + const canManageGroupsPermission = isAdmin || effectivePermissions.manageGroups; 764 + const canQueueBackfillsPermission = isAdmin || effectivePermissions.queueBackfills; 765 + const canRunNowPermission = isAdmin || effectivePermissions.runNow; 766 + const hasCurrentEmail = Boolean(me?.email && me.email.trim().length > 0); 633 767 const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]); 634 768 635 769 const showNotice = useCallback((tone: Notice['tone'], message: string) => { ··· 670 804 setGroupDraftsByKey({}); 671 805 setIsGroupActionBusy(false); 672 806 setIsUpdateBusy(false); 807 + setManagedUsers([]); 808 + setAccountsCreatorFilter('all'); 809 + setNewUserForm(defaultUserForm()); 810 + setEditingUserId(null); 811 + setEditingUserForm(defaultUserForm()); 812 + setEmailForm({ currentEmail: '', newEmail: '', password: '' }); 813 + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); 673 814 postsSearchRequestRef.current = 0; 674 815 setAuthView('login'); 675 816 }, []); ··· 685 826 [handleLogout, showNotice], 686 827 ); 687 828 829 + const fetchBootstrapStatus = useCallback(async () => { 830 + try { 831 + const response = await axios.get<BootstrapStatus>('/api/auth/bootstrap-status'); 832 + setBootstrapOpen(Boolean(response.data?.bootstrapOpen)); 833 + } catch { 834 + setBootstrapOpen(false); 835 + } 836 + }, []); 837 + 688 838 const fetchStatus = useCallback(async () => { 689 839 if (!authHeaders) { 690 840 return; ··· 763 913 } 764 914 }, [authHeaders, handleAuthFailure, isAdmin]); 765 915 916 + const fetchManagedUsers = useCallback(async () => { 917 + if (!authHeaders || !isAdmin) { 918 + setManagedUsers([]); 919 + return; 920 + } 921 + 922 + try { 923 + const response = await axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }); 924 + setManagedUsers(Array.isArray(response.data) ? response.data : []); 925 + } catch (error) { 926 + handleAuthFailure(error, 'Failed to fetch dashboard users.'); 927 + } 928 + }, [authHeaders, handleAuthFailure, isAdmin]); 929 + 766 930 const fetchProfiles = useCallback( 767 931 async (actors: string[]) => { 768 932 if (!authHeaders) { ··· 802 966 ]); 803 967 804 968 const profile = meResponse.data; 805 - const mappingData = mappingsResponse.data; 969 + const mappingData = Array.isArray(mappingsResponse.data) ? mappingsResponse.data : []; 806 970 const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; 807 - setMe(profile); 971 + setMe({ 972 + ...profile, 973 + permissions: normalizePermissions(profile.permissions), 974 + }); 808 975 setMappings(mappingData); 809 976 setGroups(groupData); 977 + setEmailForm((previous) => ({ 978 + ...previous, 979 + currentEmail: profile.email || '', 980 + })); 810 981 const versionResponse = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 811 982 setRuntimeVersion(versionResponse.data); 812 983 813 984 if (profile.isAdmin) { 814 - const [twitterResponse, aiResponse, updateStatusResponse] = await Promise.all([ 985 + const [twitterResponse, aiResponse, updateStatusResponse, usersResponse] = await Promise.all([ 815 986 axios.get<TwitterConfig>('/api/twitter-config', { headers: authHeaders }), 816 987 axios.get<AIConfig>('/api/ai-config', { headers: authHeaders }), 817 988 axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }), 989 + axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }), 818 990 ]); 819 991 820 992 setTwitterConfig({ ··· 831 1003 baseUrl: aiResponse.data.baseUrl || '', 832 1004 }); 833 1005 setUpdateStatus(updateStatusResponse.data); 1006 + setManagedUsers(Array.isArray(usersResponse.data) ? usersResponse.data : []); 834 1007 } else { 835 1008 setUpdateStatus(null); 1009 + setManagedUsers([]); 836 1010 } 837 1011 838 1012 await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); ··· 879 1053 }, [collapsedGroupKeys]); 880 1054 881 1055 useEffect(() => { 882 - if (!isAdmin && activeTab === 'settings') { 883 - setActiveTab('overview'); 884 - } 885 - }, [activeTab, isAdmin]); 886 - 887 - useEffect(() => { 888 1056 const media = window.matchMedia('(prefers-color-scheme: dark)'); 889 1057 890 1058 const applyTheme = () => { ··· 904 1072 905 1073 useEffect(() => { 906 1074 if (!token) { 1075 + void fetchBootstrapStatus(); 907 1076 return; 908 1077 } 909 1078 910 1079 void fetchData(); 911 - }, [token, fetchData]); 1080 + }, [token, fetchBootstrapStatus, fetchData]); 1081 + 1082 + useEffect(() => { 1083 + if (!bootstrapOpen && authView === 'register') { 1084 + setAuthView('login'); 1085 + } 1086 + }, [authView, bootstrapOpen]); 912 1087 913 1088 useEffect(() => { 914 1089 if (!token) { ··· 1006 1181 const currentStatus = status?.currentStatus; 1007 1182 const latestActivity = recentActivity[0]; 1008 1183 const dashboardTabs = useMemo( 1009 - () => 1010 - [ 1011 - { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, 1012 - { id: 'accounts' as DashboardTab, label: 'Accounts', icon: Users }, 1013 - { id: 'posts' as DashboardTab, label: 'Posts', icon: Newspaper }, 1014 - { id: 'activity' as DashboardTab, label: 'Activity', icon: History }, 1015 - { id: 'settings' as DashboardTab, label: 'Settings', icon: Settings2, adminOnly: true }, 1016 - ].filter((tab) => (tab.adminOnly ? isAdmin : true)), 1017 - [isAdmin], 1018 - ); 1019 - const postedActivity = useMemo( 1020 - () => enrichedPosts.slice(0, 12), 1021 - [enrichedPosts], 1184 + () => [ 1185 + { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, 1186 + { id: 'accounts' as DashboardTab, label: 'Accounts', icon: Users }, 1187 + { id: 'posts' as DashboardTab, label: 'Posts', icon: Newspaper }, 1188 + { id: 'activity' as DashboardTab, label: 'Activity', icon: History }, 1189 + { id: 'settings' as DashboardTab, label: 'Settings', icon: Settings2 }, 1190 + ], 1191 + [], 1022 1192 ); 1193 + const postedActivity = useMemo(() => enrichedPosts.slice(0, 12), [enrichedPosts]); 1023 1194 const engagementByAccount = useMemo(() => { 1024 1195 const map = new Map<string, { identifier: string; score: number; posts: number }>(); 1025 1196 for (const post of enrichedPosts) { ··· 1083 1254 () => groupOptions.filter((group) => group.key !== DEFAULT_GROUP_KEY), 1084 1255 [groupOptions], 1085 1256 ); 1257 + const managedUsersById = useMemo(() => new Map(managedUsers.map((user) => [user.id, user])), [managedUsers]); 1258 + const accountMappingsForView = useMemo(() => { 1259 + if (!isAdmin || accountsCreatorFilter === 'all') { 1260 + return mappings; 1261 + } 1262 + return mappings.filter((mapping) => mapping.createdByUserId === accountsCreatorFilter); 1263 + }, [accountsCreatorFilter, isAdmin, mappings]); 1086 1264 const groupedMappings = useMemo(() => { 1087 1265 const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>(); 1088 1266 for (const option of groupOptions) { ··· 1091 1269 mappings: [], 1092 1270 }); 1093 1271 } 1094 - for (const mapping of mappings) { 1272 + for (const mapping of accountMappingsForView) { 1095 1273 const group = getMappingGroupMeta(mapping); 1096 1274 const existing = groups.get(group.key); 1097 1275 if (!existing) { ··· 1117 1295 ), 1118 1296 ), 1119 1297 })); 1120 - }, [groupOptions, mappings]); 1298 + }, [accountMappingsForView, groupOptions]); 1121 1299 const normalizedAccountsQuery = useMemo(() => normalizeSearchValue(accountsSearchQuery), [accountsSearchQuery]); 1122 1300 const accountSearchTokens = useMemo(() => tokenizeSearchValue(normalizedAccountsQuery), [normalizedAccountsQuery]); 1123 1301 const accountSearchScores = useMemo(() => { ··· 1126 1304 return scores; 1127 1305 } 1128 1306 1129 - for (const mapping of mappings) { 1307 + for (const mapping of accountMappingsForView) { 1130 1308 scores.set(mapping.id, scoreAccountMapping(mapping, normalizedAccountsQuery, accountSearchTokens)); 1131 1309 } 1132 1310 return scores; 1133 - }, [accountSearchTokens, mappings, normalizedAccountsQuery]); 1311 + }, [accountMappingsForView, accountSearchTokens, normalizedAccountsQuery]); 1134 1312 const filteredGroupedMappings = useMemo(() => { 1135 1313 const hasQuery = normalizedAccountsQuery.length > 0; 1136 1314 const sortByScore = (items: AccountMapping[]) => { ··· 1164 1342 1165 1343 const allMappings = sortByScore( 1166 1344 hasQuery 1167 - ? mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE) 1168 - : [...mappings].sort((a, b) => 1345 + ? accountMappingsForView.filter( 1346 + (mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE, 1347 + ) 1348 + : [...accountMappingsForView].sort((a, b) => 1169 1349 `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1170 1350 `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1171 1351 ), ··· 1180 1360 mappings: allMappings, 1181 1361 }, 1182 1362 ]; 1183 - }, [accountSearchScores, accountsViewMode, groupedMappings, mappings, normalizedAccountsQuery]); 1363 + }, [accountMappingsForView, accountSearchScores, accountsViewMode, groupedMappings, normalizedAccountsQuery]); 1184 1364 const accountMatchesCount = useMemo( 1185 1365 () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), 1186 1366 [filteredGroupedMappings], 1187 1367 ); 1188 - const groupKeysForCollapse = useMemo( 1189 - () => groupedMappings.map((group) => group.key), 1190 - [groupedMappings], 1191 - ); 1368 + const groupKeysForCollapse = useMemo(() => groupedMappings.map((group) => group.key), [groupedMappings]); 1192 1369 const allGroupsCollapsed = useMemo( 1193 - () => groupKeysForCollapse.length > 0 && groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true), 1370 + () => 1371 + groupKeysForCollapse.length > 0 && 1372 + groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true), 1194 1373 [collapsedGroupKeys, groupKeysForCollapse], 1195 1374 ); 1196 1375 const resolveMappingForLocalPost = useCallback( ··· 1238 1417 }), 1239 1418 [activityGroupFilter, recentActivity, resolveMappingForActivity], 1240 1419 ); 1420 + const canManageMapping = useCallback( 1421 + (mapping: AccountMapping) => 1422 + canManageAllMappings || 1423 + (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), 1424 + [canManageAllMappings, canManageOwnMappings, me?.id], 1425 + ); 1241 1426 const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0); 1242 1427 const aiConfigured = Boolean(aiConfig.apiKey); 1243 1428 const sectionDefaultExpanded = useMemo<Record<SettingsSection, boolean>>( 1244 1429 () => ({ 1430 + account: true, 1431 + users: true, 1245 1432 twitter: !twitterConfigured, 1246 1433 ai: !aiConfigured, 1247 1434 data: false, ··· 1269 1456 }, [activityGroupFilter, groupOptions, postsGroupFilter]); 1270 1457 1271 1458 useEffect(() => { 1459 + if (!isAdmin) { 1460 + if (accountsCreatorFilter !== 'all') { 1461 + setAccountsCreatorFilter('all'); 1462 + } 1463 + return; 1464 + } 1465 + 1466 + if (accountsCreatorFilter !== 'all' && !managedUsers.some((user) => user.id === accountsCreatorFilter)) { 1467 + setAccountsCreatorFilter('all'); 1468 + } 1469 + }, [accountsCreatorFilter, isAdmin, managedUsers]); 1470 + 1471 + useEffect(() => { 1272 1472 setGroupDraftsByKey((previous) => { 1273 1473 const next: Record<string, { name: string; emoji: string }> = {}; 1274 1474 for (const group of reusableGroupOptions) { ··· 1361 1561 }; 1362 1562 1363 1563 const themeIcon = 1364 - themeMode === 'system' ? <SunMoon className="h-4 w-4" /> : themeMode === 'light' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />; 1564 + themeMode === 'system' ? ( 1565 + <SunMoon className="h-4 w-4" /> 1566 + ) : themeMode === 'light' ? ( 1567 + <Sun className="h-4 w-4" /> 1568 + ) : ( 1569 + <Moon className="h-4 w-4" /> 1570 + ); 1365 1571 1366 - const themeLabel = 1367 - themeMode === 'system' ? `Theme: system (${resolvedTheme})` : `Theme: ${themeMode}`; 1572 + const themeLabel = themeMode === 'system' ? `Theme: system (${resolvedTheme})` : `Theme: ${themeMode}`; 1368 1573 const runtimeVersionLabel = runtimeVersion 1369 1574 ? `v${runtimeVersion.version}${runtimeVersion.commit ? ` (${runtimeVersion.commit})` : ''}` 1370 1575 : 'v--'; ··· 1383 1588 setIsBusy(true); 1384 1589 1385 1590 const data = new FormData(event.currentTarget); 1386 - const email = String(data.get('email') || '').trim(); 1591 + const identifier = String(data.get('identifier') || '').trim(); 1387 1592 const password = String(data.get('password') || ''); 1388 1593 1389 1594 try { 1390 - const response = await axios.post<{ token: string }>('/api/login', { email, password }); 1595 + const response = await axios.post<{ token: string }>('/api/login', { identifier, password }); 1391 1596 localStorage.setItem('token', response.data.token); 1392 1597 setToken(response.data.token); 1393 1598 showNotice('success', 'Logged in.'); ··· 1404 1609 setIsBusy(true); 1405 1610 1406 1611 const data = new FormData(event.currentTarget); 1612 + const username = String(data.get('username') || '').trim(); 1407 1613 const email = String(data.get('email') || '').trim(); 1408 1614 const password = String(data.get('password') || ''); 1409 1615 1410 1616 try { 1411 - await axios.post('/api/register', { email, password }); 1617 + await axios.post('/api/register', { username, email, password }); 1412 1618 setAuthView('login'); 1413 1619 showNotice('success', 'Registration successful. Please log in.'); 1620 + await fetchBootstrapStatus(); 1414 1621 } catch (error) { 1415 1622 setAuthError(getApiErrorMessage(error, 'Registration failed.')); 1416 1623 } finally { ··· 1420 1627 1421 1628 const runNow = async () => { 1422 1629 if (!authHeaders) { 1630 + return; 1631 + } 1632 + if (!canRunNowPermission) { 1633 + showNotice('error', 'You do not have permission to run checks now.'); 1423 1634 return; 1424 1635 } 1425 1636 ··· 1455 1666 if (!authHeaders) { 1456 1667 return; 1457 1668 } 1669 + const mapping = mappings.find((entry) => entry.id === mappingId); 1670 + if (!mapping || !canQueueBackfillsPermission || !canManageMapping(mapping)) { 1671 + showNotice('error', 'You do not have permission to queue backfill for this account.'); 1672 + return; 1673 + } 1458 1674 1459 1675 const busy = pendingBackfills.length > 0 || currentStatus?.state === 'backfilling'; 1460 1676 if (busy) { ··· 1469 1685 1470 1686 try { 1471 1687 if (mode === 'reset') { 1688 + if (!isAdmin) { 1689 + showNotice('error', 'Only admins can reset cache before backfill.'); 1690 + return; 1691 + } 1472 1692 await axios.delete(`/api/mappings/${mappingId}/cache`, { headers: authHeaders }); 1473 1693 } 1474 1694 ··· 1517 1737 1518 1738 const handleDeleteMapping = async (mappingId: string) => { 1519 1739 if (!authHeaders) { 1740 + return; 1741 + } 1742 + const mapping = mappings.find((entry) => entry.id === mappingId); 1743 + if (!mapping || !canManageMapping(mapping)) { 1744 + showNotice('error', 'You do not have permission to delete this mapping.'); 1520 1745 return; 1521 1746 } 1522 1747 ··· 1580 1805 if (!authHeaders) { 1581 1806 return; 1582 1807 } 1808 + if (!canManageGroupsPermission) { 1809 + showNotice('error', 'You do not have permission to create groups.'); 1810 + return; 1811 + } 1583 1812 1584 1813 const name = newGroupName.trim(); 1585 1814 const emoji = newGroupEmoji.trim() || DEFAULT_GROUP_EMOJI; ··· 1604 1833 1605 1834 const handleAssignMappingGroup = async (mapping: AccountMapping, groupKey: string) => { 1606 1835 if (!authHeaders) { 1836 + return; 1837 + } 1838 + if (!canManageMapping(mapping)) { 1839 + showNotice('error', 'You do not have permission to update this mapping.'); 1607 1840 return; 1608 1841 } 1609 1842 ··· 1662 1895 if (!authHeaders) { 1663 1896 return; 1664 1897 } 1898 + if (!canManageGroupsPermission) { 1899 + showNotice('error', 'You do not have permission to rename groups.'); 1900 + return; 1901 + } 1665 1902 1666 1903 const draft = groupDraftsByKey[groupKey]; 1667 1904 if (!draft || !draft.name.trim()) { ··· 1692 1929 if (!authHeaders) { 1693 1930 return; 1694 1931 } 1932 + if (!canManageGroupsPermission) { 1933 + showNotice('error', 'You do not have permission to delete groups.'); 1934 + return; 1935 + } 1695 1936 1696 1937 const group = groupOptionsByKey.get(groupKey); 1697 1938 if (!group) { ··· 1722 1963 }; 1723 1964 1724 1965 const resetAddAccountDraft = () => { 1725 - setNewMapping(defaultMappingForm()); 1966 + setNewMapping({ 1967 + ...defaultMappingForm(), 1968 + owner: getUserLabel(me), 1969 + }); 1726 1970 setNewTwitterUsers([]); 1727 1971 setNewTwitterInput(''); 1728 1972 setAddAccountStep(1); 1729 1973 }; 1730 1974 1731 1975 const openAddAccountSheet = () => { 1976 + if (!canCreateMappings) { 1977 + showNotice('error', 'You do not have permission to add mappings.'); 1978 + return; 1979 + } 1732 1980 resetAddAccountDraft(); 1733 1981 setIsAddAccountSheetOpen(true); 1734 1982 }; ··· 1752 2000 1753 2001 const submitNewMapping = async () => { 1754 2002 if (!authHeaders) { 2003 + return; 2004 + } 2005 + if (!canCreateMappings) { 2006 + showNotice('error', 'You do not have permission to add mappings.'); 1755 2007 return; 1756 2008 } 1757 2009 ··· 1824 2076 }; 1825 2077 1826 2078 const startEditMapping = (mapping: AccountMapping) => { 2079 + if (!canManageMapping(mapping)) { 2080 + showNotice('error', 'You do not have permission to edit this mapping.'); 2081 + return; 2082 + } 1827 2083 setEditingMapping(mapping); 1828 2084 setEditForm({ 1829 2085 owner: mapping.owner || '', ··· 1840 2096 const handleUpdateMapping = async (event: React.FormEvent<HTMLFormElement>) => { 1841 2097 event.preventDefault(); 1842 2098 if (!authHeaders || !editingMapping) { 2099 + return; 2100 + } 2101 + if (!canManageMapping(editingMapping)) { 2102 + showNotice('error', 'You do not have permission to edit this mapping.'); 1843 2103 return; 1844 2104 } 1845 2105 ··· 2016 2276 } 2017 2277 }; 2018 2278 2279 + const beginEditUser = (user: ManagedUser) => { 2280 + setEditingUserId(user.id); 2281 + setEditingUserForm({ 2282 + username: user.username || '', 2283 + email: user.email || '', 2284 + password: '', 2285 + isAdmin: user.isAdmin, 2286 + permissions: normalizePermissions(user.permissions), 2287 + }); 2288 + }; 2289 + 2290 + const resetEditingUser = () => { 2291 + setEditingUserId(null); 2292 + setEditingUserForm(defaultUserForm()); 2293 + }; 2294 + 2295 + const handleCreateUser = async (event: React.FormEvent<HTMLFormElement>) => { 2296 + event.preventDefault(); 2297 + if (!authHeaders || !isAdmin) { 2298 + return; 2299 + } 2300 + 2301 + const username = normalizeUsername(newUserForm.username); 2302 + const email = normalizeEmail(newUserForm.email); 2303 + if (!username && !email) { 2304 + showNotice('error', 'Provide at least a username or email.'); 2305 + return; 2306 + } 2307 + if (!newUserForm.password || newUserForm.password.length < 8) { 2308 + showNotice('error', 'Password must be at least 8 characters.'); 2309 + return; 2310 + } 2311 + 2312 + setIsBusy(true); 2313 + try { 2314 + await axios.post( 2315 + '/api/admin/users', 2316 + { 2317 + username: username || undefined, 2318 + email: email || undefined, 2319 + password: newUserForm.password, 2320 + isAdmin: newUserForm.isAdmin, 2321 + permissions: newUserForm.permissions, 2322 + }, 2323 + { headers: authHeaders }, 2324 + ); 2325 + setNewUserForm(defaultUserForm()); 2326 + showNotice('success', 'User account created.'); 2327 + await fetchManagedUsers(); 2328 + } catch (error) { 2329 + handleAuthFailure(error, 'Failed to create user.'); 2330 + } finally { 2331 + setIsBusy(false); 2332 + } 2333 + }; 2334 + 2335 + const handleSaveEditedUser = async (userId: string) => { 2336 + if (!authHeaders || !isAdmin) { 2337 + return; 2338 + } 2339 + 2340 + const username = normalizeUsername(editingUserForm.username); 2341 + const email = normalizeEmail(editingUserForm.email); 2342 + if (!username && !email) { 2343 + showNotice('error', 'Provide at least a username or email.'); 2344 + return; 2345 + } 2346 + 2347 + setIsBusy(true); 2348 + try { 2349 + await axios.put( 2350 + `/api/admin/users/${userId}`, 2351 + { 2352 + username: username || undefined, 2353 + email: email || undefined, 2354 + isAdmin: editingUserForm.isAdmin, 2355 + permissions: editingUserForm.permissions, 2356 + }, 2357 + { headers: authHeaders }, 2358 + ); 2359 + showNotice('success', 'User updated.'); 2360 + resetEditingUser(); 2361 + await Promise.all([fetchManagedUsers(), fetchData()]); 2362 + } catch (error) { 2363 + handleAuthFailure(error, 'Failed to update user.'); 2364 + } finally { 2365 + setIsBusy(false); 2366 + } 2367 + }; 2368 + 2369 + const handleResetUserPassword = async (userId: string) => { 2370 + if (!authHeaders || !isAdmin) { 2371 + return; 2372 + } 2373 + 2374 + const newPassword = window.prompt('Enter a new password (min 8 chars):'); 2375 + if (!newPassword) { 2376 + return; 2377 + } 2378 + if (newPassword.length < 8) { 2379 + showNotice('error', 'Password must be at least 8 characters.'); 2380 + return; 2381 + } 2382 + 2383 + setIsBusy(true); 2384 + try { 2385 + await axios.post( 2386 + `/api/admin/users/${userId}/reset-password`, 2387 + { 2388 + newPassword, 2389 + }, 2390 + { headers: authHeaders }, 2391 + ); 2392 + showNotice('success', 'Password reset.'); 2393 + } catch (error) { 2394 + handleAuthFailure(error, 'Failed to reset password.'); 2395 + } finally { 2396 + setIsBusy(false); 2397 + } 2398 + }; 2399 + 2400 + const handleDeleteUser = async (user: ManagedUser) => { 2401 + if (!authHeaders || !isAdmin) { 2402 + return; 2403 + } 2404 + 2405 + const confirmed = window.confirm( 2406 + `Delete ${user.username || user.email || user.id}? Their mapped accounts will be disabled.`, 2407 + ); 2408 + if (!confirmed) { 2409 + return; 2410 + } 2411 + 2412 + setIsBusy(true); 2413 + try { 2414 + await axios.delete(`/api/admin/users/${user.id}`, { headers: authHeaders }); 2415 + showNotice('success', 'User deleted and owned mappings disabled.'); 2416 + if (accountsCreatorFilter === user.id) { 2417 + setAccountsCreatorFilter('all'); 2418 + } 2419 + await Promise.all([fetchManagedUsers(), fetchData()]); 2420 + } catch (error) { 2421 + handleAuthFailure(error, 'Failed to delete user.'); 2422 + } finally { 2423 + setIsBusy(false); 2424 + } 2425 + }; 2426 + 2427 + const handleChangeOwnEmail = async (event: React.FormEvent<HTMLFormElement>) => { 2428 + event.preventDefault(); 2429 + if (!authHeaders) { 2430 + return; 2431 + } 2432 + 2433 + if (!emailForm.newEmail.trim() || !emailForm.password || (hasCurrentEmail && !emailForm.currentEmail.trim())) { 2434 + showNotice('error', hasCurrentEmail ? 'Fill in current email, new email, and password.' : 'Fill in new email and password.'); 2435 + return; 2436 + } 2437 + 2438 + setIsBusy(true); 2439 + try { 2440 + const response = await axios.post<{ token?: string; me?: AuthUser }>( 2441 + '/api/me/change-email', 2442 + { 2443 + currentEmail: emailForm.currentEmail, 2444 + newEmail: emailForm.newEmail, 2445 + password: emailForm.password, 2446 + }, 2447 + { headers: authHeaders }, 2448 + ); 2449 + 2450 + if (response.data?.token) { 2451 + localStorage.setItem('token', response.data.token); 2452 + setToken(response.data.token); 2453 + } 2454 + if (response.data?.me) { 2455 + setMe({ 2456 + ...response.data.me, 2457 + permissions: normalizePermissions(response.data.me.permissions), 2458 + }); 2459 + } 2460 + setEmailForm((previous) => ({ 2461 + currentEmail: previous.newEmail, 2462 + newEmail: '', 2463 + password: '', 2464 + })); 2465 + showNotice('success', 'Email updated.'); 2466 + await fetchData(); 2467 + } catch (error) { 2468 + handleAuthFailure(error, 'Failed to update email.'); 2469 + } finally { 2470 + setIsBusy(false); 2471 + } 2472 + }; 2473 + 2474 + const handleChangeOwnPassword = async (event: React.FormEvent<HTMLFormElement>) => { 2475 + event.preventDefault(); 2476 + if (!authHeaders) { 2477 + return; 2478 + } 2479 + 2480 + if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) { 2481 + showNotice('error', 'Complete all password fields.'); 2482 + return; 2483 + } 2484 + if (passwordForm.newPassword.length < 8) { 2485 + showNotice('error', 'New password must be at least 8 characters.'); 2486 + return; 2487 + } 2488 + if (passwordForm.newPassword !== passwordForm.confirmPassword) { 2489 + showNotice('error', 'New password and confirmation do not match.'); 2490 + return; 2491 + } 2492 + 2493 + setIsBusy(true); 2494 + try { 2495 + await axios.post( 2496 + '/api/me/change-password', 2497 + { 2498 + currentPassword: passwordForm.currentPassword, 2499 + newPassword: passwordForm.newPassword, 2500 + }, 2501 + { headers: authHeaders }, 2502 + ); 2503 + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); 2504 + showNotice('success', 'Password updated.'); 2505 + } catch (error) { 2506 + handleAuthFailure(error, 'Failed to update password.'); 2507 + } finally { 2508 + setIsBusy(false); 2509 + } 2510 + }; 2511 + 2019 2512 if (!token) { 2020 2513 return ( 2021 2514 <main className="flex min-h-screen items-center justify-center p-4"> ··· 2036 2529 ) : null} 2037 2530 2038 2531 <form className="space-y-4" onSubmit={authView === 'login' ? handleLogin : handleRegister}> 2039 - <div className="space-y-2"> 2040 - <Label htmlFor="email">Email</Label> 2041 - <Input id="email" name="email" type="email" autoComplete="email" required /> 2042 - </div> 2532 + {authView === 'login' ? ( 2533 + <div className="space-y-2"> 2534 + <Label htmlFor="identifier">Email or Username</Label> 2535 + <Input id="identifier" name="identifier" autoComplete="username" required /> 2536 + </div> 2537 + ) : ( 2538 + <> 2539 + <div className="space-y-2"> 2540 + <Label htmlFor="username">Username</Label> 2541 + <Input id="username" name="username" autoComplete="username" placeholder="optional" /> 2542 + </div> 2543 + <div className="space-y-2"> 2544 + <Label htmlFor="email">Email</Label> 2545 + <Input id="email" name="email" type="email" autoComplete="email" placeholder="optional" /> 2546 + </div> 2547 + </> 2548 + )} 2043 2549 2044 2550 <div className="space-y-2"> 2045 2551 <Label htmlFor="password">Password</Label> ··· 2052 2558 </Button> 2053 2559 </form> 2054 2560 2055 - <Button 2056 - className="mt-4 w-full" 2057 - variant="ghost" 2058 - onClick={() => { 2059 - setAuthError(''); 2060 - setAuthView(authView === 'login' ? 'register' : 'login'); 2061 - }} 2062 - type="button" 2063 - > 2064 - {authView === 'login' ? 'Need an account? Register' : 'Have an account? Sign in'} 2065 - </Button> 2561 + {bootstrapOpen || authView === 'register' ? ( 2562 + <Button 2563 + className="mt-4 w-full" 2564 + variant="ghost" 2565 + onClick={() => { 2566 + setAuthError(''); 2567 + setAuthView(authView === 'login' ? 'register' : 'login'); 2568 + }} 2569 + type="button" 2570 + > 2571 + {authView === 'login' ? 'Need an account? Register' : 'Have an account? Sign in'} 2572 + </Button> 2573 + ) : ( 2574 + <p className="mt-4 text-center text-xs text-muted-foreground"> 2575 + Account creation is disabled. Ask an admin to create your user. 2576 + </p> 2577 + )} 2066 2578 </CardContent> 2067 2579 </Card> 2068 2580 </main> ··· 2092 2604 {themeIcon} 2093 2605 <span className="ml-2 hidden sm:inline">{themeLabel}</span> 2094 2606 </Button> 2095 - {isAdmin ? ( 2607 + {canCreateMappings ? ( 2096 2608 <Button 2097 2609 size="sm" 2098 2610 variant="outline" ··· 2105 2617 Add account 2106 2618 </Button> 2107 2619 ) : null} 2108 - <Button size="sm" onClick={runNow}> 2620 + <Button size="sm" onClick={runNow} disabled={!canRunNowPermission}> 2109 2621 <Play className="mr-2 h-4 w-4" /> 2110 2622 Run now 2111 2623 </Button> ··· 2126 2638 2127 2639 {notice ? ( 2128 2640 <div 2129 - className={cn( 2130 - 'mb-5 animate-pop-in rounded-md border px-4 py-2 text-sm', 2131 - notice.tone === 'success' && 2132 - 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:text-emerald-300', 2641 + className={cn( 2642 + 'mb-5 animate-pop-in rounded-md border px-4 py-2 text-sm', 2643 + notice.tone === 'success' && 2644 + 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:text-emerald-300', 2133 2645 notice.tone === 'error' && 2134 2646 'border-red-500/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300', 2135 - notice.tone === 'info' && 2136 - 'border-border bg-muted text-muted-foreground', 2647 + notice.tone === 'info' && 'border-border bg-muted text-muted-foreground', 2137 2648 )} 2138 2649 > 2139 2650 {notice.message} ··· 2162 2673 <div className="text-right"> 2163 2674 <p className="text-lg font-semibold">{progressPercent || 0}%</p> 2164 2675 <p className="text-xs text-muted-foreground"> 2165 - {(currentStatus.processedCount || 0).toLocaleString()} / {(currentStatus.totalCount || 0).toLocaleString()} 2676 + {(currentStatus.processedCount || 0).toLocaleString()} /{' '} 2677 + {(currentStatus.totalCount || 0).toLocaleString()} 2166 2678 </p> 2167 2679 </div> 2168 2680 </CardContent> ··· 2219 2731 <CardContent className="p-4"> 2220 2732 <p className="text-xs uppercase tracking-wide text-muted-foreground">Latest Activity</p> 2221 2733 <p className="mt-2 text-sm font-medium text-foreground"> 2222 - {latestActivity?.created_at ? new Date(latestActivity.created_at).toLocaleString() : 'No activity yet'} 2734 + {latestActivity?.created_at 2735 + ? new Date(latestActivity.created_at).toLocaleString() 2736 + : 'No activity yet'} 2223 2737 </p> 2224 2738 </CardContent> 2225 2739 </Card> ··· 2275 2789 })} 2276 2790 </CardContent> 2277 2791 </Card> 2278 - 2279 2792 </section> 2280 2793 ) : null} 2281 2794 ··· 2289 2802 <CardDescription>Organize mappings into folders and collapse/expand groups.</CardDescription> 2290 2803 </div> 2291 2804 <div className="flex items-center gap-2"> 2292 - {isAdmin ? ( 2805 + {canCreateMappings ? ( 2293 2806 <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 2294 2807 <Plus className="mr-2 h-4 w-4" /> 2295 2808 Add account 2296 2809 </Button> 2297 2810 ) : null} 2298 - <Badge variant="outline">{mappings.length} configured</Badge> 2811 + <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 2299 2812 </div> 2300 2813 </div> 2301 2814 </CardHeader> 2302 2815 <CardContent className="space-y-4 pt-0"> 2303 - <form 2304 - className="rounded-lg border border-border/70 bg-muted/30 p-3" 2305 - onSubmit={(event) => { 2306 - void handleCreateGroup(event); 2307 - }} 2308 - > 2309 - <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Create Folder</p> 2310 - <div className="flex flex-wrap items-end gap-2"> 2311 - <div className="min-w-[180px] flex-1 space-y-1"> 2312 - <Label htmlFor="accounts-group-name">Folder name</Label> 2313 - <Input 2314 - id="accounts-group-name" 2315 - value={newGroupName} 2316 - onChange={(event) => setNewGroupName(event.target.value)} 2317 - placeholder="Gaming, News, Sports..." 2318 - /> 2319 - </div> 2320 - <div className="w-20 space-y-1"> 2321 - <Label htmlFor="accounts-group-emoji">Emoji</Label> 2322 - <Input 2323 - id="accounts-group-emoji" 2324 - value={newGroupEmoji} 2325 - onChange={(event) => setNewGroupEmoji(event.target.value)} 2326 - placeholder="📁" 2327 - maxLength={8} 2328 - /> 2816 + {canManageGroupsPermission ? ( 2817 + <form 2818 + className="rounded-lg border border-border/70 bg-muted/30 p-3" 2819 + onSubmit={(event) => { 2820 + void handleCreateGroup(event); 2821 + }} 2822 + > 2823 + <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> 2824 + Create Folder 2825 + </p> 2826 + <div className="flex flex-wrap items-end gap-2"> 2827 + <div className="min-w-[180px] flex-1 space-y-1"> 2828 + <Label htmlFor="accounts-group-name">Folder name</Label> 2829 + <Input 2830 + id="accounts-group-name" 2831 + value={newGroupName} 2832 + onChange={(event) => setNewGroupName(event.target.value)} 2833 + placeholder="Gaming, News, Sports..." 2834 + /> 2835 + </div> 2836 + <div className="w-20 space-y-1"> 2837 + <Label htmlFor="accounts-group-emoji">Emoji</Label> 2838 + <Input 2839 + id="accounts-group-emoji" 2840 + value={newGroupEmoji} 2841 + onChange={(event) => setNewGroupEmoji(event.target.value)} 2842 + placeholder="📁" 2843 + maxLength={8} 2844 + /> 2845 + </div> 2846 + <Button type="submit" size="sm" disabled={isBusy || newGroupName.trim().length === 0}> 2847 + <Plus className="mr-2 h-4 w-4" /> 2848 + Create 2849 + </Button> 2329 2850 </div> 2330 - <Button type="submit" size="sm" disabled={isBusy || newGroupName.trim().length === 0}> 2331 - <Plus className="mr-2 h-4 w-4" /> 2332 - Create 2333 - </Button> 2334 - </div> 2335 - </form> 2851 + </form> 2852 + ) : null} 2336 2853 2337 2854 <div className="grid gap-2 md:grid-cols-[1fr_auto]"> 2338 2855 <div className="space-y-1"> ··· 2348 2865 {accountMatchesCount} result{accountMatchesCount === 1 ? '' : 's'} ranked by relevance 2349 2866 </p> 2350 2867 ) : null} 2868 + {isAdmin ? ( 2869 + <div className="mt-2 space-y-1"> 2870 + <Label htmlFor="accounts-creator-filter">Created by user</Label> 2871 + <select 2872 + id="accounts-creator-filter" 2873 + className={cn(selectClassName, 'h-9 text-xs')} 2874 + value={accountsCreatorFilter} 2875 + onChange={(event) => setAccountsCreatorFilter(event.target.value)} 2876 + > 2877 + <option value="all">All users</option> 2878 + {managedUsers.map((user) => ( 2879 + <option key={`creator-filter-${user.id}`} value={user.id}> 2880 + {user.username || user.email || user.id} 2881 + </option> 2882 + ))} 2883 + </select> 2884 + </div> 2885 + ) : null} 2351 2886 </div> 2352 2887 <div className="flex flex-wrap items-end justify-end gap-2"> 2353 2888 {accountsViewMode === 'grouped' ? ( ··· 2373 2908 {filteredGroupedMappings.length === 0 ? ( 2374 2909 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 2375 2910 {normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} 2376 - {isAdmin ? ( 2911 + {canCreateMappings ? ( 2377 2912 <div className="mt-3"> 2378 2913 <Button size="sm" variant="outline" onClick={openAddAccountSheet}> 2379 2914 <Plus className="mr-2 h-4 w-4" /> ··· 2439 2974 <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 2440 2975 <tr> 2441 2976 <th className="px-2 py-3">Owner</th> 2977 + {isAdmin ? <th className="px-2 py-3">Created By</th> : null} 2442 2978 <th className="px-2 py-3">Twitter Sources</th> 2443 2979 <th className="px-2 py-3">Bluesky Target</th> 2444 2980 <th className="px-2 py-3">Status</th> ··· 2456 2992 const mappingGroup = getMappingGroupMeta(mapping); 2457 2993 2458 2994 return ( 2459 - <tr key={mapping.id} className="interactive-row border-b border-border/60 last:border-0"> 2995 + <tr 2996 + key={mapping.id} 2997 + className="interactive-row border-b border-border/60 last:border-0" 2998 + > 2460 2999 <td className="px-2 py-3 align-top"> 2461 3000 <div className="flex items-center gap-2 font-medium"> 2462 3001 <UserRound className="h-4 w-4 text-muted-foreground" /> 2463 3002 {mapping.owner || 'System'} 2464 3003 </div> 2465 3004 </td> 3005 + {isAdmin ? ( 3006 + <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3007 + {mapping.createdByLabel || 3008 + mapping.createdByUser?.username || 3009 + mapping.createdByUser?.email || 3010 + '--'} 3011 + </td> 3012 + ) : null} 2466 3013 <td className="px-2 py-3 align-top"> 2467 3014 <div className="flex flex-wrap gap-2"> 2468 3015 {mapping.twitterUsernames.map((username) => ( ··· 2488 3035 )} 2489 3036 <div className="min-w-0"> 2490 3037 <p className="truncate text-sm font-medium">{profileName}</p> 2491 - <p className="truncate font-mono text-xs text-muted-foreground">{profileHandle}</p> 3038 + <p className="truncate font-mono text-xs text-muted-foreground"> 3039 + {profileHandle} 3040 + </p> 2492 3041 </div> 2493 3042 </div> 2494 3043 </td> ··· 2496 3045 {active ? ( 2497 3046 <Badge variant="warning">Backfilling</Badge> 2498 3047 ) : queued ? ( 2499 - <Badge variant="warning">Queued {queuePosition ? `#${queuePosition}` : ''}</Badge> 3048 + <Badge variant="warning"> 3049 + Queued {queuePosition ? `#${queuePosition}` : ''} 3050 + </Badge> 2500 3051 ) : ( 2501 3052 <Badge variant="success">Active</Badge> 2502 3053 )} ··· 2506 3057 <select 2507 3058 className={cn(selectClassName, 'h-9 w-44 px-2 py-1 text-xs')} 2508 3059 value={mappingGroup.key} 3060 + disabled={!canManageMapping(mapping) || !canManageGroupsPermission} 2509 3061 onChange={(event) => { 2510 3062 void handleAssignMappingGroup(mapping, event.target.value); 2511 3063 }} ··· 2516 3068 {groupOptions 2517 3069 .filter((option) => option.key !== DEFAULT_GROUP_KEY) 2518 3070 .map((option) => ( 2519 - <option key={`group-move-${mapping.id}-${option.key}`} value={option.key}> 3071 + <option 3072 + key={`group-move-${mapping.id}-${option.key}`} 3073 + value={option.key} 3074 + > 2520 3075 {option.emoji} {option.name} 2521 3076 </option> 2522 3077 ))} 2523 3078 </select> 2524 - {isAdmin ? ( 3079 + {canManageMapping(mapping) ? ( 2525 3080 <> 2526 - <Button variant="outline" size="sm" onClick={() => startEditMapping(mapping)}> 2527 - Edit 2528 - </Button> 2529 3081 <Button 2530 3082 variant="outline" 2531 3083 size="sm" 2532 - onClick={() => { 2533 - void requestBackfill(mapping.id, 'normal'); 2534 - }} 3084 + onClick={() => startEditMapping(mapping)} 2535 3085 > 2536 - Backfill 3086 + Edit 2537 3087 </Button> 2538 - <Button 2539 - variant="subtle" 2540 - size="sm" 2541 - onClick={() => { 2542 - void requestBackfill(mapping.id, 'reset'); 2543 - }} 2544 - > 2545 - Reset + Backfill 2546 - </Button> 2547 - <Button 2548 - variant="destructive" 2549 - size="sm" 2550 - onClick={() => { 2551 - void handleDeleteAllPosts(mapping.id); 2552 - }} 2553 - > 2554 - Delete Posts 2555 - </Button> 3088 + {canQueueBackfillsPermission ? ( 3089 + <> 3090 + <Button 3091 + variant="outline" 3092 + size="sm" 3093 + onClick={() => { 3094 + void requestBackfill(mapping.id, 'normal'); 3095 + }} 3096 + > 3097 + Backfill 3098 + </Button> 3099 + {isAdmin ? ( 3100 + <Button 3101 + variant="subtle" 3102 + size="sm" 3103 + onClick={() => { 3104 + void requestBackfill(mapping.id, 'reset'); 3105 + }} 3106 + > 3107 + Reset + Backfill 3108 + </Button> 3109 + ) : null} 3110 + </> 3111 + ) : null} 3112 + {isAdmin ? ( 3113 + <Button 3114 + variant="destructive" 3115 + size="sm" 3116 + onClick={() => { 3117 + void handleDeleteAllPosts(mapping.id); 3118 + }} 3119 + > 3120 + Delete Posts 3121 + </Button> 3122 + ) : null} 2556 3123 </> 2557 3124 ) : null} 2558 - <Button 2559 - variant="ghost" 2560 - size="sm" 2561 - onClick={() => { 2562 - void handleDeleteMapping(mapping.id); 2563 - }} 2564 - > 2565 - <Trash2 className="mr-1 h-4 w-4" /> 2566 - Remove 2567 - </Button> 3125 + {canManageMapping(mapping) ? ( 3126 + <Button 3127 + variant="ghost" 3128 + size="sm" 3129 + onClick={() => { 3130 + void handleDeleteMapping(mapping.id); 3131 + }} 3132 + > 3133 + <Trash2 className="mr-1 h-4 w-4" /> 3134 + Remove 3135 + </Button> 3136 + ) : null} 2568 3137 </div> 2569 3138 </td> 2570 3139 </tr> ··· 2584 3153 </CardContent> 2585 3154 </Card> 2586 3155 2587 - <Card className="animate-slide-up"> 2588 - <CardHeader className="pb-3"> 2589 - <CardTitle>Group Manager</CardTitle> 2590 - <CardDescription>Edit folder names/emojis or delete a group.</CardDescription> 2591 - </CardHeader> 2592 - <CardContent className="pt-0"> 2593 - {reusableGroupOptions.length === 0 ? ( 2594 - <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 2595 - No custom folders yet. 2596 - </div> 2597 - ) : ( 2598 - <div className="space-y-2"> 2599 - {reusableGroupOptions.map((group) => { 2600 - const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji }; 2601 - return ( 2602 - <div 2603 - key={`group-manager-${group.key}`} 2604 - className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3 md:grid-cols-[90px_minmax(0,1fr)_auto_auto]" 2605 - > 2606 - <div className="space-y-1"> 2607 - <Label htmlFor={`group-manager-emoji-${group.key}`}>Emoji</Label> 2608 - <Input 2609 - id={`group-manager-emoji-${group.key}`} 2610 - value={draft.emoji} 2611 - onChange={(event) => updateGroupDraft(group.key, 'emoji', event.target.value)} 2612 - maxLength={8} 2613 - /> 3156 + {canManageGroupsPermission ? ( 3157 + <Card className="animate-slide-up"> 3158 + <CardHeader className="pb-3"> 3159 + <CardTitle>Group Manager</CardTitle> 3160 + <CardDescription>Edit folder names/emojis or delete a group.</CardDescription> 3161 + </CardHeader> 3162 + <CardContent className="pt-0"> 3163 + {reusableGroupOptions.length === 0 ? ( 3164 + <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 3165 + No custom folders yet. 3166 + </div> 3167 + ) : ( 3168 + <div className="space-y-2"> 3169 + {reusableGroupOptions.map((group) => { 3170 + const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji }; 3171 + return ( 3172 + <div 3173 + key={`group-manager-${group.key}`} 3174 + className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3 md:grid-cols-[90px_minmax(0,1fr)_auto_auto]" 3175 + > 3176 + <div className="space-y-1"> 3177 + <Label htmlFor={`group-manager-emoji-${group.key}`}>Emoji</Label> 3178 + <Input 3179 + id={`group-manager-emoji-${group.key}`} 3180 + value={draft.emoji} 3181 + onChange={(event) => updateGroupDraft(group.key, 'emoji', event.target.value)} 3182 + maxLength={8} 3183 + /> 3184 + </div> 3185 + <div className="space-y-1"> 3186 + <Label htmlFor={`group-manager-name-${group.key}`}>Name</Label> 3187 + <Input 3188 + id={`group-manager-name-${group.key}`} 3189 + value={draft.name} 3190 + onChange={(event) => updateGroupDraft(group.key, 'name', event.target.value)} 3191 + /> 3192 + </div> 3193 + <Button 3194 + variant="outline" 3195 + size="sm" 3196 + className="self-end" 3197 + disabled={isGroupActionBusy || !draft.name.trim()} 3198 + onClick={() => { 3199 + void handleRenameGroup(group.key); 3200 + }} 3201 + > 3202 + Save 3203 + </Button> 3204 + <Button 3205 + variant="ghost" 3206 + size="sm" 3207 + className="self-end text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 3208 + disabled={isGroupActionBusy} 3209 + onClick={() => { 3210 + void handleDeleteGroup(group.key); 3211 + }} 3212 + > 3213 + Delete 3214 + </Button> 2614 3215 </div> 2615 - <div className="space-y-1"> 2616 - <Label htmlFor={`group-manager-name-${group.key}`}>Name</Label> 2617 - <Input 2618 - id={`group-manager-name-${group.key}`} 2619 - value={draft.name} 2620 - onChange={(event) => updateGroupDraft(group.key, 'name', event.target.value)} 2621 - /> 2622 - </div> 2623 - <Button 2624 - variant="outline" 2625 - size="sm" 2626 - className="self-end" 2627 - disabled={isGroupActionBusy || !draft.name.trim()} 2628 - onClick={() => { 2629 - void handleRenameGroup(group.key); 2630 - }} 2631 - > 2632 - Save 2633 - </Button> 2634 - <Button 2635 - variant="ghost" 2636 - size="sm" 2637 - className="self-end text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 2638 - disabled={isGroupActionBusy} 2639 - onClick={() => { 2640 - void handleDeleteGroup(group.key); 2641 - }} 2642 - > 2643 - Delete 2644 - </Button> 2645 - </div> 2646 - ); 2647 - })} 2648 - </div> 2649 - )} 2650 - <p className="mt-3 text-xs text-muted-foreground"> 2651 - Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}. 2652 - </p> 2653 - </CardContent> 2654 - </Card> 3216 + ); 3217 + })} 3218 + </div> 3219 + )} 3220 + <p className="mt-3 text-xs text-muted-foreground"> 3221 + Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}. 3222 + </p> 3223 + </CardContent> 3224 + </Card> 3225 + ) : null} 2655 3226 </section> 2656 3227 ) : null} 2657 3228 ··· 2715 3286 const postUrl = 2716 3287 post.postUrl || 2717 3288 (post.bskyUri 2718 - ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${post.bskyUri 2719 - .split('/') 2720 - .filter(Boolean) 2721 - .pop() || ''}` 3289 + ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ 3290 + post.bskyUri.split('/').filter(Boolean).pop() || '' 3291 + }` 2722 3292 : undefined); 2723 3293 2724 3294 return ( ··· 2729 3299 <div className="mb-2 flex flex-wrap items-center justify-between gap-2"> 2730 3300 <div className="min-w-0"> 2731 3301 <p className="truncate text-sm font-semibold"> 2732 - @{post.bskyIdentifier} <span className="text-muted-foreground">from @{post.twitterUsername}</span> 3302 + @{post.bskyIdentifier}{' '} 3303 + <span className="text-muted-foreground">from @{post.twitterUsername}</span> 2733 3304 </p> 2734 3305 <p className="text-xs text-muted-foreground"> 2735 3306 {post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'} ··· 2785 3356 const postUrl = 2786 3357 post.postUrl || 2787 3358 (post.bskyUri 2788 - ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${post.bskyUri 2789 - .split('/') 2790 - .filter(Boolean) 2791 - .pop() || ''}` 3359 + ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${ 3360 + post.bskyUri.split('/').filter(Boolean).pop() || '' 3361 + }` 2792 3362 : undefined); 2793 3363 const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); 2794 3364 const segments = buildFacetSegments(post.text, post.facets || []); ··· 2859 3429 return ( 2860 3430 <a 2861 3431 key={`${post.bskyUri}-segment-${segmentIndex}`} 2862 - className={cn('underline decoration-transparent transition hover:decoration-current', linkTone)} 3432 + className={cn( 3433 + 'underline decoration-transparent transition hover:decoration-current', 3434 + linkTone, 3435 + )} 2863 3436 href={segment.href} 2864 3437 target="_blank" 2865 3438 rel="noreferrer" ··· 2949 3522 /> 2950 3523 ) : null} 2951 3524 <div className="space-y-1 p-3"> 2952 - <p className="truncate text-sm font-medium"> 2953 - {media.title || media.url} 2954 - </p> 3525 + <p className="truncate text-sm font-medium">{media.title || media.url}</p> 2955 3526 {media.description ? ( 2956 3527 <p className="max-h-10 overflow-hidden text-xs text-muted-foreground"> 2957 3528 {media.description} ··· 3080 3651 > 3081 3652 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3082 3653 {activity.created_at 3083 - ? new Date(activity.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 3654 + ? new Date(activity.created_at).toLocaleTimeString([], { 3655 + hour: '2-digit', 3656 + minute: '2-digit', 3657 + }) 3084 3658 : '--'} 3085 3659 </td> 3086 3660 <td className="px-2 py-3 align-top font-medium">@{activity.twitter_username}</td> ··· 3099 3673 )} 3100 3674 </td> 3101 3675 <td className="px-2 py-3 align-top text-xs text-muted-foreground"> 3102 - <div className="max-w-[340px] truncate">{activity.tweet_text || `Tweet ID: ${activity.twitter_id}`}</div> 3676 + <div className="max-w-[340px] truncate"> 3677 + {activity.tweet_text || `Tweet ID: ${activity.twitter_id}`} 3678 + </div> 3103 3679 </td> 3104 3680 <td className="px-2 py-3 align-top text-right"> 3105 3681 <div className="flex flex-col items-end gap-1"> ··· 3148 3724 ) : null} 3149 3725 3150 3726 {activeTab === 'settings' ? ( 3151 - isAdmin ? ( 3152 - <section className="space-y-6 animate-fade-in"> 3153 - <Card className="animate-slide-up"> 3154 - <CardHeader> 3155 - <CardTitle className="flex items-center gap-2"> 3156 - <Settings2 className="h-4 w-4" /> 3157 - Admin Settings 3158 - </CardTitle> 3159 - <CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription> 3160 - </CardHeader> 3161 - <CardContent className="space-y-4 pt-0"> 3162 - <div className="rounded-lg border border-border/70 bg-muted/20 p-3"> 3163 - <div className="flex flex-wrap items-start justify-between gap-3"> 3164 - <div className="space-y-1"> 3165 - <p className="text-sm font-semibold">Running Version</p> 3166 - <p className="font-mono text-sm text-foreground">{runtimeVersionLabel}</p> 3167 - {runtimeBranchLabel ? ( 3168 - <p className="text-xs text-muted-foreground">{runtimeBranchLabel}</p> 3169 - ) : null} 3170 - <p className="text-xs text-muted-foreground">{updateStateLabel}</p> 3727 + <section className="space-y-6 animate-fade-in"> 3728 + <Card className="animate-slide-up"> 3729 + <button 3730 + className="flex w-full items-center justify-between px-5 py-4 text-left" 3731 + onClick={() => toggleSettingsSection('account')} 3732 + type="button" 3733 + > 3734 + <div> 3735 + <p className="text-sm font-semibold">Account Security</p> 3736 + <p className="text-xs text-muted-foreground">Update your own email/password with verification.</p> 3737 + </div> 3738 + <ChevronDown 3739 + className={cn( 3740 + 'h-4 w-4 transition-transform duration-200', 3741 + isSettingsSectionExpanded('account') ? 'rotate-0' : '-rotate-90', 3742 + )} 3743 + /> 3744 + </button> 3745 + <div 3746 + className={cn( 3747 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3748 + isSettingsSectionExpanded('account') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3749 + )} 3750 + > 3751 + <div className="min-h-0 overflow-hidden"> 3752 + <CardContent className="grid gap-4 border-t border-border/70 pt-4 lg:grid-cols-2"> 3753 + <form className="space-y-3" onSubmit={handleChangeOwnEmail}> 3754 + <p className="text-sm font-semibold">Change Email</p> 3755 + <div className="space-y-2"> 3756 + <Label htmlFor="account-current-email">Current Email</Label> 3757 + <Input 3758 + id="account-current-email" 3759 + type="email" 3760 + value={emailForm.currentEmail} 3761 + onChange={(event) => { 3762 + setEmailForm((previous) => ({ ...previous, currentEmail: event.target.value })); 3763 + }} 3764 + placeholder={hasCurrentEmail ? undefined : 'No current email on this account'} 3765 + required={hasCurrentEmail} 3766 + disabled={!hasCurrentEmail} 3767 + /> 3768 + </div> 3769 + <div className="space-y-2"> 3770 + <Label htmlFor="account-new-email">New Email</Label> 3771 + <Input 3772 + id="account-new-email" 3773 + type="email" 3774 + value={emailForm.newEmail} 3775 + onChange={(event) => { 3776 + setEmailForm((previous) => ({ ...previous, newEmail: event.target.value })); 3777 + }} 3778 + required 3779 + /> 3780 + </div> 3781 + <div className="space-y-2"> 3782 + <Label htmlFor="account-email-password">Current Password</Label> 3783 + <Input 3784 + id="account-email-password" 3785 + type="password" 3786 + value={emailForm.password} 3787 + onChange={(event) => { 3788 + setEmailForm((previous) => ({ ...previous, password: event.target.value })); 3789 + }} 3790 + required 3791 + /> 3792 + </div> 3793 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3794 + <Save className="mr-2 h-4 w-4" /> 3795 + Save Email 3796 + </Button> 3797 + </form> 3798 + 3799 + <form className="space-y-3" onSubmit={handleChangeOwnPassword}> 3800 + <p className="text-sm font-semibold">Change Password</p> 3801 + <div className="space-y-2"> 3802 + <Label htmlFor="account-current-password">Current Password</Label> 3803 + <Input 3804 + id="account-current-password" 3805 + type="password" 3806 + value={passwordForm.currentPassword} 3807 + onChange={(event) => { 3808 + setPasswordForm((previous) => ({ ...previous, currentPassword: event.target.value })); 3809 + }} 3810 + required 3811 + /> 3812 + </div> 3813 + <div className="space-y-2"> 3814 + <Label htmlFor="account-new-password">New Password</Label> 3815 + <Input 3816 + id="account-new-password" 3817 + type="password" 3818 + value={passwordForm.newPassword} 3819 + onChange={(event) => { 3820 + setPasswordForm((previous) => ({ ...previous, newPassword: event.target.value })); 3821 + }} 3822 + required 3823 + /> 3171 3824 </div> 3172 - <div className="flex flex-wrap gap-2"> 3173 - <Button 3174 - variant="outline" 3175 - onClick={() => { 3176 - void handleRunUpdate(); 3825 + <div className="space-y-2"> 3826 + <Label htmlFor="account-confirm-password">Confirm New Password</Label> 3827 + <Input 3828 + id="account-confirm-password" 3829 + type="password" 3830 + value={passwordForm.confirmPassword} 3831 + onChange={(event) => { 3832 + setPasswordForm((previous) => ({ ...previous, confirmPassword: event.target.value })); 3177 3833 }} 3178 - disabled={isUpdateBusy || updateStatus?.running} 3179 - > 3180 - {isUpdateBusy || updateStatus?.running ? ( 3181 - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3182 - ) : ( 3183 - <RefreshCw className="mr-2 h-4 w-4" /> 3184 - )} 3185 - {updateStatus?.running ? 'Updating...' : 'Update'} 3186 - </Button> 3187 - <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 3188 - <Plus className="mr-2 h-4 w-4" /> 3189 - Add Account 3190 - </Button> 3834 + required 3835 + /> 3836 + </div> 3837 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3838 + <Save className="mr-2 h-4 w-4" /> 3839 + Save Password 3840 + </Button> 3841 + </form> 3842 + </CardContent> 3843 + </div> 3844 + </div> 3845 + </Card> 3846 + 3847 + {isAdmin ? ( 3848 + <> 3849 + <Card className="animate-slide-up"> 3850 + <CardHeader> 3851 + <CardTitle className="flex items-center gap-2"> 3852 + <Settings2 className="h-4 w-4" /> 3853 + Admin Settings 3854 + </CardTitle> 3855 + <CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription> 3856 + </CardHeader> 3857 + <CardContent className="space-y-4 pt-0"> 3858 + <div className="rounded-lg border border-border/70 bg-muted/20 p-3"> 3859 + <div className="flex flex-wrap items-start justify-between gap-3"> 3860 + <div className="space-y-1"> 3861 + <p className="text-sm font-semibold">Running Version</p> 3862 + <p className="font-mono text-sm text-foreground">{runtimeVersionLabel}</p> 3863 + {runtimeBranchLabel ? ( 3864 + <p className="text-xs text-muted-foreground">{runtimeBranchLabel}</p> 3865 + ) : null} 3866 + <p className="text-xs text-muted-foreground">{updateStateLabel}</p> 3867 + </div> 3868 + <div className="flex flex-wrap gap-2"> 3869 + <Button 3870 + variant="outline" 3871 + onClick={() => { 3872 + void handleRunUpdate(); 3873 + }} 3874 + disabled={isUpdateBusy || updateStatus?.running} 3875 + > 3876 + {isUpdateBusy || updateStatus?.running ? ( 3877 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3878 + ) : ( 3879 + <RefreshCw className="mr-2 h-4 w-4" /> 3880 + )} 3881 + {updateStatus?.running ? 'Updating...' : 'Update'} 3882 + </Button> 3883 + {canCreateMappings ? ( 3884 + <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 3885 + <Plus className="mr-2 h-4 w-4" /> 3886 + Add Account 3887 + </Button> 3888 + ) : null} 3889 + </div> 3191 3890 </div> 3891 + {updateStatus?.logTail && updateStatus.logTail.length > 0 ? ( 3892 + <details className="mt-3"> 3893 + <summary className="cursor-pointer text-xs font-medium text-muted-foreground"> 3894 + Update log 3895 + </summary> 3896 + <pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"> 3897 + {updateStatus.logTail.join('\n')} 3898 + </pre> 3899 + </details> 3900 + ) : null} 3192 3901 </div> 3193 - {updateStatus?.logTail && updateStatus.logTail.length > 0 ? ( 3194 - <details className="mt-3"> 3195 - <summary className="cursor-pointer text-xs font-medium text-muted-foreground"> 3196 - Update log 3197 - </summary> 3198 - <pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"> 3199 - {updateStatus.logTail.join('\n')} 3200 - </pre> 3201 - </details> 3202 - ) : null} 3203 - </div> 3204 - </CardContent> 3205 - </Card> 3902 + </CardContent> 3903 + </Card> 3206 3904 3207 - <Card className="animate-slide-up"> 3208 - <button 3209 - className="flex w-full items-center justify-between px-5 py-4 text-left" 3210 - onClick={() => toggleSettingsSection('twitter')} 3211 - type="button" 3212 - > 3213 - <div> 3214 - <p className="text-sm font-semibold">Twitter Credentials</p> 3215 - <p className="text-xs text-muted-foreground">Primary and backup cookie values.</p> 3216 - </div> 3217 - <div className="flex items-center gap-2"> 3218 - <Badge variant={twitterConfigured ? 'success' : 'outline'}> 3219 - {twitterConfigured ? 'Configured' : 'Missing'} 3220 - </Badge> 3905 + <Card className="animate-slide-up"> 3906 + <button 3907 + className="flex w-full items-center justify-between px-5 py-4 text-left" 3908 + onClick={() => toggleSettingsSection('users')} 3909 + type="button" 3910 + > 3911 + <div> 3912 + <p className="text-sm font-semibold">User Access Manager</p> 3913 + <p className="text-xs text-muted-foreground">Create users and control what they can see/manage.</p> 3914 + </div> 3221 3915 <ChevronDown 3222 3916 className={cn( 3223 3917 'h-4 w-4 transition-transform duration-200', 3224 - isSettingsSectionExpanded('twitter') ? 'rotate-0' : '-rotate-90', 3918 + isSettingsSectionExpanded('users') ? 'rotate-0' : '-rotate-90', 3225 3919 )} 3226 3920 /> 3921 + </button> 3922 + <div 3923 + className={cn( 3924 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3925 + isSettingsSectionExpanded('users') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3926 + )} 3927 + > 3928 + <div className="min-h-0 overflow-hidden"> 3929 + <CardContent className="space-y-4 border-t border-border/70 pt-4"> 3930 + <form 3931 + className="space-y-3 rounded-lg border border-border/70 bg-muted/20 p-3" 3932 + onSubmit={handleCreateUser} 3933 + > 3934 + <p className="text-sm font-semibold">Create User</p> 3935 + <div className="grid gap-3 md:grid-cols-3"> 3936 + <div className="space-y-2"> 3937 + <Label htmlFor="new-user-username">Username</Label> 3938 + <Input 3939 + id="new-user-username" 3940 + value={newUserForm.username} 3941 + onChange={(event) => { 3942 + setNewUserForm((previous) => ({ ...previous, username: event.target.value })); 3943 + }} 3944 + placeholder="operator" 3945 + /> 3946 + </div> 3947 + <div className="space-y-2"> 3948 + <Label htmlFor="new-user-email">Email</Label> 3949 + <Input 3950 + id="new-user-email" 3951 + type="email" 3952 + value={newUserForm.email} 3953 + onChange={(event) => { 3954 + setNewUserForm((previous) => ({ ...previous, email: event.target.value })); 3955 + }} 3956 + placeholder="operator@example.com" 3957 + /> 3958 + </div> 3959 + <div className="space-y-2"> 3960 + <Label htmlFor="new-user-password">Password</Label> 3961 + <Input 3962 + id="new-user-password" 3963 + type="password" 3964 + value={newUserForm.password} 3965 + onChange={(event) => { 3966 + setNewUserForm((previous) => ({ ...previous, password: event.target.value })); 3967 + }} 3968 + placeholder="Minimum 8 characters" 3969 + required 3970 + /> 3971 + </div> 3972 + </div> 3973 + 3974 + <label className="inline-flex items-center gap-2 text-sm font-medium"> 3975 + <input 3976 + type="checkbox" 3977 + checked={newUserForm.isAdmin} 3978 + onChange={(event) => { 3979 + setNewUserForm((previous) => ({ 3980 + ...previous, 3981 + isAdmin: event.target.checked, 3982 + })); 3983 + }} 3984 + /> 3985 + Make admin 3986 + </label> 3987 + 3988 + {!newUserForm.isAdmin ? ( 3989 + <div className="grid gap-2 md:grid-cols-2"> 3990 + {PERMISSION_OPTIONS.map((permission) => ( 3991 + <label 3992 + key={`new-user-permission-${permission.key}`} 3993 + className="rounded-md border border-border/70 bg-background/80 px-3 py-2 text-xs" 3994 + > 3995 + <span className="flex items-center justify-between gap-2"> 3996 + <span className="font-medium">{permission.label}</span> 3997 + <input 3998 + type="checkbox" 3999 + checked={newUserForm.permissions[permission.key]} 4000 + onChange={(event) => { 4001 + const checked = event.target.checked; 4002 + setNewUserForm((previous) => ({ 4003 + ...previous, 4004 + permissions: { 4005 + ...previous.permissions, 4006 + [permission.key]: checked, 4007 + }, 4008 + })); 4009 + }} 4010 + /> 4011 + </span> 4012 + <span className="mt-1 block text-muted-foreground">{permission.help}</span> 4013 + </label> 4014 + ))} 4015 + </div> 4016 + ) : ( 4017 + <p className="text-xs text-muted-foreground">Admins always get full access.</p> 4018 + )} 4019 + 4020 + <Button size="sm" type="submit" disabled={isBusy}> 4021 + <Plus className="mr-2 h-4 w-4" /> 4022 + Create user 4023 + </Button> 4024 + </form> 4025 + 4026 + {managedUsers.length === 0 ? ( 4027 + <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 4028 + No user accounts created yet. 4029 + </div> 4030 + ) : ( 4031 + <div className="space-y-2"> 4032 + {managedUsers.map((user) => { 4033 + const isEditing = editingUserId === user.id; 4034 + const displayName = user.username || user.email || user.id; 4035 + return ( 4036 + <div 4037 + key={`managed-user-${user.id}`} 4038 + className="rounded-lg border border-border/70 bg-card/60 p-3" 4039 + > 4040 + {isEditing ? ( 4041 + <div className="space-y-3"> 4042 + <div className="grid gap-3 md:grid-cols-2"> 4043 + <div className="space-y-1"> 4044 + <Label htmlFor={`edit-user-username-${user.id}`}>Username</Label> 4045 + <Input 4046 + id={`edit-user-username-${user.id}`} 4047 + value={editingUserForm.username} 4048 + onChange={(event) => { 4049 + setEditingUserForm((previous) => ({ 4050 + ...previous, 4051 + username: event.target.value, 4052 + })); 4053 + }} 4054 + /> 4055 + </div> 4056 + <div className="space-y-1"> 4057 + <Label htmlFor={`edit-user-email-${user.id}`}>Email</Label> 4058 + <Input 4059 + id={`edit-user-email-${user.id}`} 4060 + type="email" 4061 + value={editingUserForm.email} 4062 + onChange={(event) => { 4063 + setEditingUserForm((previous) => ({ 4064 + ...previous, 4065 + email: event.target.value, 4066 + })); 4067 + }} 4068 + /> 4069 + </div> 4070 + </div> 4071 + 4072 + <label className="inline-flex items-center gap-2 text-sm font-medium"> 4073 + <input 4074 + type="checkbox" 4075 + checked={editingUserForm.isAdmin} 4076 + onChange={(event) => { 4077 + setEditingUserForm((previous) => ({ 4078 + ...previous, 4079 + isAdmin: event.target.checked, 4080 + })); 4081 + }} 4082 + /> 4083 + Admin access 4084 + </label> 4085 + 4086 + {!editingUserForm.isAdmin ? ( 4087 + <div className="grid gap-2 md:grid-cols-2"> 4088 + {PERMISSION_OPTIONS.map((permission) => ( 4089 + <label 4090 + key={`edit-user-permission-${user.id}-${permission.key}`} 4091 + className="rounded-md border border-border/70 bg-background/80 px-3 py-2 text-xs" 4092 + > 4093 + <span className="flex items-center justify-between gap-2"> 4094 + <span className="font-medium">{permission.label}</span> 4095 + <input 4096 + type="checkbox" 4097 + checked={editingUserForm.permissions[permission.key]} 4098 + onChange={(event) => { 4099 + const checked = event.target.checked; 4100 + setEditingUserForm((previous) => ({ 4101 + ...previous, 4102 + permissions: { 4103 + ...previous.permissions, 4104 + [permission.key]: checked, 4105 + }, 4106 + })); 4107 + }} 4108 + /> 4109 + </span> 4110 + <span className="mt-1 block text-muted-foreground">{permission.help}</span> 4111 + </label> 4112 + ))} 4113 + </div> 4114 + ) : null} 4115 + 4116 + <div className="flex flex-wrap justify-end gap-2"> 4117 + <Button size="sm" variant="ghost" onClick={resetEditingUser} type="button"> 4118 + Cancel 4119 + </Button> 4120 + <Button 4121 + size="sm" 4122 + onClick={() => { 4123 + void handleSaveEditedUser(user.id); 4124 + }} 4125 + type="button" 4126 + disabled={isBusy} 4127 + > 4128 + Save user 4129 + </Button> 4130 + </div> 4131 + </div> 4132 + ) : ( 4133 + <div className="space-y-3"> 4134 + <div className="flex flex-wrap items-start justify-between gap-3"> 4135 + <div className="space-y-1"> 4136 + <p className="text-sm font-semibold">{displayName}</p> 4137 + <p className="text-xs text-muted-foreground"> 4138 + {user.email ? `Email: ${user.email}` : 'No email set'} 4139 + </p> 4140 + <p className="text-xs text-muted-foreground"> 4141 + {user.mappingCount} mappings ({user.activeMappingCount} active) 4142 + </p> 4143 + </div> 4144 + <div className="flex flex-wrap gap-2"> 4145 + <Badge variant={user.isAdmin ? 'success' : 'outline'}> 4146 + {user.isAdmin ? 'Admin' : 'User'} 4147 + </Badge> 4148 + {user.id === me?.id ? <Badge variant="secondary">You</Badge> : null} 4149 + </div> 4150 + </div> 4151 + 4152 + {!user.isAdmin ? ( 4153 + <div className="flex flex-wrap gap-2"> 4154 + {PERMISSION_OPTIONS.filter( 4155 + (permission) => user.permissions[permission.key], 4156 + ).map((permission) => ( 4157 + <Badge key={`user-perm-${user.id}-${permission.key}`} variant="outline"> 4158 + {permission.label} 4159 + </Badge> 4160 + ))} 4161 + </div> 4162 + ) : null} 4163 + 4164 + <div className="flex flex-wrap gap-2"> 4165 + <Button 4166 + size="sm" 4167 + variant="outline" 4168 + onClick={() => { 4169 + setAccountsCreatorFilter(user.id); 4170 + setActiveTab('accounts'); 4171 + }} 4172 + > 4173 + View Accounts 4174 + </Button> 4175 + <Button size="sm" variant="outline" onClick={() => beginEditUser(user)}> 4176 + Edit 4177 + </Button> 4178 + <Button 4179 + size="sm" 4180 + variant="outline" 4181 + onClick={() => { 4182 + void handleResetUserPassword(user.id); 4183 + }} 4184 + > 4185 + Reset Password 4186 + </Button> 4187 + {user.id !== me?.id ? ( 4188 + <Button 4189 + size="sm" 4190 + variant="ghost" 4191 + className="text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 4192 + onClick={() => { 4193 + void handleDeleteUser(user); 4194 + }} 4195 + > 4196 + Delete 4197 + </Button> 4198 + ) : null} 4199 + </div> 4200 + </div> 4201 + )} 4202 + </div> 4203 + ); 4204 + })} 4205 + </div> 4206 + )} 4207 + </CardContent> 4208 + </div> 3227 4209 </div> 3228 - </button> 3229 - <div 3230 - className={cn( 3231 - 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3232 - isSettingsSectionExpanded('twitter') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3233 - )} 3234 - > 3235 - <div className="min-h-0 overflow-hidden"> 3236 - <CardContent className="space-y-3 border-t border-border/70 pt-4"> 3237 - <form className="space-y-3" onSubmit={handleSaveTwitterConfig}> 3238 - <div className="space-y-2"> 3239 - <Label htmlFor="authToken">Primary Auth Token</Label> 3240 - <Input 3241 - id="authToken" 3242 - value={twitterConfig.authToken} 3243 - onChange={(event) => { 3244 - setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); 3245 - }} 3246 - required 3247 - /> 3248 - </div> 3249 - <div className="space-y-2"> 3250 - <Label htmlFor="ct0">Primary CT0</Label> 3251 - <Input 3252 - id="ct0" 3253 - value={twitterConfig.ct0} 3254 - onChange={(event) => { 3255 - setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); 3256 - }} 3257 - required 3258 - /> 3259 - </div> 4210 + </Card> 3260 4211 3261 - <div className="grid gap-3 sm:grid-cols-2"> 4212 + <Card className="animate-slide-up"> 4213 + <button 4214 + className="flex w-full items-center justify-between px-5 py-4 text-left" 4215 + onClick={() => toggleSettingsSection('twitter')} 4216 + type="button" 4217 + > 4218 + <div> 4219 + <p className="text-sm font-semibold">Twitter Credentials</p> 4220 + <p className="text-xs text-muted-foreground">Primary and backup cookie values.</p> 4221 + </div> 4222 + <div className="flex items-center gap-2"> 4223 + <Badge variant={twitterConfigured ? 'success' : 'outline'}> 4224 + {twitterConfigured ? 'Configured' : 'Missing'} 4225 + </Badge> 4226 + <ChevronDown 4227 + className={cn( 4228 + 'h-4 w-4 transition-transform duration-200', 4229 + isSettingsSectionExpanded('twitter') ? 'rotate-0' : '-rotate-90', 4230 + )} 4231 + /> 4232 + </div> 4233 + </button> 4234 + <div 4235 + className={cn( 4236 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4237 + isSettingsSectionExpanded('twitter') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 4238 + )} 4239 + > 4240 + <div className="min-h-0 overflow-hidden"> 4241 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4242 + <form className="space-y-3" onSubmit={handleSaveTwitterConfig}> 3262 4243 <div className="space-y-2"> 3263 - <Label htmlFor="backupAuthToken">Backup Auth Token</Label> 4244 + <Label htmlFor="authToken">Primary Auth Token</Label> 3264 4245 <Input 3265 - id="backupAuthToken" 3266 - value={twitterConfig.backupAuthToken || ''} 4246 + id="authToken" 4247 + value={twitterConfig.authToken} 3267 4248 onChange={(event) => { 3268 - setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); 4249 + setTwitterConfig((prev) => ({ ...prev, authToken: event.target.value })); 3269 4250 }} 4251 + required 3270 4252 /> 3271 4253 </div> 3272 4254 <div className="space-y-2"> 3273 - <Label htmlFor="backupCt0">Backup CT0</Label> 4255 + <Label htmlFor="ct0">Primary CT0</Label> 3274 4256 <Input 3275 - id="backupCt0" 3276 - value={twitterConfig.backupCt0 || ''} 4257 + id="ct0" 4258 + value={twitterConfig.ct0} 3277 4259 onChange={(event) => { 3278 - setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); 4260 + setTwitterConfig((prev) => ({ ...prev, ct0: event.target.value })); 3279 4261 }} 4262 + required 3280 4263 /> 3281 4264 </div> 3282 - </div> 3283 4265 3284 - <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3285 - <Save className="mr-2 h-4 w-4" /> 3286 - Save Twitter Credentials 3287 - </Button> 3288 - </form> 3289 - </CardContent> 3290 - </div> 3291 - </div> 3292 - </Card> 3293 - 3294 - <Card className="animate-slide-up"> 3295 - <button 3296 - className="flex w-full items-center justify-between px-5 py-4 text-left" 3297 - onClick={() => toggleSettingsSection('ai')} 3298 - type="button" 3299 - > 3300 - <div> 3301 - <p className="text-sm font-semibold">AI Settings</p> 3302 - <p className="text-xs text-muted-foreground">Optional enrichment and rewrite provider config.</p> 3303 - </div> 3304 - <div className="flex items-center gap-2"> 3305 - <Badge variant={aiConfigured ? 'success' : 'outline'}> 3306 - {aiConfigured ? 'Configured' : 'Optional'} 3307 - </Badge> 3308 - <ChevronDown 3309 - className={cn( 3310 - 'h-4 w-4 transition-transform duration-200', 3311 - isSettingsSectionExpanded('ai') ? 'rotate-0' : '-rotate-90', 3312 - )} 3313 - /> 3314 - </div> 3315 - </button> 3316 - <div 3317 - className={cn( 3318 - 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3319 - isSettingsSectionExpanded('ai') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3320 - )} 3321 - > 3322 - <div className="min-h-0 overflow-hidden"> 3323 - <CardContent className="space-y-3 border-t border-border/70 pt-4"> 3324 - <form className="space-y-3" onSubmit={handleSaveAiConfig}> 3325 - <div className="space-y-2"> 3326 - <Label htmlFor="provider">Provider</Label> 3327 - <select 3328 - className={selectClassName} 3329 - id="provider" 3330 - value={aiConfig.provider} 3331 - onChange={(event) => { 3332 - setAiConfig((prev) => ({ ...prev, provider: event.target.value as AIConfig['provider'] })); 3333 - }} 3334 - > 3335 - <option value="gemini">Google Gemini</option> 3336 - <option value="openai">OpenAI / OpenRouter</option> 3337 - <option value="anthropic">Anthropic</option> 3338 - <option value="custom">Custom</option> 3339 - </select> 3340 - </div> 3341 - <div className="space-y-2"> 3342 - <Label htmlFor="apiKey">API Key</Label> 3343 - <Input 3344 - id="apiKey" 3345 - type="password" 3346 - value={aiConfig.apiKey || ''} 3347 - onChange={(event) => { 3348 - setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); 3349 - }} 3350 - /> 3351 - </div> 3352 - {aiConfig.provider !== 'gemini' ? ( 3353 - <> 4266 + <div className="grid gap-3 sm:grid-cols-2"> 3354 4267 <div className="space-y-2"> 3355 - <Label htmlFor="model">Model ID</Label> 4268 + <Label htmlFor="backupAuthToken">Backup Auth Token</Label> 3356 4269 <Input 3357 - id="model" 3358 - value={aiConfig.model || ''} 4270 + id="backupAuthToken" 4271 + value={twitterConfig.backupAuthToken || ''} 3359 4272 onChange={(event) => { 3360 - setAiConfig((prev) => ({ ...prev, model: event.target.value })); 4273 + setTwitterConfig((prev) => ({ ...prev, backupAuthToken: event.target.value })); 3361 4274 }} 3362 - placeholder="gpt-4o" 3363 4275 /> 3364 4276 </div> 3365 4277 <div className="space-y-2"> 3366 - <Label htmlFor="baseUrl">Base URL</Label> 4278 + <Label htmlFor="backupCt0">Backup CT0</Label> 3367 4279 <Input 3368 - id="baseUrl" 3369 - value={aiConfig.baseUrl || ''} 4280 + id="backupCt0" 4281 + value={twitterConfig.backupCt0 || ''} 3370 4282 onChange={(event) => { 3371 - setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); 4283 + setTwitterConfig((prev) => ({ ...prev, backupCt0: event.target.value })); 3372 4284 }} 3373 - placeholder="https://api.example.com/v1" 3374 4285 /> 3375 4286 </div> 3376 - </> 3377 - ) : null} 4287 + </div> 3378 4288 3379 - <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 3380 - <Bot className="mr-2 h-4 w-4" /> 3381 - Save AI Settings 3382 - </Button> 3383 - </form> 3384 - </CardContent> 4289 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 4290 + <Save className="mr-2 h-4 w-4" /> 4291 + Save Twitter Credentials 4292 + </Button> 4293 + </form> 4294 + </CardContent> 4295 + </div> 3385 4296 </div> 3386 - </div> 3387 - </Card> 4297 + </Card> 4298 + 4299 + <Card className="animate-slide-up"> 4300 + <button 4301 + className="flex w-full items-center justify-between px-5 py-4 text-left" 4302 + onClick={() => toggleSettingsSection('ai')} 4303 + type="button" 4304 + > 4305 + <div> 4306 + <p className="text-sm font-semibold">AI Settings</p> 4307 + <p className="text-xs text-muted-foreground">Optional enrichment and rewrite provider config.</p> 4308 + </div> 4309 + <div className="flex items-center gap-2"> 4310 + <Badge variant={aiConfigured ? 'success' : 'outline'}> 4311 + {aiConfigured ? 'Configured' : 'Optional'} 4312 + </Badge> 4313 + <ChevronDown 4314 + className={cn( 4315 + 'h-4 w-4 transition-transform duration-200', 4316 + isSettingsSectionExpanded('ai') ? 'rotate-0' : '-rotate-90', 4317 + )} 4318 + /> 4319 + </div> 4320 + </button> 4321 + <div 4322 + className={cn( 4323 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4324 + isSettingsSectionExpanded('ai') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 4325 + )} 4326 + > 4327 + <div className="min-h-0 overflow-hidden"> 4328 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4329 + <form className="space-y-3" onSubmit={handleSaveAiConfig}> 4330 + <div className="space-y-2"> 4331 + <Label htmlFor="provider">Provider</Label> 4332 + <select 4333 + className={selectClassName} 4334 + id="provider" 4335 + value={aiConfig.provider} 4336 + onChange={(event) => { 4337 + setAiConfig((prev) => ({ 4338 + ...prev, 4339 + provider: event.target.value as AIConfig['provider'], 4340 + })); 4341 + }} 4342 + > 4343 + <option value="gemini">Google Gemini</option> 4344 + <option value="openai">OpenAI / OpenRouter</option> 4345 + <option value="anthropic">Anthropic</option> 4346 + <option value="custom">Custom</option> 4347 + </select> 4348 + </div> 4349 + <div className="space-y-2"> 4350 + <Label htmlFor="apiKey">API Key</Label> 4351 + <Input 4352 + id="apiKey" 4353 + type="password" 4354 + value={aiConfig.apiKey || ''} 4355 + onChange={(event) => { 4356 + setAiConfig((prev) => ({ ...prev, apiKey: event.target.value })); 4357 + }} 4358 + /> 4359 + </div> 4360 + {aiConfig.provider !== 'gemini' ? ( 4361 + <> 4362 + <div className="space-y-2"> 4363 + <Label htmlFor="model">Model ID</Label> 4364 + <Input 4365 + id="model" 4366 + value={aiConfig.model || ''} 4367 + onChange={(event) => { 4368 + setAiConfig((prev) => ({ ...prev, model: event.target.value })); 4369 + }} 4370 + placeholder="gpt-4o" 4371 + /> 4372 + </div> 4373 + <div className="space-y-2"> 4374 + <Label htmlFor="baseUrl">Base URL</Label> 4375 + <Input 4376 + id="baseUrl" 4377 + value={aiConfig.baseUrl || ''} 4378 + onChange={(event) => { 4379 + setAiConfig((prev) => ({ ...prev, baseUrl: event.target.value })); 4380 + }} 4381 + placeholder="https://api.example.com/v1" 4382 + /> 4383 + </div> 4384 + </> 4385 + ) : null} 3388 4386 3389 - <Card className="animate-slide-up"> 3390 - <button 3391 - className="flex w-full items-center justify-between px-5 py-4 text-left" 3392 - onClick={() => toggleSettingsSection('data')} 3393 - type="button" 3394 - > 3395 - <div> 3396 - <p className="text-sm font-semibold">Data Management</p> 3397 - <p className="text-xs text-muted-foreground">Export/import mappings and provider settings.</p> 4387 + <Button className="w-full sm:w-auto" size="sm" type="submit" disabled={isBusy}> 4388 + <Bot className="mr-2 h-4 w-4" /> 4389 + Save AI Settings 4390 + </Button> 4391 + </form> 4392 + </CardContent> 4393 + </div> 3398 4394 </div> 3399 - <ChevronDown 4395 + </Card> 4396 + 4397 + <Card className="animate-slide-up"> 4398 + <button 4399 + className="flex w-full items-center justify-between px-5 py-4 text-left" 4400 + onClick={() => toggleSettingsSection('data')} 4401 + type="button" 4402 + > 4403 + <div> 4404 + <p className="text-sm font-semibold">Data Management</p> 4405 + <p className="text-xs text-muted-foreground">Export/import mappings and provider settings.</p> 4406 + </div> 4407 + <ChevronDown 4408 + className={cn( 4409 + 'h-4 w-4 transition-transform duration-200', 4410 + isSettingsSectionExpanded('data') ? 'rotate-0' : '-rotate-90', 4411 + )} 4412 + /> 4413 + </button> 4414 + <div 3400 4415 className={cn( 3401 - 'h-4 w-4 transition-transform duration-200', 3402 - isSettingsSectionExpanded('data') ? 'rotate-0' : '-rotate-90', 4416 + 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 4417 + isSettingsSectionExpanded('data') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3403 4418 )} 3404 - /> 3405 - </button> 3406 - <div 3407 - className={cn( 3408 - 'grid transition-[grid-template-rows,opacity] duration-300 ease-out', 3409 - isSettingsSectionExpanded('data') ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', 3410 - )} 3411 - > 3412 - <div className="min-h-0 overflow-hidden"> 3413 - <CardContent className="space-y-3 border-t border-border/70 pt-4"> 3414 - <Button className="w-full sm:w-auto" variant="outline" onClick={handleExportConfig}> 3415 - <Download className="mr-2 h-4 w-4" /> 3416 - Export configuration 3417 - </Button> 3418 - <input 3419 - ref={importInputRef} 3420 - className="hidden" 3421 - type="file" 3422 - accept="application/json,.json" 3423 - onChange={(event) => { 3424 - void handleImportConfig(event); 3425 - }} 3426 - /> 3427 - <Button 3428 - className="w-full sm:w-auto" 3429 - variant="outline" 3430 - onClick={() => { 3431 - importInputRef.current?.click(); 3432 - }} 3433 - > 3434 - <Upload className="mr-2 h-4 w-4" /> 3435 - Import configuration 3436 - </Button> 3437 - <p className="text-xs text-muted-foreground"> 3438 - Imports preserve dashboard users and passwords while replacing mappings, provider keys, and 3439 - scheduler settings. 3440 - </p> 3441 - </CardContent> 4419 + > 4420 + <div className="min-h-0 overflow-hidden"> 4421 + <CardContent className="space-y-3 border-t border-border/70 pt-4"> 4422 + <Button className="w-full sm:w-auto" variant="outline" onClick={handleExportConfig}> 4423 + <Download className="mr-2 h-4 w-4" /> 4424 + Export configuration 4425 + </Button> 4426 + <input 4427 + ref={importInputRef} 4428 + className="hidden" 4429 + type="file" 4430 + accept="application/json,.json" 4431 + onChange={(event) => { 4432 + void handleImportConfig(event); 4433 + }} 4434 + /> 4435 + <Button 4436 + className="w-full sm:w-auto" 4437 + variant="outline" 4438 + onClick={() => { 4439 + importInputRef.current?.click(); 4440 + }} 4441 + > 4442 + <Upload className="mr-2 h-4 w-4" /> 4443 + Import configuration 4444 + </Button> 4445 + <p className="text-xs text-muted-foreground"> 4446 + Imports preserve dashboard users and passwords while replacing mappings, provider keys, and 4447 + scheduler settings. 4448 + </p> 4449 + </CardContent> 4450 + </div> 3442 4451 </div> 3443 - </div> 4452 + </Card> 4453 + </> 4454 + ) : ( 4455 + <Card className="animate-slide-up"> 4456 + <CardHeader> 4457 + <CardTitle>Access Scope</CardTitle> 4458 + <CardDescription>Your current account permissions.</CardDescription> 4459 + </CardHeader> 4460 + <CardContent className="flex flex-wrap gap-2 pt-0"> 4461 + {PERMISSION_OPTIONS.filter((permission) => effectivePermissions[permission.key]).map((permission) => ( 4462 + <Badge key={`self-perm-${permission.key}`} variant="outline"> 4463 + {permission.label} 4464 + </Badge> 4465 + ))} 4466 + </CardContent> 3444 4467 </Card> 3445 - </section> 3446 - ) : null 4468 + )} 4469 + </section> 3447 4470 ) : null} 3448 - 3449 4471 {isAddAccountSheetOpen ? ( 3450 4472 <div 3451 4473 className="fixed inset-0 z-50 flex items-end justify-center bg-black/55 p-0 backdrop-blur-sm sm:items-stretch sm:justify-end" ··· 3635 4657 <div className="space-y-4 animate-fade-in"> 3636 4658 <div className="space-y-1"> 3637 4659 <p className="text-sm font-semibold">Target Bluesky account</p> 3638 - <p className="text-xs text-muted-foreground"> 3639 - Use an app password for the destination account. 3640 - </p> 4660 + <p className="text-xs text-muted-foreground">Use an app password for the destination account.</p> 3641 4661 </div> 3642 4662 <div className="space-y-2"> 3643 4663 <Label htmlFor="add-account-bsky-identifier">Bluesky Identifier</Label> ··· 3864 4884 </form> 3865 4885 </CardContent> 3866 4886 </Card> 3867 - </div> 3868 - ) : null} 3869 - 3870 - {!isAdmin ? ( 3871 - <div className="mt-6 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300"> 3872 - <p className="flex items-center gap-2"> 3873 - <AlertTriangle className="h-4 w-4" /> 3874 - Admin-only settings are hidden for this account. 3875 - </p> 3876 4887 </div> 3877 4888 ) : null} 3878 4889 </main>