Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/controllers/admin/api_keys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
class Admin::ApiKeysController < Admin::ApplicationController
before_action :require_superadmin!

PROVIDERS = {
"claude" => {
label: "Claude",
credential_source: -> { AiRequirementsChecker.credential_source },
model: -> { AiRequirementsChecker.model },
setting_key: AiRequirementsChecker::AUTH_TOKEN_SETTING,
test: ->(token) { AiRequirementsChecker.test_connection!(token) }
},
"macondo" => {
label: "Macondo",
credential_source: -> { MacondoService.enabled? ? "admin_key" : "none" },
model: nil,
setting_key: MacondoService::API_KEY_SETTING,
test: ->(token) { MacondoService.test_connection!(token) }
}
}.freeze

def index
render inertia: "Admin/ApiKeys/Show", props: {
providers: PROVIDERS.map { |id, cfg| provider_payload(id, cfg) }
}
end

def update
provider = PROVIDERS.fetch(params[:id]) { return not_found! }
token = params[:token].to_s.strip
return redirect_to admin_api_keys_path, alert: "Paste a key first." if token.blank?

begin
provider[:test].call(token)
rescue StandardError => e
return redirect_to admin_api_keys_path, alert: e.message
end

AppSetting.set(provider[:setting_key], token)
audit!("#{params[:id]}.reauthed", metadata: { token_digest: Digest::SHA256.hexdigest(token).first(12) })
redirect_to admin_api_keys_path, notice: "#{provider[:label]} key verified and saved."
end

def test
provider = PROVIDERS.fetch(params[:id]) { return not_found! }
provider[:test].call(nil)
redirect_to admin_api_keys_path, notice: "#{provider[:label]} connection is working."
rescue StandardError => e
redirect_to admin_api_keys_path, alert: e.message
end

def destroy
provider = PROVIDERS.fetch(params[:id]) { return not_found! }
AppSetting.clear(provider[:setting_key])
audit!("#{params[:id]}.token_cleared")
redirect_to admin_api_keys_path, notice: "#{provider[:label]} key cleared."
end

private

def not_found!
raise ActionController::RoutingError, "Not Found"
end

def provider_payload(id, cfg)
{
id: id,
label: cfg[:label],
credential_source: cfg[:credential_source].call,
model: cfg[:model]&.call,
token_saved_at: AppSetting.updated_at_for(cfg[:setting_key])&.strftime("%b %d, %Y %H:%M")
}
end

def require_superadmin!
require_permission!("superadmin")
end
end
45 changes: 0 additions & 45 deletions app/controllers/admin/claude_controller.rb

This file was deleted.

