security: hash registration tokens at rest, idempotency guard, README trust model
SEC-001: Registration tokens stored as SHA-256 hashes. Migration 046 adds token_hash column, backfills from plaintext, drops token column. All queries use hash. Token plaintext shown once at creation (reveal panel in UI), never retrievable again. Follows the refresh-token pattern. SEC-005: README "no sanitization" claims corrected — code correctly sanitizes against log injection (ANSI stripping, control char replacement, truncation). Wording updated to match reality. SEC-008: Command creation with idempotency_key uses ON CONFLICT DO NOTHING instead of blind insert. Prevents duplicate command execution. Trust model: Ed25519 key rotation documented — signing_keys table supports multiple concurrent active keys with a sliding window for zero-downtime rotation. OSV.dev ecosystem coverage updated (apt, dnf added).
This commit is contained in:
parent
393821ab59
commit
b810b10162
8 changed files with 233 additions and 68 deletions
10
README.md
10
README.md
|
|
@ -84,13 +84,13 @@ Agents run at the OS level and query the Docker socket directly — there's no s
|
|||
|
||||
Agents register with a one-time token plus a hardware fingerprint. The server stores the fingerprint; future check-ins that don't match the registered machine are rejected. This prevents config copying between hosts.
|
||||
|
||||
On first connect, the agent fetches and caches the server's Ed25519 public key (TOFU). Every subsequent command is verified against it. Keys have TTL-based rotation — agents pre-cache new keys before the old ones expire, so rotation is zero-downtime.
|
||||
On first connect, the agent fetches and caches the server's Ed25519 public key (TOFU). Every subsequent command is verified against it. The `signing_keys` table supports multiple concurrent active keys with a sliding window — a new key is promoted to primary while the old key remains active, so agents that cached the previous key continue verifying successfully until the operator deactivates it. Rotation is zero-downtime; no coordinated restart required.
|
||||
|
||||
Every command includes a signed nonce with a 10-minute validity window. The agent tracks executed nonces and rejects replays, including from an attacker who intercepted a valid command.
|
||||
|
||||
Agent-server communication runs over HTTPS. The Ed25519 signing model is a defense-in-depth layer on top of that — commands can't be forged or replayed even if traffic is somehow intercepted or TLS is terminated at a proxy. The signing model doesn't assume the transport is trustworthy. Cert pinning and enforced TLS verification are on the roadmap.
|
||||
|
||||
Before a package is installed: the agent fetches the expected SHA-256 from the server, downloads the artifact, verifies the hash. Mismatch blocks the install. At approval time, OSV.dev is queried for known vulnerabilities in npm and PyPI packages.
|
||||
Before a package is installed: the agent fetches the expected SHA-256 from the server, downloads the artifact, verifies the hash. Mismatch blocks the install. OSV.dev is queried for known vulnerabilities at discovery time (async, deduped) for npm, PyPI, apt, and dnf packages — results are visible in the dashboard before approval.
|
||||
|
||||
**Refresh-token rotation.** Each renewal mints a new refresh token and marks the old one consumed. Replaying a consumed token whose successor was also consumed means theft — the server revokes the entire token family and logs a security event. Agent crash-before-save is covered by accept-previous-once grace: a consumed token whose successor is still unconsumed gets a fresh one, not a revocation.
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
|
|||
- **Idempotent installer** — re-running won't create duplicate agents
|
||||
- **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
|
||||
- **Native services** — systemd on Linux, Windows Services on Windows
|
||||
- **Full audit trail** — all operations logged with context, nothing sanitized
|
||||
- **Full audit trail** — all operations logged with context, sanitized against log injection
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -207,11 +207,11 @@ Then install fresh with the standard one-liner.
|
|||
RedFlag follows ETHOS:
|
||||
|
||||
- **Honest** — what you see is what you get
|
||||
- **Transparent** — errors logged with full context, nothing sanitized
|
||||
- **Transparent** — errors logged with full context, sanitized against injection
|
||||
- **Secure** — hardware binding, cryptographic verification, local-only logging
|
||||
- **Open standards** — no vendor lock-in, no cloud dependency, no telemetry
|
||||
|
||||
The maintainer runs this on their own infrastructure. Releases are versioned, migrations are idempotent. If something breaks, the error shows up in full — not sanitized into a generic failure message.
|
||||
The maintainer runs this on their own infrastructure. Releases are versioned, migrations are idempotent. If something breaks, the error shows up in full — not swallowed into a generic failure message. Log output is sanitized against injection (ANSI stripping, control character replacement, field truncation) but the content is preserved.
|
||||
|
||||
Built for operators who'd rather own the problem than outsource it.
|
||||
|
||||
|
|
|
|||
|
|
@ -397,9 +397,10 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Step 2: Mark registration token as used (via stored procedure)
|
||||
// Step 2: Mark registration token as used (via stored procedure — expects hash)
|
||||
var tokenSuccess bool
|
||||
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", registrationToken, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
|
||||
tokenHash := queries.HashRegistrationToken(registrationToken)
|
||||
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
|
||||
log.Printf("[ERROR] [server] [registration] mark_token_failed error=%v success=%v", err, tokenSuccess)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
-- Reverse SEC-001: restore plaintext token column.
|
||||
-- WARNING: hash is one-way — rolled-back tokens will be empty strings.
|
||||
|
||||
ALTER TABLE registration_tokens ADD COLUMN token VARCHAR(64);
|
||||
UPDATE registration_tokens SET token = '';
|
||||
ALTER TABLE registration_tokens ALTER COLUMN token SET NOT NULL;
|
||||
ALTER TABLE registration_tokens ADD CONSTRAINT registration_tokens_token_key UNIQUE (token);
|
||||
|
||||
ALTER TABLE registration_tokens DROP COLUMN token_hash;
|
||||
|
||||
DROP FUNCTION IF EXISTS mark_registration_token_used(VARCHAR, UUID);
|
||||
CREATE FUNCTION mark_registration_token_used(token_input VARCHAR, agent_id_param UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
rows_updated INTEGER;
|
||||
token_id_val UUID;
|
||||
new_seats_used INT;
|
||||
token_max_seats INT;
|
||||
BEGIN
|
||||
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
|
||||
FROM registration_tokens
|
||||
WHERE token = token_input
|
||||
AND status = 'active'
|
||||
AND expires_at > NOW()
|
||||
AND seats_used < max_seats;
|
||||
|
||||
IF token_id_val IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
UPDATE registration_tokens
|
||||
SET seats_used = new_seats_used,
|
||||
used_at = CASE WHEN used_at IS NULL THEN NOW() ELSE used_at END,
|
||||
status = CASE WHEN new_seats_used >= token_max_seats THEN 'used' ELSE 'active' END
|
||||
WHERE token = token_input AND status = 'active';
|
||||
|
||||
GET DIAGNOSTICS rows_updated = ROW_COUNT;
|
||||
|
||||
IF rows_updated > 0 THEN
|
||||
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
|
||||
VALUES (token_id_val, agent_id_param, NOW())
|
||||
ON CONFLICT (token_id, agent_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN rows_updated > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_input VARCHAR)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
token_valid BOOLEAN;
|
||||
BEGIN
|
||||
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
|
||||
FROM registration_tokens
|
||||
WHERE token = token_input;
|
||||
RETURN COALESCE(token_valid, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
-- SEC-001: Hash registration tokens at rest.
|
||||
-- Registration tokens are now stored as SHA-256 hashes, matching the refresh
|
||||
-- token model. The plaintext column is dropped; all queries use token_hash.
|
||||
|
||||
-- Add the hash column
|
||||
ALTER TABLE registration_tokens ADD COLUMN token_hash VARCHAR(64);
|
||||
|
||||
-- Backfill: SHA-256 hash every existing plaintext token
|
||||
UPDATE registration_tokens
|
||||
SET token_hash = encode(sha256(token::bytea), 'hex');
|
||||
|
||||
-- Now enforce NOT NULL + uniqueness
|
||||
ALTER TABLE registration_tokens ALTER COLUMN token_hash SET NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_registration_tokens_hash ON registration_tokens(token_hash);
|
||||
|
||||
-- Drop the plaintext column and its old unique constraint
|
||||
ALTER TABLE registration_tokens DROP COLUMN token;
|
||||
|
||||
-- Rebuild is_registration_token_valid to use token_hash
|
||||
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_hash_input VARCHAR)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
token_valid BOOLEAN;
|
||||
BEGIN
|
||||
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
|
||||
FROM registration_tokens
|
||||
WHERE token_hash = token_hash_input;
|
||||
|
||||
RETURN COALESCE(token_valid, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Rebuild mark_registration_token_used to use token_hash
|
||||
DROP FUNCTION IF EXISTS mark_registration_token_used(VARCHAR, UUID);
|
||||
CREATE FUNCTION mark_registration_token_used(token_hash_input VARCHAR, agent_id_param UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
rows_updated INTEGER;
|
||||
token_id_val UUID;
|
||||
new_seats_used INT;
|
||||
token_max_seats INT;
|
||||
BEGIN
|
||||
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
|
||||
FROM registration_tokens
|
||||
WHERE token_hash = token_hash_input
|
||||
AND status = 'active'
|
||||
AND expires_at > NOW()
|
||||
AND seats_used < max_seats;
|
||||
|
||||
IF token_id_val IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
UPDATE registration_tokens
|
||||
SET seats_used = new_seats_used,
|
||||
used_at = CASE
|
||||
WHEN used_at IS NULL THEN NOW()
|
||||
ELSE used_at
|
||||
END,
|
||||
status = CASE
|
||||
WHEN new_seats_used >= token_max_seats THEN 'used'
|
||||
ELSE 'active'
|
||||
END
|
||||
WHERE id = token_id_val
|
||||
AND status = 'active';
|
||||
|
||||
GET DIAGNOSTICS rows_updated = ROW_COUNT;
|
||||
|
||||
IF rows_updated > 0 THEN
|
||||
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
|
||||
VALUES (token_id_val, agent_id_param, NOW())
|
||||
ON CONFLICT (token_id, agent_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN rows_updated > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -34,7 +34,6 @@ func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
|
|||
cmd.ExpiresAt = &exp
|
||||
}
|
||||
|
||||
// Handle optional idempotency_key
|
||||
if cmd.IdempotencyKey != nil {
|
||||
query := `
|
||||
INSERT INTO agent_commands (
|
||||
|
|
@ -42,6 +41,7 @@ func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
|
|||
) VALUES (
|
||||
:id, :agent_id, :command_type, :params, :status, :source, :signature, :key_id, :signed_at, :expires_at, :idempotency_key, :retried_from_id
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING
|
||||
`
|
||||
_, err := q.db.NamedExec(query, cmd)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
|
@ -10,13 +12,19 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// HashRegistrationToken creates a SHA-256 hash of a registration token for storage.
|
||||
func HashRegistrationToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
type RegistrationTokenQueries struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
type RegistrationToken struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
TokenHash string `json:"-" db:"token_hash"`
|
||||
Label *string `json:"label" db:"label"`
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
|
@ -50,24 +58,25 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
|
|||
return &RegistrationTokenQueries{db: db}
|
||||
}
|
||||
|
||||
// CreateRegistrationToken creates a new registration token with seat tracking
|
||||
// CreateRegistrationToken creates a new registration token with seat tracking.
|
||||
// The caller passes the plaintext token; only the SHA-256 hash is stored.
|
||||
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Ensure maxSeats is at least 1
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
tokenHash := HashRegistrationToken(token)
|
||||
query := `
|
||||
INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
|
||||
INSERT INTO registration_tokens (token_hash, label, expires_at, max_seats, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
_, err = q.db.Exec(query, token, label, expiresAt, maxSeats, metadataJSON)
|
||||
_, err = q.db.Exec(query, tokenHash, label, expiresAt, maxSeats, metadataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registration token: %w", err)
|
||||
}
|
||||
|
|
@ -75,18 +84,20 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateRegistrationToken checks if a token is valid and has available seats
|
||||
// ValidateRegistrationToken checks if a token is valid and has available seats.
|
||||
// The caller passes the plaintext token; this function hashes it before querying.
|
||||
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
|
||||
var regToken RegistrationToken
|
||||
tokenHash := HashRegistrationToken(token)
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
WHERE token = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
|
||||
WHERE token_hash = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
|
||||
`
|
||||
|
||||
err := q.db.Get(®Token, query, token)
|
||||
err := q.db.Get(®Token, query, tokenHash)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid, expired, or seats full")
|
||||
|
|
@ -97,14 +108,15 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
|
|||
return ®Token, nil
|
||||
}
|
||||
|
||||
// MarkTokenUsed marks a token as used by an agent
|
||||
// With seat tracking, this increments seats_used and only marks status='used' when all seats are taken
|
||||
// MarkTokenUsed marks a token as used by an agent.
|
||||
// The caller passes the plaintext token; this function hashes it before calling
|
||||
// the stored procedure (which now matches on token_hash).
|
||||
func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
|
||||
// Call the PostgreSQL function that handles seat tracking logic
|
||||
tokenHash := HashRegistrationToken(token)
|
||||
query := `SELECT mark_registration_token_used($1, $2)`
|
||||
|
||||
var success bool
|
||||
err := q.db.QueryRow(query, token, agentID).Scan(&success)
|
||||
err := q.db.QueryRow(query, tokenHash, agentID).Scan(&success)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||
}
|
||||
|
|
@ -153,7 +165,7 @@ func (q *RegistrationTokenQueries) GetAgentsBoundToToken(tokenID uuid.UUID) ([]B
|
|||
func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]RegistrationToken, error) {
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
|
|
@ -173,7 +185,7 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
|
|||
func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) ([]RegistrationToken, error) {
|
||||
var tokens []RegistrationToken
|
||||
query := `
|
||||
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used
|
||||
FROM registration_tokens
|
||||
|
|
@ -199,16 +211,17 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
|
|||
// — that path is surfaced as an explicit per-agent operator action, not a side
|
||||
// effect of revoking the issuing token.
|
||||
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
|
||||
tokenHash := HashRegistrationToken(token)
|
||||
query := `
|
||||
UPDATE registration_tokens
|
||||
SET status = 'revoked',
|
||||
revoked = true,
|
||||
revoked_at = NOW(),
|
||||
revoked_reason = $1
|
||||
WHERE token = $2
|
||||
WHERE token_hash = $2
|
||||
`
|
||||
|
||||
result, err := q.db.Exec(query, reason, token)
|
||||
result, err := q.db.Exec(query, reason, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke token: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ import {
|
|||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
|
|
@ -33,7 +30,7 @@ const TokenManagement: React.FC = () => {
|
|||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [showToken, setShowToken] = useState<Record<string, boolean>>({});
|
||||
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -68,9 +65,10 @@ const TokenManagement: React.FC = () => {
|
|||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createToken.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (data: any) => {
|
||||
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
||||
setShowCreateForm(false);
|
||||
setCreatedToken({ token: data.token, install_command: data.install_command });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
|
@ -94,24 +92,11 @@ const TokenManagement: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const getServerUrl = () => {
|
||||
// Use API server port (31337) instead of web UI port (31336)
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const port = hostname === 'localhost' || hostname === '127.0.0.1' ? ':31337' : '';
|
||||
return `${protocol}//${hostname}${port}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Show success feedback
|
||||
};
|
||||
|
||||
const copyInstallCommand = async (token: string) => {
|
||||
const serverUrl = getServerUrl();
|
||||
const command = `curl -sfL "${serverUrl}/api/v1/install/linux?token=${token}" | sudo bash`;
|
||||
await navigator.clipboard.writeText(command);
|
||||
};
|
||||
|
||||
const getStatusColor = (token: RegistrationToken) => {
|
||||
if (token.status === 'revoked') return 'text-gray-500';
|
||||
|
|
@ -299,6 +284,59 @@ const TokenManagement: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Created token reveal — shown once, dismissed by the operator */}
|
||||
{createdToken && (
|
||||
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-8">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<h3 className="text-lg font-semibold text-green-900">Token Created</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreatedToken(null)}
|
||||
className="text-green-600 hover:text-green-800 text-sm"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 mb-3">
|
||||
Copy this token now. It cannot be retrieved again — only a hash is stored.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
|
||||
{createdToken.token}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(createdToken.token)}
|
||||
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
|
||||
title="Copy token"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
|
||||
{createdToken.install_command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(createdToken.install_command)}
|
||||
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
|
||||
title="Copy install command"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
|
|
@ -412,16 +450,8 @@ const TokenManagement: React.FC = () => {
|
|||
{filteredTokens.map((token) => (
|
||||
<tr key={token.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="font-mono text-sm bg-gray-100 px-3 py-2 rounded">
|
||||
{showToken[token.id] ? token.token : '•••••••••••••••••'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowToken({ ...showToken, [token.id]: !showToken[token.id] })}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showToken[token.id] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="font-mono text-sm text-gray-500 bg-gray-100 px-3 py-2 rounded">
|
||||
{token.id.slice(0, 8)}...
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
|
|
@ -454,20 +484,6 @@ const TokenManagement: React.FC = () => {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(token.token)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="Copy token"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyInstallCommand(token.token)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="Copy install command"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{token.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
|
||||
|
|
|
|||
|
|
@ -388,7 +388,6 @@ export interface ApiError {
|
|||
// Registration Token types
|
||||
export interface RegistrationToken {
|
||||
id: string;
|
||||
token: string;
|
||||
label: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue