feat(admin): add staff admin console

Internal, admin-only /admin console for running the business: - Dashboard: growth, estimated MRR, catalog, and cloud usage rollups - Users: search/filter/paginate, detail, grant/revoke staff access - Repositories: full catalog with kind/privacy filters and detail - Subscriptions: revenue view with plan/status breakdowns Gated by a new users.admin flag + require_admin!; granted by hand via `rake admin:grant`. Read logic lives in Admin::* query objects under app/queries with unit specs, keeping controllers thin. Renders in a dedicated admin layout reusing existing Tailwind tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jul 1, 2026 at 08:06 UTC 1f2377d3e50e24d08a9d413513bd340ec5ded188
30 files changed +1096 -2
app/controllers/admin/base_controller.rb
+13
new file mode 100644 index 0000000..de730ee --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Admin + # Shared base for the internal admin console. Every admin page requires a + # signed-in staff account, renders in the dedicated admin layout, and is kept + # out of search indexes. + class BaseController < ApplicationController + before_action :require_admin! + before_action :noindex! + + layout "admin" + end +end
app/controllers/admin/dashboard_controller.rb
+11
new file mode 100644 index 0000000..4d8c265 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Admin + # The admin landing page: the business at a glance — growth, catalog, and + # recurring revenue. Read-only; the rollups live in Admin::DashboardMetrics. + class DashboardController < BaseController + def show + @metrics = DashboardMetrics.new + end + end +end
app/controllers/admin/repositories_controller.rb
+17
new file mode 100644 index 0000000..f0615c8 --- /dev/null +++ b/app/controllers/admin/repositories_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + # Browse the full repository catalog across all owners — code and model repos, + # public and private. Listing/search lives in Admin::RepositorySearch. + class RepositoriesController < BaseController + def index + @search = RepositorySearch.new(params) + end + + def show + @repository = Repository.includes(:user).find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to admin_repositories_path, alert: "Repository not found." + end + end +end
app/controllers/admin/subscriptions_controller.rb
+12
new file mode 100644 index 0000000..a6d336f --- /dev/null +++ b/app/controllers/admin/subscriptions_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin + # Recurring-revenue view: every subscription with its plan, status, and the + # customer behind it, plus headline MRR and plan/status breakdowns. The query + # and rollups live in Admin::SubscriptionSearch. + class SubscriptionsController < BaseController + def index + @search = SubscriptionSearch.new(params) + end + end +end
app/controllers/admin/users_controller.rb
+41
new file mode 100644 index 0000000..e16cff6 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Admin + # Browse and inspect user accounts; grant or revoke staff (admin) access. + # Listing/search lives in Admin::UserSearch; this controller just handles the + # request and the two state-changing actions. + class UsersController < BaseController + def index + @search = UserSearch.new(params) + end + + def show + @user = User.find_by!(username: params[:id]) + @subscription = @user.subscription + @repositories = @user.repositories.order(updated_at: :desc).limit(20) + @repos_count = @user.repositories.count + @cloud_used = @user.cloud_requests_used + @cloud_allow = @user.cloud_allowance + rescue ActiveRecord::RecordNotFound + redirect_to admin_users_path, alert: "User not found." + end + + def grant_admin + user = User.find_by!(username: params[:id]) + user.update!(admin: true) + redirect_to admin_user_path(user.username), notice: "Granted admin to #{user.username}." + end + + def revoke_admin + user = User.find_by!(username: params[:id]) + + if user == current_user + return redirect_to admin_user_path(user.username), + alert: "You can't revoke your own admin access." + end + + user.update!(admin: false) + redirect_to admin_user_path(user.username), notice: "Revoked admin from #{user.username}." + end + end +end
app/controllers/application_controller.rb
+20 -1
index cbb0673..c9ec133 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,7 +7,7 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes - helper_method :current_user, :signed_in? + helper_method :current_user, :signed_in?, :current_user_admin? private @@ -19,6 +19,12 @@ class ApplicationController < ActionController::Base current_user.present? end + # True only for signed-in staff. Drives the /admin console gate and the + # conditional "Admin" link in the account menu. + def current_user_admin? + signed_in? && current_user.admin? + end + def require_sign_in! unless signed_in? session[:return_to] = request.fullpath @@ -26,6 +32,19 @@ class ApplicationController < ActionController::Base end end + # Gate for the admin console. Non-admins are sent home with a 404-ish message + # rather than a 403 so we don't advertise the console's existence. + def require_admin! + return if current_user_admin? + + if signed_in? + redirect_to root_path, alert: "Not found." + else + session[:return_to] = request.fullpath + redirect_to signin_path, alert: "Please sign in to continue." + end + end + # Marks the current response as off-limits to search engines. The SEO partial # reads @noindex and emits <meta name="robots" content="noindex, nofollow">. # Use for auth, settings, billing, and other non-content/transactional pages.
app/helpers/admin_helper.rb
+36
new file mode 100644 index 0000000..e5a5e2b --- /dev/null +++ b/app/helpers/admin_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module AdminHelper + # Colored pill for a subscription plan. + def admin_plan_badge(plan) + plan = plan.to_s + classes = + case plan + when "team" then "bg-purple-500/15 text-purple-300" + when "pro" then "bg-brand-500/15 text-brand-300" + else "bg-surface-600 text-gray-300" + end + content_tag(:span, plan.presence&.capitalize || "Free", + class: "inline-flex items-center rounded px-2 py-0.5 text-xs font-medium #{classes}") + end + + # Colored pill for a subscription status. + def admin_status_badge(status) + status = status.to_s + classes = + case status + when "active" then "bg-green-500/15 text-green-300" + when "trialing" then "bg-blue-500/15 text-blue-300" + when "past_due", "incomplete" then "bg-amber-500/15 text-amber-300" + when "canceled" then "bg-red-500/15 text-red-300" + else "bg-surface-600 text-gray-300" + end + content_tag(:span, status.tr("_", " ").presence&.capitalize || "—", + class: "inline-flex items-center rounded px-2 py-0.5 text-xs font-medium #{classes}") + end + + # Compact USD, e.g. 1234 -> "$1,234". + def admin_usd(amount) + "$#{number_with_delimiter(amount.to_i)}" + end +end
app/models/user.rb
+4
index 54cb9d2..9bfbaa1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,10 @@ class User < ApplicationRecord has_many :cloud_usages, dependent: :destroy has_many :cloud_sessions, dependent: :destroy + # Staff who can reach the /admin console. `admin` is a plain boolean column, so + # `admin?` is provided by ActiveRecord. Granted by hand — see `rake admin:grant`. + scope :admins, -> { where(admin: true) } + # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free / # unsubscribed users run on-device only. def entitled_to_cloud?
app/queries/admin/dashboard_metrics.rb
+45
new file mode 100644 index 0000000..664529f --- /dev/null +++ b/app/queries/admin/dashboard_metrics.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Admin + # Read model for the admin dashboard. Holds every rollup the overview page + # needs so the controller stays a one-liner and the numbers are unit-testable + # without going through HTTP. All methods are memoized; build one per request. + class DashboardMetrics + # --- Growth -------------------------------------------------------------- + def total_users = @total_users ||= User.count + def admins_count = @admins_count ||= User.admins.count + def new_users_7d = @new_users_7d ||= User.where(created_at: 7.days.ago..).count + def new_users_30d = @new_users_30d ||= User.where(created_at: 30.days.ago..).count + + # --- Catalog ------------------------------------------------------------- + def total_repos = @total_repos ||= Repository.count + def code_repos = @code_repos ||= Repository.where(kind: "code").count + def model_repos = @model_repos ||= Repository.where(kind: "model").count + def private_repos = @private_repos ||= Repository.where(is_private: true).count + + # --- Recurring revenue --------------------------------------------------- + # group(:plan) keys back as the enum's string labels ("pro", "team"). + def paying_by_plan + @paying_by_plan ||= Subscription.where(status: %i[active trialing]) + .where.not(plan: :free) + .group(:plan).count + end + + def paying(plan) = paying_by_plan[plan.to_s].to_i + def trialing_count = @trialing_count ||= Subscription.where(status: :trialing).count + def past_due_count = @past_due_count ||= Subscription.where(status: :past_due).count + def estimated_mrr = @estimated_mrr ||= BillingMetrics.estimated_mrr + + # --- Cloud consumption --------------------------------------------------- + def cloud_requests_month + @cloud_requests_month ||= + CloudUsage.where(period_key: Time.current.utc.strftime("%Y-%m")).sum(:request_count) + end + + def cloud_sessions_count = @cloud_sessions_count ||= CloudSession.count + + # --- Recent activity ----------------------------------------------------- + def recent_users = @recent_users ||= User.order(created_at: :desc).limit(8).to_a + def recent_repos = @recent_repos ||= Repository.includes(:user).order(created_at: :desc).limit(8).to_a + end +end
app/queries/admin/repository_search.rb
+42
new file mode 100644 index 0000000..928c683 --- /dev/null +++ b/app/queries/admin/repository_search.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Admin + # Search / filter / paginate the repository catalog for the admin repos index. + class RepositorySearch + PER_PAGE = 30 + + attr_reader :query, :kind, :filter, :page + + def initialize(params) + @query = params[:q].to_s.strip + @kind = params[:kind].presence + @filter = params[:filter].presence + @page = [ params[:page].to_i, 1 ].max + end + + def records + @records ||= scope.order(created_at: :desc) + .limit(PER_PAGE) + .offset((page - 1) * PER_PAGE) + .to_a + end + + def total = @total ||= scope.count + def has_next? = page * PER_PAGE < total + def per_page = PER_PAGE + def private_only? = filter == "private" + + private + + def scope + s = Repository.includes(:user) + s = s.where(kind: kind) if %w[code model].include?(kind) + s = s.where(is_private: true) if private_only? + if query.present? + like = "%#{Repository.sanitize_sql_like(query)}%" + s = s.where("repositories.name ILIKE :q OR repositories.description ILIKE :q", q: like) + end + s + end + end +end
app/queries/admin/subscription_search.rb
+43
new file mode 100644 index 0000000..2ab759e --- /dev/null +++ b/app/queries/admin/subscription_search.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Admin + # Filter / paginate subscriptions and expose the revenue rollups the admin + # subscriptions index needs (MRR + plan/status breakdowns). + class SubscriptionSearch + PER_PAGE = 30 + + attr_reader :plan, :status, :page + + def initialize(params) + @plan = params[:plan].presence + @status = params[:status].presence + @page = [ params[:page].to_i, 1 ].max + end + + def records + @records ||= scope.includes(:user) + .order(created_at: :desc) + .limit(PER_PAGE) + .offset((page - 1) * PER_PAGE) + .to_a + end + + def total = @total ||= scope.count + def has_next? = page * PER_PAGE < total + def per_page = PER_PAGE + def estimated_mrr = BillingMetrics.estimated_mrr + + # group(:plan)/group(:status) key back as the enums' string labels. + def by_plan = @by_plan ||= Subscription.group(:plan).count + def by_status = @by_status ||= Subscription.group(:status).count + + private + + def scope + s = Subscription.all + s = s.where(status: status) if Subscription.statuses.key?(status) + s = s.where(plan: plan) if Subscription.plans.key?(plan) + s + end + end +end
app/queries/admin/user_search.rb
+43
new file mode 100644 index 0000000..ea0f53f --- /dev/null +++ b/app/queries/admin/user_search.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Admin + # Search / filter / paginate users for the admin users index. Encapsulates the + # query so the controller is a one-liner and the behaviour is unit-testable + # without HTTP. Instantiate with the request params. + class UserSearch + PER_PAGE = 30 + + attr_reader :query, :filter, :page + + def initialize(params) + @query = params[:q].to_s.strip + @filter = params[:filter].presence + @page = [ params[:page].to_i, 1 ].max + end + + def records + @records ||= scope.includes(:subscription) + .order(created_at: :desc) + .limit(PER_PAGE) + .offset((page - 1) * PER_PAGE) + .to_a + end + + def total = @total ||= scope.count + def has_next? = page * PER_PAGE < total + def per_page = PER_PAGE + def admins_only? = filter == "admins" + + private + + def scope + s = User.all + s = s.admins if admins_only? + if query.present? + like = "%#{User.sanitize_sql_like(query)}%" + s = s.where("username ILIKE :q OR email ILIKE :q OR display_name ILIKE :q", q: like) + end + s + end + end +end
app/services/billing_metrics.rb
+31
new file mode 100644 index 0000000..fd6d359 --- /dev/null +++ b/app/services/billing_metrics.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Business rollups over local Subscription state for the admin console. Stripe is +# the source of truth for money; these are directional in-app estimates so the +# operator can see recurring revenue without a Stripe round-trip. +# +# MONTHLY_PRICE_USD mirrors the published list price (siGit Code Pro is $20/mo). +# Trials are excluded from MRR since they aren't billing yet. Update these knobs +# if list pricing changes. +module BillingMetrics + MONTHLY_PRICE_USD = { "pro" => 20, "team" => 40 }.freeze + + module_function + + # Directional monthly recurring revenue: sum of list prices over paid plans in + # good standing (active only — trials aren't paying yet). + def estimated_mrr + # group(:plan) returns the enum's string labels ("pro", "team") as keys. + counts = Subscription + .where(status: :active) + .where.not(plan: :free) + .group(:plan).count + + counts.sum { |plan, n| (MONTHLY_PRICE_USD[plan] || 0) * n } + end + + # Monthly price for a plan name, 0 when unpriced (e.g. free). + def price_for(plan) + MONTHLY_PRICE_USD.fetch(plan.to_s, 0) + end +end
app/views/admin/_pagination.html.erb
+20
new file mode 100644 index 0000000..9198408 --- /dev/null +++ b/app/views/admin/_pagination.html.erb @@ -0,0 +1,20 @@ +<%# locals: page, has_next, total, per_page %> +<% if page > 1 || has_next %> + <div class="flex items-center justify-between mt-4 text-sm"> + <span class="text-gray-500"> + <% first = (page - 1) * per_page + 1 %> + <% last = [ page * per_page, total ].min %> + Showing <%= number_with_delimiter(first) %>–<%= number_with_delimiter(last) %> of <%= number_with_delimiter(total) %> + </span> + <div class="flex items-center gap-2"> + <% if page > 1 %> + <%= link_to "← Prev", url_for(request.query_parameters.merge(page: page - 1)), + class: "px-3 py-1.5 rounded border border-surface-600 text-gray-300 hover:bg-surface-700" %> + <% end %> + <% if has_next %> + <%= link_to "Next →", url_for(request.query_parameters.merge(page: page + 1)), + class: "px-3 py-1.5 rounded border border-surface-600 text-gray-300 hover:bg-surface-700" %> + <% end %> + </div> + </div> +<% end %>
app/views/admin/_sidebar.html.erb
+22
new file mode 100644 index 0000000..f9a3924 --- /dev/null +++ b/app/views/admin/_sidebar.html.erb @@ -0,0 +1,22 @@ +<% + nav_items = [ + { label: "Dashboard", path: admin_dashboard_path, active: controller_name == "dashboard" }, + { label: "Users", path: admin_users_path, active: controller_name == "users" }, + { label: "Repositories", path: admin_repositories_path, active: controller_name == "repositories" }, + { label: "Subscriptions", path: admin_subscriptions_path, active: controller_name == "subscriptions" } + ] +%> +<aside class="md:w-56 shrink-0 border-b md:border-b-0 md:border-r border-surface-600 bg-surface-800"> + <div class="px-4 sm:px-5 h-14 flex items-center gap-2 border-b border-surface-600"> + <img src="/icon.png" alt="siGit" class="h-6 w-auto"> + <span class="font-semibold tracking-tight text-gray-100">siGit&nbsp;<span class="text-brand-500">Admin</span></span> + </div> + <nav class="p-3 flex md:flex-col gap-1 overflow-x-auto"> + <% nav_items.each do |item| %> + <%= link_to item[:path], + class: "px-3 py-2 rounded text-sm whitespace-nowrap transition-colors #{item[:active] ? 'bg-brand-500/15 text-brand-300 font-medium' : 'text-gray-400 hover:bg-surface-600 hover:text-gray-200'}" do %> + <%= item[:label] %> + <% end %> + <% end %> + </nav> +</aside>
app/views/admin/_stat.html.erb
+8
new file mode 100644 index 0000000..86c888c --- /dev/null +++ b/app/views/admin/_stat.html.erb @@ -0,0 +1,8 @@ +<%# locals: label, value, sub (optional), accent (optional tailwind text color) %> +<div class="rounded-lg border border-surface-600 bg-surface-800 px-4 py-4"> + <p class="text-xs uppercase tracking-wide text-gray-500"><%= label %></p> + <p class="mt-1 text-2xl font-semibold <%= local_assigns.fetch(:accent, "text-gray-100") %>"><%= value %></p> + <% if local_assigns[:sub].present? %> + <p class="mt-0.5 text-xs text-gray-500"><%= sub %></p> + <% end %> +</div>
app/views/admin/dashboard/show.html.erb
+80
new file mode 100644 index 0000000..c244a27 --- /dev/null +++ b/app/views/admin/dashboard/show.html.erb @@ -0,0 +1,80 @@ +<% content_for :title, "Dashboard" %> + +<div class="mb-6"> + <h1 class="text-xl font-semibold text-gray-100">Overview</h1> + <p class="text-sm text-gray-500">The business at a glance — growth, catalog, and recurring revenue.</p> +</div> + +<%# Growth %> +<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Growth</h2> +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> + <%= render "admin/stat", label: "Total users", value: number_with_delimiter(@metrics.total_users), + sub: "#{@metrics.admins_count} admin#{'s' unless @metrics.admins_count == 1}" %> + <%= render "admin/stat", label: "New · 7 days", value: "+#{number_with_delimiter(@metrics.new_users_7d)}", accent: "text-green-300" %> + <%= render "admin/stat", label: "New · 30 days", value: "+#{number_with_delimiter(@metrics.new_users_30d)}", accent: "text-green-300" %> + <%= render "admin/stat", label: "Cloud sessions", value: number_with_delimiter(@metrics.cloud_sessions_count) %> +</div> + +<%# Revenue %> +<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Recurring revenue</h2> +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> + <%= render "admin/stat", label: "Estimated MRR", value: admin_usd(@metrics.estimated_mrr), + sub: "active paid plans", accent: "text-brand-300" %> + <%= render "admin/stat", label: "Paying · Pro", value: number_with_delimiter(@metrics.paying("pro")) %> + <%= render "admin/stat", label: "Paying · Team", value: number_with_delimiter(@metrics.paying("team")) %> + <%= render "admin/stat", label: "Trialing", value: number_with_delimiter(@metrics.trialing_count), + sub: (@metrics.past_due_count.positive? ? "#{@metrics.past_due_count} past due" : nil), + accent: (@metrics.past_due_count.positive? ? "text-amber-300" : "text-gray-100") %> +</div> + +<%# Catalog %> +<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Catalog</h2> +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8"> + <%= render "admin/stat", label: "Repositories", value: number_with_delimiter(@metrics.total_repos) %> + <%= render "admin/stat", label: "Code repos", value: number_with_delimiter(@metrics.code_repos) %> + <%= render "admin/stat", label: "Model repos", value: number_with_delimiter(@metrics.model_repos) %> + <%= render "admin/stat", label: "Cloud reqs · month", value: number_with_delimiter(@metrics.cloud_requests_month), + sub: "#{number_with_delimiter(@metrics.private_repos)} private repos" %> +</div> + +<%# Recent activity %> +<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <section> + <div class="flex items-center justify-between mb-2"> + <h2 class="text-sm font-semibold text-gray-200">Newest users</h2> + <%= link_to "View all →", admin_users_path, class: "text-xs text-brand-400 hover:text-brand-300" %> + </div> + <div class="rounded-lg border border-surface-600 divide-y divide-surface-600 overflow-hidden"> + <% @metrics.recent_users.each do |user| %> + <%= link_to admin_user_path(user.username), class: "flex items-center gap-3 px-3 py-2 hover:bg-surface-700 transition-colors" do %> + <img src="<%= user.avatar_url_or_default %>" alt="" class="w-7 h-7 rounded-full border border-surface-500"> + <div class="min-w-0 flex-1"> + <p class="text-sm text-gray-200 truncate"><%= user.username %><% if user.admin? %> <span class="text-brand-400 text-xs">· admin</span><% end %></p> + <p class="text-xs text-gray-500 truncate"><%= user.email %></p> + </div> + <span class="text-xs text-gray-500 whitespace-nowrap"><%= time_ago_in_words(user.created_at) %> ago</span> + <% end %> + <% end %> + <% if @metrics.recent_users.empty? %><p class="px-3 py-4 text-sm text-gray-500">No users yet.</p><% end %> + </div> + </section> + + <section> + <div class="flex items-center justify-between mb-2"> + <h2 class="text-sm font-semibold text-gray-200">Newest repositories</h2> + <%= link_to "View all →", admin_repositories_path, class: "text-xs text-brand-400 hover:text-brand-300" %> + </div> + <div class="rounded-lg border border-surface-600 divide-y divide-surface-600 overflow-hidden"> + <% @metrics.recent_repos.each do |repo| %> + <%= link_to admin_repository_path(repo.id), class: "flex items-center gap-3 px-3 py-2 hover:bg-surface-700 transition-colors" do %> + <div class="min-w-0 flex-1"> + <p class="text-sm text-gray-200 truncate"><%= repo.user.username %>/<span class="text-gray-100 font-medium"><%= repo.name %></span></p> + <p class="text-xs text-gray-500 truncate"><%= repo.kind %><%= " · private" if repo.is_private %></p> + </div> + <span class="text-xs text-gray-500 whitespace-nowrap"><%= time_ago_in_words(repo.created_at) %> ago</span> + <% end %> + <% end %> + <% if @metrics.recent_repos.empty? %><p class="px-3 py-4 text-sm text-gray-500">No repositories yet.</p><% end %> + </div> + </section> +</div>
app/views/admin/repositories/index.html.erb
+59
new file mode 100644 index 0000000..c960784 --- /dev/null +++ b/app/views/admin/repositories/index.html.erb @@ -0,0 +1,59 @@ +<% content_for :title, "Repositories" %> + +<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> + <h1 class="text-xl font-semibold text-gray-100">Repositories <span class="text-gray-500 text-base font-normal">(<%= number_with_delimiter(@search.total) %>)</span></h1> + + <%= form_with url: admin_repositories_path, method: :get, class: "flex items-center gap-2" do %> + <% if params[:kind].present? %><%= hidden_field_tag :kind, params[:kind] %><% end %> + <% if params[:filter].present? %><%= hidden_field_tag :filter, params[:filter] %><% end %> + <%= search_field_tag :q, @search.query, placeholder: "Search name, description…", + class: "bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm text-gray-200 w-64 focus:outline-none focus:border-brand-500" %> + <%= submit_tag "Search", class: "px-3 py-1.5 rounded bg-brand-500/15 text-brand-300 text-sm hover:bg-brand-500/25 cursor-pointer border-0" %> + <% end %> +</div> + +<div class="flex items-center gap-2 mb-4 text-sm"> + <% tabs = [ [ "All", {} ], [ "Code", { kind: "code" } ], [ "Models", { kind: "model" } ], [ "Private", { filter: "private" } ] ] %> + <% tabs.each do |label, extra| %> + <% active = (extra[:kind].to_s == params[:kind].to_s) && (extra[:filter].to_s == params[:filter].to_s) %> + <%= link_to label, admin_repositories_path(extra.merge(q: @search.query.presence)), + class: "px-2.5 py-1 rounded #{active ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <% end %> +</div> + +<div class="rounded-lg border border-surface-600 overflow-hidden"> + <table class="w-full text-sm"> + <thead class="bg-surface-800 text-gray-500 text-xs uppercase tracking-wide"> + <tr> + <th class="text-left font-medium px-4 py-2.5">Repository</th> + <th class="text-left font-medium px-4 py-2.5 hidden sm:table-cell">Kind</th> + <th class="text-left font-medium px-4 py-2.5 hidden md:table-cell">Stars</th> + <th class="text-left font-medium px-4 py-2.5 hidden lg:table-cell">Created</th> + <th class="px-4 py-2.5"></th> + </tr> + </thead> + <tbody class="divide-y divide-surface-600"> + <% @search.records.each do |repo| %> + <tr class="hover:bg-surface-800/50"> + <td class="px-4 py-2.5"> + <p class="text-gray-200"><%= repo.user.username %>/<span class="text-gray-100 font-medium"><%= repo.name %></span> + <% if repo.is_private %><span class="ml-1 inline-flex items-center rounded bg-surface-600 text-gray-400 px-1.5 py-0.5 text-xs">private</span><% end %> + </p> + <% if repo.description.present? %><p class="text-xs text-gray-500 truncate max-w-md"><%= repo.description %></p><% end %> + </td> + <td class="px-4 py-2.5 hidden sm:table-cell text-gray-400"><%= repo.kind %></td> + <td class="px-4 py-2.5 hidden md:table-cell text-gray-400"><%= repo.stars_count %></td> + <td class="px-4 py-2.5 hidden lg:table-cell text-gray-500"><%= repo.created_at.strftime("%b %-d, %Y") %></td> + <td class="px-4 py-2.5 text-right"> + <%= link_to "View", admin_repository_path(repo.id), class: "text-brand-400 hover:text-brand-300" %> + </td> + </tr> + <% end %> + <% if @search.records.empty? %> + <tr><td colspan="5" class="px-4 py-8 text-center text-gray-500">No repositories match your search.</td></tr> + <% end %> + </tbody> + </table> +</div> + +<%= render "admin/pagination", page: @search.page, has_next: @search.has_next?, total: @search.total, per_page: @search.per_page %>
app/views/admin/repositories/show.html.erb
+33
new file mode 100644 index 0000000..58ec37c --- /dev/null +++ b/app/views/admin/repositories/show.html.erb @@ -0,0 +1,33 @@ +<% content_for :title, @repository.full_name %> + +<div class="mb-4"> + <%= link_to "← All repositories", admin_repositories_path, class: "text-sm text-gray-400 hover:text-gray-200" %> +</div> + +<div class="flex flex-wrap items-start justify-between gap-4 mb-6"> + <div> + <h1 class="text-xl font-semibold text-gray-100"> + <%= link_to @repository.user.username, admin_user_path(@repository.user.username), class: "text-gray-400 hover:text-gray-200" %>/<%= @repository.name %> + <% if @repository.is_private %><span class="ml-1 inline-flex items-center rounded bg-surface-600 text-gray-400 px-2 py-0.5 text-xs align-middle">private</span><% end %> + </h1> + <% if @repository.description.present? %><p class="text-sm text-gray-500 mt-1"><%= @repository.description %></p><% end %> + </div> + <%= link_to "Open on site ↗", @repository.html_url, target: "_blank", rel: "noopener", + class: "text-sm text-brand-400 hover:text-brand-300" %> +</div> + +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> + <%= render "admin/stat", label: "Kind", value: @repository.kind.capitalize %> + <%= render "admin/stat", label: "Stars", value: number_with_delimiter(@repository.stars_count) %> + <%= render "admin/stat", label: "Downloads", value: number_with_delimiter(@repository.downloads_count) %> + <%= render "admin/stat", label: "Initialized", value: (@repository.initialized? ? "Yes" : "No"), + accent: (@repository.initialized? ? "text-green-300" : "text-gray-300") %> +</div> + +<div class="rounded-lg border border-surface-600 bg-surface-800 divide-y divide-surface-600 text-sm"> + <div class="flex justify-between px-4 py-2.5"><span class="text-gray-500">Default branch</span><span class="text-gray-300 font-mono"><%= @repository.default_branch %></span></div> + <div class="flex justify-between px-4 py-2.5"><span class="text-gray-500">Disk path</span><span class="text-gray-400 font-mono text-xs truncate max-w-xs"><%= @repository.disk_path %></span></div> + <div class="flex justify-between px-4 py-2.5"><span class="text-gray-500">Visibility</span><span class="text-gray-300"><%= @repository.is_private ? "Private" : "Public" %></span></div> + <div class="flex justify-between px-4 py-2.5"><span class="text-gray-500">Created</span><span class="text-gray-300"><%= @repository.created_at.strftime("%b %-d, %Y %H:%M") %></span></div> + <div class="flex justify-between px-4 py-2.5"><span class="text-gray-500">Updated</span><span class="text-gray-300"><%= @repository.updated_at.strftime("%b %-d, %Y %H:%M") %></span></div> +</div>
app/views/admin/subscriptions/index.html.erb
+68
new file mode 100644 index 0000000..192037a --- /dev/null +++ b/app/views/admin/subscriptions/index.html.erb @@ -0,0 +1,68 @@ +<% content_for :title, "Subscriptions" %> + +<div class="mb-4"> + <h1 class="text-xl font-semibold text-gray-100">Subscriptions <span class="text-gray-500 text-base font-normal">(<%= number_with_delimiter(@search.total) %>)</span></h1> + <p class="text-sm text-gray-500">Local mirror of Stripe subscription state. Money is settled in Stripe.</p> +</div> + +<% by_plan = @search.by_plan; by_status = @search.by_status %> +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> + <%= render "admin/stat", label: "Estimated MRR", value: admin_usd(@search.estimated_mrr), sub: "active paid plans", accent: "text-brand-300" %> + <%= render "admin/stat", label: "Pro", value: number_with_delimiter(by_plan["pro"].to_i) %> + <%= render "admin/stat", label: "Team", value: number_with_delimiter(by_plan["team"].to_i) %> + <%= render "admin/stat", label: "Active", value: number_with_delimiter(by_status["active"].to_i), + sub: "#{by_status['trialing'].to_i} trialing · #{by_status['past_due'].to_i} past due" %> +</div> + +<div class="flex flex-wrap items-center gap-2 mb-4 text-sm"> + <span class="text-gray-500">Plan:</span> + <%= link_to "All", admin_subscriptions_path(status: params[:status].presence), + class: "px-2.5 py-1 rounded #{params[:plan].blank? ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <% %w[free pro team].each do |plan| %> + <%= link_to plan.capitalize, admin_subscriptions_path(plan: plan, status: params[:status].presence), + class: "px-2.5 py-1 rounded #{params[:plan] == plan ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <% end %> + <span class="text-gray-600 mx-1">·</span> + <span class="text-gray-500">Status:</span> + <%= link_to "All", admin_subscriptions_path(plan: params[:plan].presence), + class: "px-2.5 py-1 rounded #{params[:status].blank? ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <% %w[active trialing past_due canceled].each do |status| %> + <%= link_to status.tr("_", " ").capitalize, admin_subscriptions_path(status: status, plan: params[:plan].presence), + class: "px-2.5 py-1 rounded #{params[:status] == status ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <% end %> +</div> + +<div class="rounded-lg border border-surface-600 overflow-hidden"> + <table class="w-full text-sm"> + <thead class="bg-surface-800 text-gray-500 text-xs uppercase tracking-wide"> + <tr> + <th class="text-left font-medium px-4 py-2.5">Customer</th> + <th class="text-left font-medium px-4 py-2.5">Plan</th> + <th class="text-left font-medium px-4 py-2.5">Status</th> + <th class="text-left font-medium px-4 py-2.5 hidden md:table-cell">Renews</th> + <th class="text-left font-medium px-4 py-2.5 hidden lg:table-cell">Stripe customer</th> + </tr> + </thead> + <tbody class="divide-y divide-surface-600"> + <% @search.records.each do |sub| %> + <tr class="hover:bg-surface-800/50"> + <td class="px-4 py-2.5"> + <%= link_to admin_user_path(sub.user.username), class: "text-gray-200 hover:text-brand-300" do %> + <%= sub.user.username %> + <% end %> + <p class="text-xs text-gray-500"><%= sub.user.email %></p> + </td> + <td class="px-4 py-2.5"><%= admin_plan_badge(sub.plan) %></td> + <td class="px-4 py-2.5"><%= admin_status_badge(sub.status) %></td> + <td class="px-4 py-2.5 hidden md:table-cell text-gray-500"><%= sub.current_period_end&.strftime("%b %-d, %Y") || "—" %></td> + <td class="px-4 py-2.5 hidden lg:table-cell text-gray-600 font-mono text-xs"><%= sub.stripe_customer_id.presence || "—" %></td> + </tr> + <% end %> + <% if @search.records.empty? %> + <tr><td colspan="5" class="px-4 py-8 text-center text-gray-500">No subscriptions match this filter.</td></tr> + <% end %> + </tbody> + </table> +</div> + +<%= render "admin/pagination", page: @search.page, has_next: @search.has_next?, total: @search.total, per_page: @search.per_page %>
app/views/admin/users/index.html.erb
+66
new file mode 100644 index 0000000..33ccd0e --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,66 @@ +<% content_for :title, "Users" %> + +<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> + <h1 class="text-xl font-semibold text-gray-100">Users <span class="text-gray-500 text-base font-normal">(<%= number_with_delimiter(@search.total) %>)</span></h1> + + <div class="flex items-center gap-2"> + <%= form_with url: admin_users_path, method: :get, class: "flex items-center gap-2" do %> + <% if params[:filter].present? %><%= hidden_field_tag :filter, params[:filter] %><% end %> + <%= search_field_tag :q, @search.query, placeholder: "Search username, email, name…", + class: "bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm text-gray-200 w-64 focus:outline-none focus:border-brand-500" %> + <%= submit_tag "Search", class: "px-3 py-1.5 rounded bg-brand-500/15 text-brand-300 text-sm hover:bg-brand-500/25 cursor-pointer border-0" %> + <% end %> + </div> +</div> + +<div class="flex items-center gap-2 mb-4 text-sm"> + <%= link_to "All", admin_users_path(q: @search.query.presence), + class: "px-2.5 py-1 rounded #{params[:filter].blank? ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> + <%= link_to "Admins", admin_users_path(filter: "admins", q: @search.query.presence), + class: "px-2.5 py-1 rounded #{params[:filter] == 'admins' ? 'bg-surface-600 text-gray-200' : 'text-gray-400 hover:text-gray-200'}" %> +</div> + +<div class="rounded-lg border border-surface-600 overflow-hidden"> + <table class="w-full text-sm"> + <thead class="bg-surface-800 text-gray-500 text-xs uppercase tracking-wide"> + <tr> + <th class="text-left font-medium px-4 py-2.5">User</th> + <th class="text-left font-medium px-4 py-2.5 hidden sm:table-cell">Plan</th> + <th class="text-left font-medium px-4 py-2.5 hidden md:table-cell">Repos</th> + <th class="text-left font-medium px-4 py-2.5 hidden lg:table-cell">Joined</th> + <th class="px-4 py-2.5"></th> + </tr> + </thead> + <tbody class="divide-y divide-surface-600"> + <% @search.records.each do |user| %> + <tr class="hover:bg-surface-800/50"> + <td class="px-4 py-2.5"> + <div class="flex items-center gap-3"> + <img src="<%= user.avatar_url_or_default %>" alt="" class="w-7 h-7 rounded-full border border-surface-500"> + <div class="min-w-0"> + <p class="text-gray-200 truncate"> + <%= user.username %> + <% if user.admin? %><span class="ml-1 inline-flex items-center rounded bg-brand-500/15 text-brand-300 px-1.5 py-0.5 text-xs">admin</span><% end %> + </p> + <p class="text-xs text-gray-500 truncate"><%= user.email %></p> + </div> + </div> + </td> + <td class="px-4 py-2.5 hidden sm:table-cell"> + <%= admin_plan_badge(user.subscription&.plan) %> + </td> + <td class="px-4 py-2.5 hidden md:table-cell text-gray-400"><%= user.repositories.size %></td> + <td class="px-4 py-2.5 hidden lg:table-cell text-gray-500"><%= user.created_at.strftime("%b %-d, %Y") %></td> + <td class="px-4 py-2.5 text-right"> + <%= link_to "View", admin_user_path(user.username), class: "text-brand-400 hover:text-brand-300" %> + </td> + </tr> + <% end %> + <% if @search.records.empty? %> + <tr><td colspan="5" class="px-4 py-8 text-center text-gray-500">No users match your search.</td></tr> + <% end %> + </tbody> + </table> +</div> + +<%= render "admin/pagination", page: @search.page, has_next: @search.has_next?, total: @search.total, per_page: @search.per_page %>
app/views/admin/users/show.html.erb
+73
new file mode 100644 index 0000000..969b662 --- /dev/null +++ b/app/views/admin/users/show.html.erb @@ -0,0 +1,73 @@ +<% content_for :title, @user.username %> + +<div class="mb-4"> + <%= link_to "← All users", admin_users_path, class: "text-sm text-gray-400 hover:text-gray-200" %> +</div> + +<div class="flex flex-wrap items-start justify-between gap-4 mb-6"> + <div class="flex items-center gap-4"> + <img src="<%= @user.avatar_url_or_default %>" alt="" class="w-14 h-14 rounded-full border border-surface-500"> + <div> + <h1 class="text-xl font-semibold text-gray-100 flex items-center gap-2"> + <%= @user.display_name_or_username %> + <% if @user.admin? %><span class="inline-flex items-center rounded bg-brand-500/15 text-brand-300 px-2 py-0.5 text-xs">admin</span><% end %> + </h1> + <p class="text-sm text-gray-500"><%= @user.email %></p> + <p class="text-xs text-gray-600 mt-0.5"> + <%= link_to "@#{@user.username}", user_profile_path(@user.username), class: "hover:text-gray-400", target: "_blank" %> + · smbCloud #<%= @user.smbcloud_id %> · joined <%= @user.created_at.strftime("%b %-d, %Y") %> + </p> + </div> + </div> + + <div class="flex items-center gap-2"> + <% if @user.admin? %> + <%= button_to "Revoke admin", revoke_admin_admin_user_path(@user.username), method: :patch, + form: { data: { turbo_confirm: "Revoke admin access from #{@user.username}?" } }, + class: "px-3 py-1.5 rounded border border-red-800/50 text-red-300 text-sm hover:bg-red-900/30 cursor-pointer" %> + <% else %> + <%= button_to "Grant admin", grant_admin_admin_user_path(@user.username), method: :patch, + form: { data: { turbo_confirm: "Grant admin (staff) access to #{@user.username}?" } }, + class: "px-3 py-1.5 rounded bg-brand-500/15 text-brand-300 text-sm hover:bg-brand-500/25 cursor-pointer border-0" %> + <% end %> + </div> +</div> + +<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6"> + <%= render "admin/stat", label: "Repositories", value: @repos_count %> + <%= render "admin/stat", label: "Plan", value: (@subscription&.plan || "free").capitalize %> + <%= render "admin/stat", label: "Cloud used", value: "#{number_with_delimiter(@cloud_used)} / #{number_with_delimiter(@cloud_allow)}", + sub: "this period" %> + <%= render "admin/stat", label: "Entitled to cloud", value: (@user.entitled_to_cloud? ? "Yes" : "No"), + accent: (@user.entitled_to_cloud? ? "text-green-300" : "text-gray-300") %> +</div> + +<% if @subscription %> + <section class="mb-6"> + <h2 class="text-sm font-semibold text-gray-200 mb-2">Subscription</h2> + <div class="rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> + <span><%= admin_plan_badge(@subscription.plan) %> <%= admin_status_badge(@subscription.status) %></span> + <% if @subscription.current_period_end %> + <span class="text-gray-500">Renews <%= @subscription.current_period_end.strftime("%b %-d, %Y") %></span> + <% end %> + <% if @subscription.stripe_customer_id.present? %> + <span class="text-gray-600 font-mono text-xs"><%= @subscription.stripe_customer_id %></span> + <% end %> + </div> + </section> +<% end %> + +<section> + <h2 class="text-sm font-semibold text-gray-200 mb-2">Repositories <span class="text-gray-500 font-normal">(<%= @repos_count %>)</span></h2> + <div class="rounded-lg border border-surface-600 divide-y divide-surface-600 overflow-hidden"> + <% @repositories.each do |repo| %> + <%= link_to admin_repository_path(repo.id), class: "flex items-center justify-between px-3 py-2 hover:bg-surface-700 transition-colors" do %> + <span class="text-sm text-gray-200"><%= repo.name %> + <span class="text-xs text-gray-500 ml-1"><%= repo.kind %><%= " · private" if repo.is_private %></span> + </span> + <span class="text-xs text-gray-500"><%= repo.updated_at.strftime("%b %-d, %Y") %></span> + <% end %> + <% end %> + <% if @repositories.empty? %><p class="px-3 py-4 text-sm text-gray-500">No repositories.</p><% end %> + </div> +</section>
app/views/layouts/admin.html.erb
+49
new file mode 100644 index 0000000..d855e8d --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html lang="en" class="h-full"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="color-scheme" content="dark"> + <meta name="theme-color" content="#1a1d21"> + <meta name="robots" content="noindex, nofollow"> + <title><%= content_for?(:title) ? "#{yield(:title)} · siGit Admin" : "siGit Admin" %></title> + <link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg"> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + <%= csrf_meta_tags %> + </head> + + <body class="h-full bg-surface-900 text-gray-200"> + <div class="min-h-full flex flex-col md:flex-row"> + <%= render "admin/sidebar" %> + + <div class="flex-1 min-w-0"> + <header class="border-b border-surface-600 bg-surface-800 px-4 sm:px-8 h-14 flex items-center justify-between"> + <div class="flex items-center gap-2 text-sm text-gray-400"> + <span class="inline-flex items-center gap-1.5 rounded bg-brand-500/10 text-brand-400 px-2 py-0.5 text-xs font-semibold tracking-wide uppercase"> + Admin + </span> + <span class="text-gray-600">/</span> + <span class="text-gray-300"><%= content_for?(:title) ? yield(:title) : "Console" %></span> + </div> + <div class="flex items-center gap-3 text-sm"> + <%= link_to "↩ Back to site", root_path, class: "text-gray-400 hover:text-gray-200 transition-colors" %> + <span class="text-gray-600">·</span> + <span class="text-gray-400"><%= current_user.username %></span> + </div> + </header> + + <% if notice.present? %> + <div class="bg-green-900/20 border-b border-green-800/40 px-4 sm:px-8 py-2.5 text-sm text-green-300" role="alert"><%= notice %></div> + <% end %> + <% if alert.present? %> + <div class="bg-amber-900/20 border-b border-amber-800/40 px-4 sm:px-8 py-2.5 text-sm text-amber-300" role="alert"><%= alert %></div> + <% end %> + + <main class="px-4 sm:px-8 py-6 max-w-6xl"> + <%= yield %> + </main> + </div> + </div> + </body> +</html>
app/views/shared/_navbar.html.erb
+4
index 675cd1f..472db81 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -47,6 +47,10 @@ class: "block px-3 py-1.5 text-sm text-gray-300 hover:bg-surface-600" do %>Your repositories<% end %> <%= link_to settings_path, class: "block px-3 py-1.5 text-sm text-gray-300 hover:bg-surface-600" do %>Settings<% end %> + <% if current_user_admin? %> + <%= link_to admin_root_path, + class: "block px-3 py-1.5 text-sm text-brand-400 hover:bg-surface-600" do %>Admin console<% end %> + <% end %> <div class="border-t border-surface-600 mt-1"> <%= button_to signout_path, method: :delete, class: "block w-full text-left px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/30 transition-colors" do %>
config/routes.rb
+17
index 15bb910..c277d9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,6 +71,23 @@ Rails.application.routes.draw do get "/settings", to: "users#settings", as: :settings patch "/settings", to: "users#update_settings" + # Admin console — internal ops/business dashboard. Gated by `require_admin!`. + # Declared before the "/:username" matcher so "/admin" isn't read as a profile. + namespace :admin do + root "dashboard#show" + get "dashboard", to: "dashboard#show" + + resources :users, only: %i[index show] do + member do + patch :grant_admin + patch :revoke_admin + end + end + + resources :repositories, only: %i[index show] + resources :subscriptions, only: %i[index] + end + # Billing — siGit Code plans (web). Checkout/portal happen on the web. get "/billing", to: "billing#show", as: :billing post "/billing/checkout", to: "billing#checkout", as: :billing_checkout
db/migrate/20250101000011_add_admin_to_users.rb
+12
new file mode 100644 index 0000000..d4056da --- /dev/null +++ b/db/migrate/20250101000011_add_admin_to_users.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Grants the admin console. Admins reach /admin — the internal ops/business +# dashboard for siGit (users, repositories, subscriptions, cloud usage). This is +# staff access, not a customer-facing role, so it defaults to false and is +# toggled by hand (rake admin:grant or another admin in the console). +class AddAdminToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :admin, :boolean, null: false, default: false + add_index :users, :admin, where: "admin = true", name: "index_users_on_admin_when_true" + end +end
db/schema.rb
+3 -1
index 182a008..51e8b3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000011) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -141,6 +141,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do create_table "users", force: :cascade do |t| t.string "access_token" + t.boolean "admin", default: false, null: false t.string "avatar_url" t.datetime "created_at", null: false t.string "display_name" @@ -148,6 +149,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do t.integer "smbcloud_id", null: false t.datetime "updated_at", null: false t.string "username", null: false + t.index ["admin"], name: "index_users_on_admin_when_true", where: "(admin = true)" t.index ["email"], name: "index_users_on_email", unique: true t.index ["smbcloud_id"], name: "index_users_on_smbcloud_id", unique: true t.index ["username"], name: "index_users_on_username", unique: true
lib/tasks/admin.rake
+43
new file mode 100644 index 0000000..e3dc81b --- /dev/null +++ b/lib/tasks/admin.rake @@ -0,0 +1,43 @@ +# lib/tasks/admin.rake +# Manage staff (admin console) access. Admin is granted by hand — there is no +# self-service path to it. +# +# Usage: +# bin/rails "admin:grant[alice]" # by username or email +# bin/rails "admin:revoke[alice]" +# bin/rails admin:list + +namespace :admin do + desc "Grant admin (console) access to a user by username or email" + task :grant, [ :who ] => :environment do |_t, args| + user = find_user!(args[:who]) + user.update!(admin: true) + puts " [admin:grant] #{user.username} <#{user.email}> is now an admin." + end + + desc "Revoke admin access from a user by username or email" + task :revoke, [ :who ] => :environment do |_t, args| + user = find_user!(args[:who]) + user.update!(admin: false) + puts " [admin:revoke] #{user.username} <#{user.email}> is no longer an admin." + end + + desc "List all admins" + task list: :environment do + admins = User.admins.order(:username) + if admins.empty? + puts " [admin:list] No admins yet. Grant one: bin/rails \"admin:grant[you]\"" + else + admins.each { |u| puts " - #{u.username} <#{u.email}>" } + end + end +end + +def find_user!(who) + who = who.to_s.strip + abort " Pass a username or email, e.g. bin/rails \"admin:grant[alice]\"" if who.empty? + + user = User.find_by(username: who) || User.find_by("LOWER(email) = ?", who.downcase) + abort " No user found matching '#{who}'." unless user + user +end
spec/queries/admin_queries_spec.rb
+77
new file mode 100644 index 0000000..31a4e29 --- /dev/null +++ b/spec/queries/admin_queries_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Unit coverage for the admin read models — exercised directly, without HTTP, so +# the query/rollup logic is pinned independently of the controllers and views. +RSpec.describe "Admin read models" do + def make_user(n, **attrs) + User.create!(smbcloud_id: 800_000 + n, email: "u#{n}@example.com", username: "user-#{n}", **attrs) + end + + describe Admin::UserSearch do + it "filters to admins only" do + make_user(1) + admin = make_user(2, admin: true) + result = described_class.new(filter: "admins") + expect(result.records).to contain_exactly(admin) + expect(result.total).to eq(1) + end + + it "matches on username, email, or display name (case-insensitive)" do + match = make_user(3, display_name: "Ada Lovelace") + make_user(4) + expect(described_class.new(q: "ADA").records).to include(match) + expect(described_class.new(q: "user-3").records).to include(match) + end + + it "paginates and reports has_next?" do + (1..(Admin::UserSearch::PER_PAGE + 5)).each { |n| make_user(n) } + page1 = described_class.new({}) + expect(page1.records.size).to eq(Admin::UserSearch::PER_PAGE) + expect(page1.has_next?).to be(true) + expect(described_class.new(page: 2).has_next?).to be(false) + end + end + + describe Admin::RepositorySearch do + it "filters by kind and privacy" do + owner = make_user(10) + code = owner.repositories.create!(name: "code-repo", disk_path: "/tmp/a.git", kind: "code") + model = owner.repositories.create!(name: "model-repo", disk_path: "/tmp/b.git", kind: "model") + priv = owner.repositories.create!(name: "secret", disk_path: "/tmp/c.git", kind: "code", is_private: true) + + expect(described_class.new(kind: "model").records).to contain_exactly(model) + expect(described_class.new(filter: "private").records).to contain_exactly(priv) + expect(described_class.new(q: "code-repo").records).to contain_exactly(code) + end + end + + describe Admin::SubscriptionSearch do + it "breaks down by plan and status and filters" do + make_user(20).create_subscription!(plan: :pro, status: :active) + make_user(21).create_subscription!(plan: :team, status: :trialing) + make_user(22).create_subscription!(plan: :pro, status: :canceled) + + search = described_class.new({}) + expect(search.by_plan).to eq("pro" => 2, "team" => 1) + expect(search.by_status["active"]).to eq(1) + expect(described_class.new(status: "active").records.size).to eq(1) + expect(described_class.new(plan: "team").records.size).to eq(1) + end + end + + describe Admin::DashboardMetrics do + it "rolls up growth, catalog, and paying counts" do + make_user(30) + make_user(31, admin: true) + make_user(32).create_subscription!(plan: :pro, status: :active) + + m = described_class.new + expect(m.total_users).to eq(3) + expect(m.admins_count).to eq(1) + expect(m.paying("pro")).to eq(1) + expect(m.estimated_mrr).to eq(20) + end + end +end
spec/requests/admin_spec.rb
+104
new file mode 100644 index 0000000..fe18323 --- /dev/null +++ b/spec/requests/admin_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Covers the admin console: the require_admin! gate, that each page renders, and +# the grant/revoke-admin flows including the self-revoke guard. Auth normally +# runs through smbCloud's native gem, so we stub current_user rather than log in. +RSpec.describe "Admin console", type: :request do + let(:admin) do + User.create!(smbcloud_id: 900_001, email: "admin@example.com", username: "bossadmin", admin: true) + end + let(:member) do + User.create!(smbcloud_id: 900_002, email: "member@example.com", username: "plainuser") + end + + def sign_in_as(user) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + describe "the require_admin! gate" do + it "redirects a signed-out visitor to sign in" do + get "/admin" + expect(response).to redirect_to(signin_path) + end + + it "redirects a signed-in non-admin to root" do + sign_in_as(member) + get "/admin" + expect(response).to redirect_to(root_path) + end + + it "lets an admin in" do + sign_in_as(admin) + get "/admin" + expect(response).to have_http_status(:success) + expect(response.body).to include("Overview") + end + end + + describe "pages render for an admin" do + before { sign_in_as(admin) } + + it "renders the users index and finds a user by search" do + member + get admin_users_path(q: "plainuser") + expect(response).to have_http_status(:success) + expect(response.body).to include("plainuser") + end + + it "renders a user detail page" do + get admin_user_path(member.username) + expect(response).to have_http_status(:success) + expect(response.body).to include(member.email) + end + + it "renders the repositories index" do + get admin_repositories_path + expect(response).to have_http_status(:success) + end + + it "renders the subscriptions index" do + Subscription.create!(user: member, plan: :pro, status: :active) + get admin_subscriptions_path + expect(response).to have_http_status(:success) + expect(response.body).to include("plainuser") + end + end + + describe "granting and revoking admin" do + before { sign_in_as(admin) } + + it "grants admin to a member" do + patch grant_admin_admin_user_path(member.username) + expect(response).to redirect_to(admin_user_path(member.username)) + expect(member.reload.admin?).to be(true) + end + + it "revokes admin from another admin" do + other = User.create!(smbcloud_id: 900_003, email: "o@example.com", username: "otheradmin", admin: true) + patch revoke_admin_admin_user_path(other.username) + expect(other.reload.admin?).to be(false) + end + + it "refuses to let an admin revoke their own access" do + patch revoke_admin_admin_user_path(admin.username) + expect(admin.reload.admin?).to be(true) + expect(flash[:alert]).to match(/your own admin/i) + end + end + + describe BillingMetrics do + it "sums list prices over active paid plans only" do + User.create!(smbcloud_id: 910_001, email: "p1@example.com", username: "payer-one") + .create_subscription!(plan: :pro, status: :active) + User.create!(smbcloud_id: 910_002, email: "p2@example.com", username: "payer-two") + .create_subscription!(plan: :team, status: :active) + # Trials and free plans are excluded from MRR. + User.create!(smbcloud_id: 910_003, email: "p3@example.com", username: "trialer") + .create_subscription!(plan: :pro, status: :trialing) + + expect(BillingMetrics.estimated_mrr).to eq(20 + 40) + end + end +end