47 changes: 45 additions & 2 deletions app/controllers/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def new
title: "New Project",
submit_url: projects_path,
method: "post",
hackatime_enabled: HackatimeService.enabled?
hackatime_enabled: HackatimeService.enabled?,
macondo_enabled: MacondoService.enabled?
}
when Project::BUILD_REVIEW_TIER
render inertia: "Projects/Form", props: {
Expand All @@ -52,7 +53,8 @@ def new
submit_url: projects_path,
method: "post",
linkable_projects: linkable_projects_for(current_user),
hackatime_enabled: HackatimeService.enabled?
hackatime_enabled: HackatimeService.enabled?,
macondo_enabled: MacondoService.enabled?
}
else
render inertia: "Projects/New", props: {
Expand All @@ -71,6 +73,8 @@ def create

if @project.save
audit!("project.created", target: @project, metadata: { tier: @project.tier, build_review: @project.build_review })
macondo_project_id = params.dig(:project, :macondo_project_id).presence
ImportMacondoDataJob.perform_later(@project.id, macondo_project_id) if macondo_project_id
redirect_to @project, notice: @project.build_review? ? "Build review created as draft." : "Project created as draft."
else
fallback_tier = @project.build_review? ? Project::BUILD_REVIEW_TIER : @project.tier
Expand Down Expand Up @@ -211,6 +215,45 @@ def import_from_github
render json: { error: "Could not parse AI response" }, status: :unprocessable_entity
end

def import_from_macondo
authorize Project

unless MacondoService.enabled?
render json: { error: "Macondo import isn't configured." }, status: :service_unavailable
return
end

project_id = MacondoService.parse_project_id(params[:url])
if project_id.blank?
render json: { error: "That doesn't look like a Macondo project link." }, status: :unprocessable_entity
return
end

data = MacondoService.get_project(project_id)
if data.nil?
render json: { error: "Couldn't find that Macondo project." }, status: :not_found
return
end

unless MacondoService.owned_by?(data, current_user)
render json: { error: "This Macondo project isn't owned by your account." }, status: :forbidden
return
end

if MacondoService.shipped?(data)
render json: { error: "This project has already been shipped on Macondo and can't be imported." }, status: :unprocessable_entity
return
end

render json: {
name: data["name"],
description: data["description"],
repo_link: data["repository_url"],
hackatime_projects: Array(data["hackatime_projects"]),
macondo_project_id: project_id
}
end

def add_kudo
authorize @project, :show?

Expand Down
6 changes: 5 additions & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ module ApplicationHelper

ICON_FONT_HREF = "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=block".freeze

# Used to stylize the "Macondo" wordmark on the Import from Macondo button.
DISPLAY_FONTS_HREF = "https://fonts.googleapis.com/css2?family=Are+You+Serious&display=swap".freeze

def google_fonts_tags
safe_join([
tag.link(rel: "preconnect", href: "https://fonts.googleapis.com"),
tag.link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous"),
tag.link(rel: "stylesheet", href: TEXT_FONTS_HREF),
tag.link(rel: "stylesheet", href: ICON_FONT_HREF)
tag.link(rel: "stylesheet", href: ICON_FONT_HREF),
tag.link(rel: "stylesheet", href: DISPLAY_FONTS_HREF)
], "\n")
end

Expand Down
2 changes: 1 addition & 1 deletion app/javascript/components/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function buildSections(): { items: NavItem[] }[] {
{ label: 'Review Audits', href: '/admin/review_audits', icon: Activity, permission: 'is_superadmin' },
{ label: 'Metrics', href: '/admin/metrics', icon: BarChart3, permission: 'audit_log' },
{ label: 'Database', href: '/admin/database', icon: Database, permission: 'is_admin' },
{ label: 'Claude', href: '/admin/claude', icon: Bot, permission: 'is_superadmin' },
{ label: 'API Keys', href: '/admin/api_keys', icon: Bot, permission: 'is_superadmin' },
{ label: 'Airtable Queue', href: '/admin/airtable_queue', icon: TableProperties, permission: 'is_superadmin' },
{ label: 'Jobs', href: '/admin/jobs', icon: Briefcase, external: true, permission: 'jobs' },
{ label: 'Sentry', href: 'https://sentry.io', icon: Activity, external: true, permission: 'third_party' },
Expand Down
127 changes: 127 additions & 0 deletions app/javascript/pages/Admin/ApiKeys/Show.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState } from 'react'
import { router } from '@inertiajs/react'
import { KeyRound, PlugZap, Trash2 } from 'lucide-react'
import { Button } from '@/components/admin/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/admin/ui/card'
import { Input } from '@/components/admin/ui/input'
import { Badge } from '@/components/admin/ui/badge'

interface Provider {
id: string
label: string
credential_source: string
model: string | null
token_saved_at: string | null
}

const SOURCE_LABELS: Record<string, { label: string; variant: 'success' | 'secondary' | 'destructive' }> = {
env_api_key: { label: 'API key (env)', variant: 'success' },
admin_token: { label: 'OAuth token (saved here)', variant: 'success' },
admin_key: { label: 'Key (saved here)', variant: 'success' },
env_auth_token: { label: 'OAuth token (env)', variant: 'secondary' },
none: { label: 'Not configured', variant: 'destructive' },
}

function ProviderCard({ provider }: { provider: Provider }) {
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const source = SOURCE_LABELS[provider.credential_source] ?? SOURCE_LABELS.none

function reauth(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
router.post(
`/admin/api_keys/${provider.id}`,
{ token },
{
onFinish: () => {
setSaving(false)
setToken('')
},
},
)
}

function testConnection() {
router.post(`/admin/api_keys/${provider.id}/test`)
}

function clearToken() {
if (!confirm(`Clear the saved ${provider.label} key?`)) return
router.delete(`/admin/api_keys/${provider.id}`)
}

return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{provider.label}</CardTitle>
<Button variant="outline" size="sm" onClick={testConnection}>
<PlugZap className="size-4" />
Test connection
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Credentials</span>
<Badge variant={source.variant}>{source.label}</Badge>
</div>
{provider.model && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Model</span>
<span className="font-mono">{provider.model}</span>
</div>
)}
{provider.token_saved_at && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Key last saved</span>
<span>{provider.token_saved_at}</span>
</div>
)}
</div>

<form onSubmit={reauth} className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">New key</label>
<Input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste a new key…"
autoComplete="off"
required
/>
<p className="text-xs text-muted-foreground">
Verified before it's saved, then used for all {provider.label} requests. Stored encrypted and never shown
again.
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={saving || !token.trim()}>
<KeyRound className="size-4" />
{saving ? 'Verifying…' : 'Verify & save'}
</Button>
{provider.credential_source !== 'none' && provider.credential_source !== 'env_api_key' && (
<Button type="button" variant="outline" onClick={clearToken}>
<Trash2 className="size-4 text-destructive" />
Clear saved key
</Button>
)}
</div>
</form>
</CardContent>
</Card>
)
}

export default function AdminApiKeysShow({ providers }: { providers: Provider[] }) {
return (
<div className="max-w-3xl mx-auto space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>

{providers.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
</div>
)
}
Loading
Loading