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