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 <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