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