cleanup more auth

This commit is contained in:
Evan Jarrett
2025-10-07 10:58:11 -05:00
parent 5b18538a8b
commit 2d16bbfee3
31 changed files with 2524 additions and 918 deletions
+249
View File
@@ -0,0 +1,249 @@
package apikey
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// APIKey represents a user's API key
type APIKey struct {
ID string `json:"id"` // UUID
KeyHash string `json:"key_hash"` // bcrypt hash
DID string `json:"did"` // Owner's DID
Handle string `json:"handle"` // Owner's handle
Name string `json:"name"` // User-provided name
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
// Store manages API keys
type Store struct {
mu sync.RWMutex
keys map[string]*APIKey // keyHash -> APIKey
byDID map[string][]string // DID -> []keyHash
filePath string // /var/lib/atcr/api-keys.json
}
// persistentData is the structure saved to disk
type persistentData struct {
Keys []*APIKey `json:"keys"`
}
// NewStore creates a new API key store
func NewStore(filePath string) (*Store, error) {
s := &Store{
keys: make(map[string]*APIKey),
byDID: make(map[string][]string),
filePath: filePath,
}
// Load existing keys from file
if err := s.load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load API keys: %w", err)
}
return s, nil
}
// Generate creates a new API key and returns the plaintext key (shown once)
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) {
// Generate 32 random bytes
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Format: atcr_<base64>
key = "atcr_" + base64.RawURLEncoding.EncodeToString(b)
// Hash for storage
keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
if err != nil {
return "", "", fmt.Errorf("failed to hash key: %w", err)
}
keyHash := string(keyHashBytes)
// Generate ID
keyID = uuid.New().String()
apiKey := &APIKey{
ID: keyID,
KeyHash: keyHash,
DID: did,
Handle: handle,
Name: name,
CreatedAt: time.Now(),
LastUsed: time.Time{}, // Never used yet
}
s.mu.Lock()
s.keys[keyHash] = apiKey
s.byDID[did] = append(s.byDID[did], keyHash)
s.mu.Unlock()
if err := s.save(); err != nil {
return "", "", fmt.Errorf("failed to save keys: %w", err)
}
// Return plaintext key (only time it's available)
return key, keyID, nil
}
// Validate checks if an API key is valid and returns the associated data
func (s *Store) Validate(key string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Try to match against all stored hashes
for hash, apiKey := range s.keys {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
// Update last used asynchronously
go s.UpdateLastUsed(hash)
// Return a copy to prevent external modifications
keyCopy := *apiKey
return &keyCopy, nil
}
}
return nil, fmt.Errorf("invalid API key")
}
// List returns all API keys for a DID (without plaintext keys)
func (s *Store) List(did string) []*APIKey {
s.mu.RLock()
defer s.mu.RUnlock()
keyHashes, ok := s.byDID[did]
if !ok {
return []*APIKey{}
}
result := make([]*APIKey, 0, len(keyHashes))
for _, hash := range keyHashes {
if apiKey, ok := s.keys[hash]; ok {
// Return copy without hash
keyCopy := *apiKey
keyCopy.KeyHash = "" // Don't expose hash
result = append(result, &keyCopy)
}
}
return result
}
// Delete removes an API key
func (s *Store) Delete(did, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Find the key by DID and ID
keyHashes, ok := s.byDID[did]
if !ok {
return fmt.Errorf("no keys found for DID: %s", did)
}
var foundHash string
for _, hash := range keyHashes {
if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID {
foundHash = hash
break
}
}
if foundHash == "" {
return fmt.Errorf("key not found: %s", keyID)
}
// Remove from keys map
delete(s.keys, foundHash)
// Remove from byDID index
newHashes := make([]string, 0, len(keyHashes)-1)
for _, hash := range keyHashes {
if hash != foundHash {
newHashes = append(newHashes, hash)
}
}
if len(newHashes) == 0 {
delete(s.byDID, did)
} else {
s.byDID[did] = newHashes
}
return s.save()
}
// UpdateLastUsed updates the last used timestamp
func (s *Store) UpdateLastUsed(keyHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
apiKey, ok := s.keys[keyHash]
if !ok {
return fmt.Errorf("key not found")
}
apiKey.LastUsed = time.Now()
return s.save()
}
// load reads keys from disk
func (s *Store) load() error {
data, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
var pd persistentData
if err := json.Unmarshal(data, &pd); err != nil {
return fmt.Errorf("failed to unmarshal keys: %w", err)
}
// Rebuild in-memory structures
for _, apiKey := range pd.Keys {
s.keys[apiKey.KeyHash] = apiKey
s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash)
}
return nil
}
// save writes keys to disk
func (s *Store) save() error {
// Collect all keys
allKeys := make([]*APIKey, 0, len(s.keys))
for _, apiKey := range s.keys {
allKeys = append(allKeys, apiKey)
}
pd := persistentData{
Keys: allKeys,
}
data, err := json.MarshalIndent(pd, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal keys: %w", err)
}
// Write atomically with temp file + rename
tmpPath := s.filePath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, s.filePath); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
+3 -3
View File
@@ -16,7 +16,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
`
args := []interface{}{}
args := []any{}
if userFilter != "" {
query += " WHERE u.handle = ? OR u.did = ?"
@@ -43,7 +43,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
// Get total count
countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did"
countArgs := []interface{}{}
countArgs := []any{}
if userFilter != "" {
countQuery += " WHERE u.handle = ? OR u.did = ?"
@@ -228,7 +228,7 @@ func DeleteManifestsNotInList(db *sql.DB, did string, keepDigests []string) erro
// Build placeholders for IN clause
placeholders := make([]string, len(keepDigests))
args := []interface{}{did}
args := []any{did}
for i, digest := range keepDigests {
placeholders[i] = "?"
args = append(args, digest)
+91
View File
@@ -0,0 +1,91 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"atcr.io/pkg/appview/apikey"
"atcr.io/pkg/appview/middleware"
"github.com/gorilla/mux"
)
// GenerateAPIKeyHandler handles POST /api/keys
type GenerateAPIKeyHandler struct {
Store *apikey.Store
}
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
name := r.FormValue("name")
if name == "" {
name = "Unnamed Key"
}
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
if err != nil {
fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err)
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
return
}
fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n",
user.DID, user.Handle, name, keyID)
// Return key (shown once!)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"id": keyID,
"key": key,
})
}
// ListAPIKeysHandler handles GET /api/keys
type ListAPIKeysHandler struct {
Store *apikey.Store
}
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
keys := h.Store.List(user.DID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(keys)
}
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
type DeleteAPIKeyHandler struct {
Store *apikey.Store
}
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
keyID := vars["id"]
if err := h.Store.Delete(user.DID, keyID); err != nil {
fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n",
user.DID, keyID, err)
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
return
}
fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID)
w.WriteHeader(http.StatusNoContent)
}
+14 -12
View File
@@ -28,16 +28,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
// OAuth session not found or expired - redirect to re-authenticate
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Extract access token and HTTP client from session
accessToken, _ := session.GetHostAccessData()
httpClient := session.APIClient().Client
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's DPoP-configured HTTP client
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Fetch sailor profile
profile, err := atproto.GetProfile(r.Context(), client)
@@ -93,16 +94,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
// OAuth session not found or expired - redirect to re-authenticate
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Extract access token and HTTP client from session
accessToken, _ := session.GetHostAccessData()
httpClient := session.APIClient().Client
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's DPoP-configured HTTP client
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Fetch existing profile or create new one
profile, err := atproto.GetProfile(r.Context(), client)
+49 -13
View File
@@ -8,15 +8,18 @@ import (
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
type BackfillWorker struct {
db *sql.DB
client *atproto.Client
resolver *atproto.Resolver
db *sql.DB
client *atproto.Client
directory identity.Directory
}
// BackfillState tracks backfill progress
@@ -36,9 +39,9 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker,
client := atproto.NewClient(relayEndpoint, "", "")
return &BackfillWorker{
db: database,
client: client, // This points to the relay
resolver: atproto.NewResolver(),
db: database,
client: client, // This points to the relay
directory: identity.DefaultDirectory(),
}, nil
}
@@ -117,11 +120,21 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
}
// Resolve DID to get user's PDS endpoint
_, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
return 0, fmt.Errorf("invalid DID %s: %w", did, err)
}
ident, err := b.directory.LookupDID(ctx, didParsed)
if err != nil {
return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return 0, fmt.Errorf("no PDS endpoint found for DID %s", did)
}
// Create a client for this user's PDS
pdsClient := atproto.NewClient(pdsEndpoint, "", "")
@@ -314,17 +327,40 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
}
// Resolve DID to get handle and PDS endpoint
resolvedDID, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
// Fallback: use DID as handle
resolvedDID = did
pdsEndpoint = "https://bsky.social"
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social",
LastSeen: time.Now(),
}
return db.UpsertUser(b.db, user)
}
// Get handle from DID document
handle, err := b.resolver.ResolveHandleFromDID(ctx, resolvedDID)
ident, err := b.directory.LookupDID(ctx, didParsed)
if err != nil {
handle = resolvedDID // Fallback to DID
// Fallback: use DID as handle
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social",
LastSeen: time.Now(),
}
return db.UpsertUser(b.db, user)
}
resolvedDID := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid or PDS is missing, use defaults
if handle == "handle.invalid" || handle == "" {
handle = resolvedDID
}
if pdsEndpoint == "" {
pdsEndpoint = "https://bsky.social"
}
// Upsert to database
+45 -17
View File
@@ -9,6 +9,9 @@ import (
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"github.com/gorilla/websocket"
@@ -31,7 +34,7 @@ type Worker struct {
wantedCollections []string
debugCollectionCount int
userCache *UserCache
resolver *atproto.Resolver
directory identity.Directory
eventCallback EventCallback
}
@@ -53,7 +56,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker
userCache: &UserCache{
cache: make(map[string]*db.User),
},
resolver: atproto.NewResolver(),
directory: identity.DefaultDirectory(),
}
}
@@ -198,19 +201,44 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error {
}
// Resolve DID to get handle and PDS endpoint
resolvedDID, pdsEndpoint, err := w.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err)
// Fallback: use DID as handle
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
LastSeen: time.Now(),
}
w.userCache.cache[did] = user
return db.UpsertUser(w.db, user)
}
ident, err := w.directory.LookupDID(ctx, didParsed)
if err != nil {
fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err)
// Fallback: use DID as handle
resolvedDID = did
pdsEndpoint = "https://bsky.social" // Default PDS endpoint as fallback
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
LastSeen: time.Now(),
}
w.userCache.cache[did] = user
return db.UpsertUser(w.db, user)
}
// Get handle from DID document
handle, err := w.resolver.ResolveHandleFromDID(ctx, resolvedDID)
if err != nil {
fmt.Printf("WARNING: Failed to get handle for DID %s: %v (using DID as handle)\n", resolvedDID, err)
handle = resolvedDID // Fallback to DID
resolvedDID := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid or PDS is missing, use defaults
if handle == "handle.invalid" || handle == "" {
handle = resolvedDID
}
if pdsEndpoint == "" {
pdsEndpoint = "https://bsky.social"
}
// Cache the user
@@ -349,13 +377,13 @@ type JetstreamEvent struct {
// CommitEvent represents a commit event (create/update/delete)
type CommitEvent struct {
Rev string `json:"rev"`
Operation string `json:"operation"` // "create", "update", "delete"
Collection string `json:"collection"`
RKey string `json:"rkey"`
Record map[string]interface{} `json:"record,omitempty"`
CID string `json:"cid,omitempty"`
DID string `json:"-"` // Set from parent event
Rev string `json:"rev"`
Operation string `json:"operation"` // "create", "update", "delete"
Collection string `json:"collection"`
RKey string `json:"rkey"`
Record map[string]any `json:"record,omitempty"`
CID string `json:"cid,omitempty"`
DID string `json:"-"` // Set from parent event
}
// IdentityInfo represents an identity event
+21
View File
@@ -129,6 +129,27 @@ func (s *Store) Get(id string) (*Session, bool) {
return sess, true
}
// Extend extends a session's expiration time
func (s *Store) Extend(id string, duration time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return fmt.Errorf("session not found: %s", id)
}
// Extend the expiration
sess.ExpiresAt = time.Now().Add(duration)
// Save to disk
if err := s.save(); err != nil {
fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
}
return nil
}
// Delete removes a session
func (s *Store) Delete(id string) {
s.mu.Lock()
+275
View File
@@ -57,6 +57,42 @@
<div id="hold-status"></div>
</section>
<!-- API Keys Section -->
<section class="settings-section api-keys-section">
<h2>API Keys</h2>
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
<!-- Generate New Key -->
<div class="generate-key">
<h3>Generate New API Key</h3>
<form id="generate-key-form">
<div class="form-group">
<label for="key-name">Key Name:</label>
<input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required>
</div>
<button type="submit" class="btn-primary">Generate Key</button>
</form>
</div>
<!-- Existing Keys List -->
<div class="keys-list">
<h3>Your API Keys</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="keys-table">
<tr><td colspan="4">Loading...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- OAuth Session Section -->
<section class="settings-section">
<h2>OAuth Session</h2>
@@ -78,7 +114,246 @@
<!-- Modal container for HTMX -->
<div id="modal"></div>
<!-- API Key Modal (shown once after generation) -->
<div id="key-modal" class="modal hidden">
<div class="modal-backdrop" onclick="closeKeyModal()"></div>
<div class="modal-content">
<h3>✓ API Key Generated!</h3>
<p><strong>Copy this key now - it won't be shown again:</strong></p>
<div class="key-display">
<code id="generated-key"></code>
<button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button>
</div>
<div class="usage-instructions">
<h4>Using with Docker:</h4>
<p><strong>Direct login (quick start)</strong></p>
<pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre>
<p><strong>Credential helper (if you opened this from configure)</strong></p>
<p>Just paste your handle and this key when prompted in the terminal.</p>
</div>
<button class="btn-primary" onclick="closeKeyModal()">Done</button>
</div>
</div>
<script src="/static/js/app.js"></script>
<script>
// API Key Management JavaScript
(function() {
// Generate key
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('key-name').value;
try {
const resp = await fetch('/api/keys', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `name=${encodeURIComponent(name)}`
});
if (!resp.ok) {
throw new Error('Failed to generate key');
}
const data = await resp.json();
// Show key in modal (only time it's available)
document.getElementById('generated-key').textContent = data.key;
document.getElementById('key-modal').classList.remove('hidden');
// Clear form
document.getElementById('key-name').value = '';
// Refresh keys list
loadKeys();
} catch (err) {
alert('Error generating key: ' + err.message);
}
});
// Copy key to clipboard
window.copyKey = function() {
const key = document.getElementById('generated-key').textContent;
navigator.clipboard.writeText(key).then(() => {
alert('Copied to clipboard!');
}).catch(err => {
alert('Failed to copy: ' + err.message);
});
};
// Close modal
window.closeKeyModal = function() {
document.getElementById('key-modal').classList.add('hidden');
};
// Load existing keys
async function loadKeys() {
try {
const resp = await fetch('/api/keys');
if (!resp.ok) {
throw new Error('Failed to load keys');
}
const keys = await resp.json();
const tbody = document.getElementById('keys-table');
if (keys.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>';
return;
}
tbody.innerHTML = keys.map(key => {
const createdDate = new Date(key.created_at).toLocaleDateString();
const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z'
? new Date(key.last_used).toLocaleDateString()
: 'Never';
return `
<tr>
<td>${escapeHtml(key.name)}</td>
<td>${createdDate}</td>
<td>${lastUsed}</td>
<td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td>
</tr>
`;
}).join('');
} catch (err) {
console.error('Error loading keys:', err);
document.getElementById('keys-table').innerHTML =
'<tr><td colspan="4">Error loading keys</td></tr>';
}
}
// Delete key
window.deleteKey = async function(id) {
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
return;
}
try {
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
if (!resp.ok) {
throw new Error('Failed to delete key');
}
loadKeys();
} catch (err) {
alert('Error revoking key: ' + err.message);
}
};
// Escape HTML helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load keys on page load
loadKeys();
})();
</script>
<style>
/* API Key Modal Styles */
.modal.hidden { display: none; }
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
}
.modal-content {
position: relative;
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
width: 90%;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1001;
}
.key-display {
background: #f5f5f5;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
border: 1px solid #ddd;
}
.key-display code {
word-break: break-all;
font-size: 14px;
display: block;
margin-bottom: 1rem;
}
.usage-instructions {
margin-top: 1rem;
padding: 1rem;
background: #e3f2fd;
border-radius: 4px;
}
.usage-instructions h4 {
margin-top: 0;
}
.usage-instructions pre {
background: #263238;
color: #aed581;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0 0 0;
}
.usage-instructions code {
font-family: monospace;
}
/* API Keys Section Styles */
.api-keys-section table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-section th,
.api-keys-section td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
.api-keys-section th {
background: #f5f5f5;
font-weight: bold;
}
.api-keys-section .btn-danger {
background: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.api-keys-section .btn-danger:hover {
background: #c82333;
}
.generate-key {
margin: 1rem 0;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
</style>
</body>
</html>
{{ end }}