Add service credential visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-19 00:33:22 +03:30
parent 41a276d16d
commit 18c6abd0e8
5 changed files with 169 additions and 5 deletions
+108 -3
View File
@@ -4,13 +4,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, OptionalServiceCredentials, Invoice } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { BuildProgressModal } from '@/components/build-progress-modal';
import { useAuthStore } from '@/lib/store';
/** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
@@ -22,8 +22,8 @@ const statusColors: Record<string, string> = {
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
stopped: 'badge-gray',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
/**
@@ -102,6 +102,7 @@ export default function AppDetailPage() {
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60);
const [showAccessSecret, setShowAccessSecret] = useState(false);
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
const [accessNow, setAccessNow] = useState(() => Date.now());
const { data: app, isLoading } = useQuery<Application>({
@@ -122,6 +123,12 @@ export default function AppDetailPage() {
refetchInterval: 5000, // Poll for status updates
});
const { data: serviceCredentials } = useQuery<OptionalServiceCredentials>({
queryKey: ['service-credentials', appId],
queryFn: () => api.get(`/applications/${appId}/service-credentials`).then((r) => r.data),
enabled: !!app?.latestImageTag && (!!app?.enableRedis || !!app?.enableRabbitmq),
});
const { data: logsData } = useQuery<{ logs: string }>({
queryKey: ['logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
@@ -1821,6 +1828,104 @@ export default function AppDetailPage() {
</div>
)}
{/* Optional Service Credentials */}
{(app.enableRedis || app.enableRabbitmq) && (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<KeyRound className="w-5 h-5" /> Service Credentials
</h2>
<button
type="button"
onClick={() => setShowServiceSecrets(!showServiceSecrets)}
className="btn-secondary text-xs inline-flex items-center gap-1"
>
{showServiceSecrets ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
{showServiceSecrets ? 'Hide secrets' : 'Show secrets'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{app.enableRedis && (
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-700">Redis</h3>
{[
{ label: 'Host', value: serviceCredentials?.redis?.host || `${app.name}-redis`, field: 'redis-host' },
{ label: 'Port', value: String(serviceCredentials?.redis?.port || 6379), field: 'redis-port' },
{
label: 'Password',
value: serviceCredentials?.redis?.password || '',
field: 'redis-password',
secret: true,
},
{ label: 'URL', value: serviceCredentials?.redis?.url || '', field: 'redis-url', secret: true },
].map(({ label, value, field, secret }) => (
<div key={field} className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
</span>
<button
type="button"
onClick={() => copyToClipboard(value || '', field)}
disabled={!value}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
title="Copy"
>
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
)}
{app.enableRabbitmq && (
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-700">RabbitMQ</h3>
{[
{ label: 'Host', value: serviceCredentials?.rabbitmq?.host || `${app.name}-rabbitmq`, field: 'rabbit-host' },
{ label: 'AMQP Port', value: String(serviceCredentials?.rabbitmq?.amqpPort || 5672), field: 'rabbit-amqp-port' },
{ label: 'Management Port', value: String(serviceCredentials?.rabbitmq?.managementPort || 15672), field: 'rabbit-mgmt-port' },
{ label: 'Username', value: serviceCredentials?.rabbitmq?.username || 'appuser', field: 'rabbit-user' },
{
label: 'Password',
value: serviceCredentials?.rabbitmq?.password || '',
field: 'rabbit-password',
secret: true,
},
{ label: 'AMQP URL', value: serviceCredentials?.rabbitmq?.amqpUrl || '', field: 'rabbit-amqp-url', secret: true },
{ label: 'Management URL', value: serviceCredentials?.rabbitmq?.managementUrl || '', field: 'rabbit-mgmt-url' },
].map(({ label, value, field, secret }) => (
<div key={field} className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
</span>
<button
type="button"
onClick={() => copyToClipboard(value || '', field)}
disabled={!value}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
title="Copy"
>
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
)}
</div>
{!app.latestImageTag && (
<p className="text-xs text-gray-400 mt-3">
Service passwords are available after the application is deployed.
</p>
)}
</div>
)}
{/* Temporary External Access */}
{hasAccessTargets && (
<div className="card">