admin-ui: align with API, add auth, fix integrations
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled

This commit is contained in:
DSS
2025-12-12 15:46:08 -03:00
parent ec09a0a662
commit d13e1cd76a
16 changed files with 1565 additions and 671 deletions

View File

@@ -23,7 +23,8 @@ class ApiClient {
data?: unknown,
options?: RequestInit
): Promise<T> {
const url = `${this.baseUrl}${path}`;
// Most endpoints live under `/api/*`, but health is intentionally exposed at `/health`.
const url = path === '/health' ? path : `${this.baseUrl}${path}`;
// Deduplicate GET requests
if (method === 'GET') {
@@ -58,6 +59,14 @@ class ApiClient {
...options?.headers
};
// Attach auth token when present (headless server auth).
if (typeof window !== 'undefined') {
const token = localStorage.getItem('dss-token');
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
}
const config: RequestInit = {
method,
headers,
@@ -124,35 +133,64 @@ export const api = new ApiClient();
// Import types
import type {
Project, ProjectCreateData, ProjectConfig,
FigmaFile, FigmaExtractResult, FigmaHealthStatus,
AuthLoginRequest,
AuthLoginResponse,
UserProfile,
Project,
ProjectCreateData,
ProjectUpdateData,
ProjectConfig,
ProjectDashboardSummary,
FigmaFile,
FigmaFileCreateData,
FigmaHealthStatus,
FigmaExtractTokensResult,
FigmaExtractComponentsResult,
FigmaExtractStylesResult,
FigmaSyncTokensResult,
FigmaValidateResult,
FigmaGenerateCodeResult,
TokenDrift,
TokenDriftListResponse,
Component,
ESREDefinition, ESRECreateData,
AuditEntry, AuditStats,
SystemHealth, RuntimeConfig,
DiscoveryResult, DiscoveryStats,
Team, TeamCreateData,
Integration, IntegrationCreateData,
ChatResponse, MCPTool,
Service
ESREDefinition,
ESRECreateData,
AuditLogResponse,
AuditStats,
RuntimeConfigEnvelope,
ConfigUpdateRequest,
FigmaConfigStatus,
FigmaConnectionTestResult,
SystemHealth,
ServiceDiscoveryEnvelope,
MCPIntegrationsResponse,
ProjectIntegrationsResponse,
ChatResponse,
MCPTool,
MCPStatus
} from './types';
// Type-safe API endpoints - Mapped to actual DSS backend
export const endpoints = {
// Auth
auth: {
login: (data: AuthLoginRequest) => api.post<AuthLoginResponse>('/auth/login', data),
me: () => api.get<UserProfile>('/auth/me')
},
// Projects
projects: {
list: (status?: string) =>
api.get<Project[]>(status ? `/projects?status=${status}` : '/projects'),
get: (id: string) => api.get<Project>(`/projects/${id}`),
create: (data: ProjectCreateData) => api.post<Project>('/projects', data),
update: (id: string, data: Partial<Project>) => api.put<Project>(`/projects/${id}`, data),
update: (id: string, data: ProjectUpdateData) => api.put<Project>(`/projects/${id}`, data),
delete: (id: string) => api.delete<void>(`/projects/${id}`),
config: (id: string) => api.get<ProjectConfig>(`/projects/${id}/config`),
updateConfig: (id: string, data: ProjectConfig) => api.put<ProjectConfig>(`/projects/${id}/config`, data),
context: (id: string) => api.get<unknown>(`/projects/${id}/context`),
components: (id: string) => api.get<Component[]>(`/projects/${id}/components`),
dashboard: (id: string) => api.get<unknown>(`/projects/${id}/dashboard/summary`),
dashboard: (id: string) => api.get<ProjectDashboardSummary>(`/projects/${id}/dashboard/summary`),
// Files (sandboxed)
files: (id: string, path?: string) =>
api.get<unknown>(`/projects/${id}/files${path ? `?path=${encodeURIComponent(path)}` : ''}`),
@@ -163,7 +201,7 @@ export const endpoints = {
api.post<void>(`/projects/${id}/files/write`, { path, content }),
// Figma files per project
figmaFiles: (id: string) => api.get<FigmaFile[]>(`/projects/${id}/figma-files`),
addFigmaFile: (id: string, data: { name: string; fileKey: string }) =>
addFigmaFile: (id: string, data: FigmaFileCreateData) =>
api.post<FigmaFile>(`/projects/${id}/figma-files`, data),
syncFigmaFile: (id: string, fileId: string) =>
api.put<FigmaFile>(`/projects/${id}/figma-files/${fileId}/sync`, {}),
@@ -174,23 +212,26 @@ export const endpoints = {
// Figma Integration
figma: {
health: () => api.get<FigmaHealthStatus>('/figma/health'),
extractVariables: (fileKey: string, nodeId?: string) =>
api.post<FigmaExtractResult>('/figma/extract-variables', { file_key: fileKey, node_id: nodeId }),
extractComponents: (fileKey: string, nodeId?: string) =>
api.post<FigmaExtractResult>('/figma/extract-components', { file_key: fileKey, node_id: nodeId }),
extractStyles: (fileKey: string, nodeId?: string) =>
api.post<FigmaExtractResult>('/figma/extract-styles', { file_key: fileKey, node_id: nodeId }),
extractVariables: (fileKey: string, format: string = 'css') =>
api.post<FigmaExtractTokensResult>('/figma/extract-variables', { file_key: fileKey, format }),
extractComponents: (fileKey: string) =>
api.post<FigmaExtractComponentsResult>('/figma/extract-components', { file_key: fileKey, format: 'json' }),
extractStyles: (fileKey: string) =>
api.post<FigmaExtractStylesResult>('/figma/extract-styles', { file_key: fileKey, format: 'json' }),
syncTokens: (fileKey: string, targetPath: string, format?: string) =>
api.post<{ synced: number }>('/figma/sync-tokens', { file_key: fileKey, target_path: targetPath, format }),
validate: (componentDef: unknown) =>
api.post<{ valid: boolean; errors: string[] }>('/figma/validate', componentDef),
generateCode: (componentDef: unknown, framework?: string) =>
api.post<{ code: string }>('/figma/generate-code', { component: componentDef, framework })
api.post<FigmaSyncTokensResult>('/figma/sync-tokens', { file_key: fileKey, target_path: targetPath, format }),
validateComponents: (fileKey: string) =>
api.post<FigmaValidateResult>('/figma/validate', { file_key: fileKey, format: 'json' }),
generateCode: (fileKey: string, componentName: string, framework: string = 'webcomponent') =>
api.post<FigmaGenerateCodeResult>(
`/figma/generate-code?file_key=${encodeURIComponent(fileKey)}&component_name=${encodeURIComponent(componentName)}&framework=${encodeURIComponent(framework)}`,
{}
)
},
// Token Drift
tokens: {
drift: (projectId: string) => api.get<TokenDrift[]>(`/projects/${projectId}/token-drift`),
drift: (projectId: string) => api.get<TokenDriftListResponse>(`/projects/${projectId}/token-drift`),
recordDrift: (projectId: string, data: Partial<TokenDrift>) =>
api.post<TokenDrift>(`/projects/${projectId}/token-drift`, data),
updateDriftStatus: (projectId: string, driftId: string, status: string) =>
@@ -208,22 +249,11 @@ export const endpoints = {
api.delete<void>(`/projects/${projectId}/esre/${esreId}`)
},
// Integrations per project
integrations: {
list: (projectId: string) => api.get<Integration[]>(`/projects/${projectId}/integrations`),
create: (projectId: string, data: IntegrationCreateData) =>
api.post<Integration>(`/projects/${projectId}/integrations`, data),
update: (projectId: string, type: string, data: Partial<Integration>) =>
api.put<Integration>(`/projects/${projectId}/integrations/${type}`, data),
delete: (projectId: string, type: string) =>
api.delete<void>(`/projects/${projectId}/integrations/${type}`)
},
// Discovery
discovery: {
run: () => api.get<DiscoveryResult>('/discovery'),
scan: () => api.post<DiscoveryResult>('/discovery/scan', {}),
stats: () => api.get<DiscoveryStats>('/discovery/stats'),
run: () => api.get<unknown>('/discovery'),
scan: () => api.post<unknown>('/discovery/scan', {}),
stats: () => api.get<unknown>('/discovery/stats'),
activity: () => api.get<unknown[]>('/discovery/activity'),
ports: () => api.get<unknown[]>('/discovery/ports'),
env: () => api.get<unknown>('/discovery/env')
@@ -231,20 +261,20 @@ export const endpoints = {
// Teams
teams: {
list: () => api.get<Team[]>('/teams'),
get: (id: string) => api.get<Team>(`/teams/${id}`),
create: (data: TeamCreateData) => api.post<Team>('/teams', data)
list: () => api.get<unknown[]>('/teams'),
get: (id: string) => api.get<unknown>(`/teams/${id}`),
create: (data: Record<string, unknown>) => api.post<unknown>('/teams', data)
},
// System & Config
system: {
health: () => api.get<SystemHealth>('/health'),
stats: () => api.get<unknown>('/stats'),
config: () => api.get<RuntimeConfig>('/config'),
updateConfig: (data: Partial<RuntimeConfig>) => api.put<RuntimeConfig>('/config', data),
figmaConfig: () => api.get<{ configured: boolean }>('/config/figma'),
testFigma: () => api.post<{ success: boolean; message: string }>('/config/figma/test', {}),
reset: () => api.post<void>('/system/reset', {}),
config: () => api.get<RuntimeConfigEnvelope>('/config'),
updateConfig: (data: ConfigUpdateRequest) => api.put<Record<string, unknown>>('/config', data),
figmaConfig: () => api.get<FigmaConfigStatus>('/config/figma'),
testFigma: () => api.post<FigmaConnectionTestResult>('/config/figma/test', {}),
reset: (confirm: 'RESET') => api.post<void>('/system/reset', { confirm }),
mode: () => api.get<{ mode: string }>('/mode'),
setMode: (mode: string) => api.put<{ mode: string }>('/mode', { mode })
},
@@ -252,15 +282,15 @@ export const endpoints = {
// Cache Management
cache: {
clear: () => api.post<{ cleared: number }>('/cache/clear', {}),
purge: () => api.delete<{ purged: number }>('/cache')
purge: () => api.delete<{ success: boolean }>('/cache')
},
// Services
services: {
list: () => api.get<Service[]>('/services'),
configure: (name: string, config: unknown) => api.put<Service>(`/services/${name}`, config),
list: () => api.get<ServiceDiscoveryEnvelope>('/services'),
configure: (name: string, config: unknown) => api.put<Record<string, unknown>>(`/services/${name}`, config),
storybook: () => api.get<{ running: boolean; url?: string }>('/services/storybook'),
initStorybook: () => api.post<void>('/storybook/init', {}),
initStorybook: (projectId?: string) => api.post<void>('/storybook/init', projectId ? { project_id: projectId } : {}),
clearStories: () => api.delete<void>('/storybook/stories')
},
@@ -268,9 +298,9 @@ export const endpoints = {
audit: {
list: (params?: Record<string, string>) => {
const query = params ? '?' + new URLSearchParams(params).toString() : '';
return api.get<AuditEntry[]>(`/audit${query}`);
return api.get<AuditLogResponse>(`/audit${query}`);
},
create: (entry: Partial<AuditEntry>) => api.post<AuditEntry>('/audit', entry),
create: (entry: Record<string, unknown>) => api.post<{ success: boolean; message: string }>('/audit', entry),
stats: () => api.get<AuditStats>('/audit/stats'),
categories: () => api.get<string[]>('/audit/categories'),
actions: () => api.get<string[]>('/audit/actions'),
@@ -285,17 +315,59 @@ export const endpoints = {
// Claude AI Chat
claude: {
chat: (message: string, projectId?: string, context?: unknown) =>
api.post<ChatResponse>('/claude/chat', { message, project_id: projectId, context })
chat: (
message: string,
projectId?: string,
context?: unknown,
options?: { user_id?: number; model?: 'claude' | 'gemini'; enable_tools?: boolean; history?: unknown[] }
) =>
api.post<ChatResponse>('/claude/chat', {
message,
project_id: projectId,
user_id: options?.user_id,
model: options?.model,
enable_tools: options?.enable_tools,
history: options?.history,
context
})
},
// MCP Tools
mcp: {
integrations: () => api.get<string[]>('/mcp/integrations'),
integrations: () => api.get<MCPIntegrationsResponse>('/mcp/integrations'),
projectIntegrations: (projectId: string, userId?: number) =>
api.get<ProjectIntegrationsResponse>(
`/projects/${projectId}/integrations${typeof userId === 'number' ? `?user_id=${userId}` : ''}`
),
upsertProjectIntegration: (
projectId: string,
userId: number,
integration_type: string,
config: Record<string, unknown>,
enabled: boolean = true
) =>
api.post<Record<string, unknown>>(
`/projects/${projectId}/integrations?user_id=${userId}`,
{ integration_type, config, enabled }
),
updateProjectIntegration: (
projectId: string,
userId: number,
integration_type: string,
update: { config?: Record<string, unknown>; enabled?: boolean }
) =>
api.put<Record<string, unknown>>(
`/projects/${projectId}/integrations/${integration_type}?user_id=${userId}`,
update
),
deleteProjectIntegration: (projectId: string, userId: number, integration_type: string) =>
api.delete<Record<string, unknown>>(
`/projects/${projectId}/integrations/${integration_type}?user_id=${userId}`
),
tools: () => api.get<MCPTool[]>('/mcp/tools'),
tool: (name: string) => api.get<MCPTool>(`/mcp/tools/${name}`),
execute: (name: string, params: unknown) => api.post<unknown>(`/mcp/tools/${name}/execute`, params),
status: () => api.get<{ connected: boolean; tools: number }>('/mcp/status')
status: () => api.get<MCPStatus>('/mcp/status')
},
// Browser Logs
@@ -307,7 +379,7 @@ export const endpoints = {
// Debug
debug: {
diagnostic: () => api.get<unknown>('/debug/diagnostic'),
workflows: () => api.get<unknown[]>('/debug/workflows')
workflows: () => api.get<Record<string, unknown>>('/debug/workflows')
},
// Design System Ingestion

View File

@@ -1,4 +1,39 @@
// DSS Admin UI - API Types
//
// These types match the headless server in `apps/api/server.py`.
// ============ Auth ============
export type AtlassianService = 'jira' | 'confluence';
export interface AuthLoginRequest {
url: string;
email: string;
api_token: string;
service?: AtlassianService;
}
export interface AuthLoginResponse {
token: string;
user: {
id: number;
email: string;
display_name: string;
atlassian_url?: string;
service?: AtlassianService;
};
expires_at: string;
}
export interface UserProfile {
id: number;
email: string;
display_name: string;
atlassian_url?: string;
atlassian_service?: AtlassianService;
created_at?: string;
last_login?: string;
}
// ============ Projects ============
@@ -6,127 +41,201 @@ export interface Project {
id: string;
name: string;
description?: string;
path: string;
status: 'active' | 'archived' | 'draft';
figma_file_key?: string;
root_path?: string;
status: 'active' | 'archived' | 'draft' | string;
created_at: string;
updated_at: string;
components_count?: number;
tokens_count?: number;
}
export interface ProjectCreateData {
name: string;
description?: string;
path: string;
figma_file_key?: string;
root_path?: string;
}
export interface ProjectUpdateData {
name?: string;
description?: string;
figma_file_key?: string;
root_path?: string;
status?: string;
}
// `.dss/config.json` (managed by `ConfigService`)
export interface ProjectConfig {
schema_version?: string;
figma?: {
file_key?: string;
access_token?: string;
};
storybook?: {
url?: string;
config_path?: string;
file_id?: string | null;
team_id?: string | null;
};
tokens?: {
source?: string;
output?: string;
format?: 'css' | 'scss' | 'json' | 'js';
output_path?: string;
format?: 'css' | 'scss' | 'json' | 'js' | string;
};
components?: {
source_dir?: string;
patterns?: string[];
ai?: {
allowed_operations?: string[];
context_files?: string[];
max_file_size_kb?: number;
};
[key: string]: unknown;
}
export interface ProjectDashboardSummary {
project_id: string;
ux: {
figma_files_count: number;
figma_files: unknown[];
};
ui: {
token_drift: TokenDriftStats;
code_metrics: unknown;
};
qa: {
esre_count: number;
test_summary: unknown;
};
}
// ============ Figma ============
export interface FigmaHealthStatus {
status: 'ok' | 'degraded' | string;
mode: 'live' | 'mock' | string;
message: string;
}
export interface FigmaToken {
name: string;
value: unknown;
type: string;
description?: string;
category?: string;
}
export interface FigmaExtractTokensResult {
success: boolean;
tokens_count: number;
collections: string[];
output_path: string;
tokens: FigmaToken[];
formatted_output: string;
}
export interface FigmaComponentDefinition {
name: string;
key: string;
description: string;
properties: Record<string, unknown>;
variants: unknown[];
}
export interface FigmaExtractComponentsResult {
success: boolean;
components_count: number;
output_path?: string;
components: FigmaComponentDefinition[];
error?: string;
}
export interface FigmaStyleDefinition {
name: string;
key: string;
type: string;
properties: Record<string, unknown>;
}
export interface FigmaExtractStylesResult {
success: boolean;
styles_count: number;
output_path?: string;
styles: FigmaStyleDefinition[];
error?: string;
}
export interface FigmaSyncTokensResult {
success: boolean;
has_changes: boolean;
tokens_synced: number;
target_path: string;
backup_created: boolean;
}
export interface FigmaValidateResult {
success: boolean;
valid: boolean;
components_checked: number;
issues: Array<Record<string, unknown>>;
summary: { errors: number; warnings: number; info: number };
}
export interface FigmaGenerateCodeResult {
success: boolean;
component: string;
framework: string;
output_path?: string;
code: string;
}
// Per-project saved Figma files (UX dashboard)
export interface FigmaFile {
id: string;
project_id: string;
name: string;
figma_url: string;
file_name: string;
file_key: string;
status: 'synced' | 'pending' | 'error';
last_synced?: string;
sync_status: 'pending' | 'synced' | 'error' | string;
last_synced?: string | null;
created_at: string;
error_message?: string;
created_at: string;
}
export interface FigmaHealthStatus {
connected: boolean;
token_configured: boolean;
last_request?: string;
rate_limit?: {
remaining: number;
reset_at: string;
};
export interface FigmaFileCreateData {
figma_url: string;
file_name: string;
file_key: string;
}
export interface FigmaExtractResult {
success: boolean;
count: number;
items: FigmaExtractedItem[];
warnings?: string[];
errors?: string[];
}
export interface FigmaExtractedItem {
name: string;
type: string;
value: unknown;
description?: string;
metadata?: Record<string, unknown>;
}
// ============ Tokens ============
export interface Token {
name: string;
value: string;
type: 'color' | 'spacing' | 'typography' | 'radius' | 'shadow' | 'other';
category?: string;
description?: string;
source?: 'figma' | 'code' | 'manual';
}
export interface TokenDrift {
id: string;
project_id: string;
token_name: string;
expected_value: string;
actual_value: string;
file_path: string;
line_number?: number;
severity: 'low' | 'medium' | 'high' | 'critical';
status: 'open' | 'resolved' | 'ignored';
created_at: string;
resolved_at?: string;
}
// ============ Components ============
// ============ Components (stored) ============
export interface Component {
id: string;
project_id: string;
name: string;
display_name?: string;
figma_key?: string;
description?: string;
file_path: string;
type: 'react' | 'vue' | 'web-component' | 'other';
props?: ComponentProp[];
variants?: string[];
has_story?: boolean;
figma_node_id?: string;
properties?: Record<string, unknown>;
variants?: unknown[];
code_generated?: boolean;
created_at: string;
updated_at: string;
}
export interface ComponentProp {
name: string;
type: string;
required: boolean;
default_value?: unknown;
description?: string;
// ============ Tokens / Drift ============
export interface TokenDrift {
id: string;
component_id: string;
property_name: string;
hardcoded_value: string;
suggested_token?: string | null;
severity: 'info' | 'warning' | 'critical' | string;
file_path: string;
line_number: number;
status: 'pending' | 'fixed' | 'ignored' | string;
detected_at: string;
}
export interface TokenDriftStats {
total: number;
by_status: Record<string, number>;
by_severity: Record<string, number>;
}
export interface TokenDriftListResponse {
drifts: TokenDrift[];
stats: TokenDriftStats;
}
// ============ ESRE (QA) ============
@@ -135,154 +244,137 @@ export interface ESREDefinition {
id: string;
project_id: string;
name: string;
component_name: string;
description?: string;
expected_value: string;
selector?: string;
css_property?: string;
status: 'active' | 'inactive';
last_result?: 'pass' | 'fail' | 'skip';
last_tested?: string;
definition_text: string;
expected_value?: string | null;
component_name?: string | null;
created_at: string;
updated_at: string;
}
export interface ESRECreateData {
name: string;
component_name: string;
description?: string;
expected_value: string;
selector?: string;
css_property?: string;
definition_text: string;
expected_value?: string | null;
component_name?: string | null;
}
// ============ Audit ============
// ============ Activity / Audit ============
export interface AuditEntry {
id: string;
timestamp: string;
category: string;
action: string;
user?: string;
category?: string;
severity?: string;
description?: string;
entity_type?: string;
entity_id?: string;
entity_name?: string;
project_id?: string;
details?: Record<string, unknown>;
metadata?: Record<string, unknown>;
user_id?: string;
user_name?: string;
team_context?: string;
details?: Record<string, unknown> | null;
ip_address?: string;
user_agent?: string;
}
export interface AuditLogResponse {
activities: AuditEntry[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface AuditStats {
total_entries: number;
by_category: Record<string, number>;
by_action: Record<string, number>;
recent_activity: number;
by_user: Record<string, number>;
total_count: number;
}
// ============ System ============
// ============ System / Runtime Config ============
export interface RuntimeConfigEnvelope {
config: Record<string, unknown>;
env: Record<string, unknown>;
mode?: string;
}
export interface ConfigUpdateRequest {
mode?: 'local' | 'server';
figma_token?: string;
services?: Record<string, unknown>;
features?: Record<string, boolean>;
}
export interface FigmaConfigStatus {
configured: boolean;
mode: string;
features: Record<string, boolean>;
}
export interface FigmaConnectionTestResult {
success: boolean;
user?: string;
handle?: string;
error?: string;
}
export interface SystemResetRequest {
confirm: 'RESET';
}
export interface SystemHealth {
status: 'healthy' | 'degraded' | 'unhealthy';
version?: string;
uptime?: number;
status: 'healthy' | 'degraded' | 'unhealthy' | string;
uptime_seconds: number;
version: string;
timestamp: string;
services: {
storage: 'up' | 'down';
mcp: 'up' | 'down';
figma: 'up' | 'down' | 'not_configured';
storage: string;
mcp: string;
figma: string;
};
timestamp: string;
}
export interface RuntimeConfig {
server_host: string;
server_port: number;
figma_token?: string;
storybook_url?: string;
data_dir?: string;
log_level?: 'debug' | 'info' | 'warning' | 'error';
export interface MCPStatus {
connected: boolean;
tools: number;
details?: Record<string, unknown>;
}
export interface CacheStatus {
size: number;
entries: number;
hit_rate: number;
last_cleared?: string;
export interface ServiceDiscoveryEnvelope {
configured: Record<string, unknown>;
discovered: Record<string, unknown>;
storybook: { running: boolean; url?: string };
}
// ============ Discovery ============
// ============ MCP Integrations ============
export interface DiscoveryResult {
projects: DiscoveredProject[];
services: DiscoveredService[];
timestamp: string;
export interface MCPIntegrationHealth {
integration_type: string;
is_healthy: boolean;
failure_count: number;
}
export interface DiscoveredProject {
path: string;
name: string;
type: string;
has_dss_config: boolean;
framework?: string;
export interface MCPIntegrationsResponse {
integrations: MCPIntegrationHealth[];
}
export interface DiscoveredService {
name: string;
port: number;
type: string;
url?: string;
status: 'running' | 'stopped';
}
export interface DiscoveryStats {
projects_discovered: number;
services_running: number;
last_scan: string;
}
// ============ Teams ============
export interface Team {
export interface ProjectIntegrationRecord {
id: string;
name: string;
description?: string;
members?: TeamMember[];
created_at: string;
}
export interface TeamMember {
id: string;
name: string;
email: string;
role: 'admin' | 'member' | 'viewer';
}
export interface TeamCreateData {
name: string;
description?: string;
}
// ============ Integrations ============
export interface Integration {
type: 'figma' | 'jira' | 'confluence' | 'github' | 'gitlab' | 'slack';
project_id: string;
status: 'connected' | 'disconnected' | 'error';
config: Record<string, unknown>;
last_sync?: string;
error_message?: string;
user_id: number;
integration_type: string;
config: string;
enabled: boolean;
created_at: string;
updated_at: string;
last_used_at?: string | null;
}
export interface IntegrationCreateData {
type: Integration['type'];
config: Record<string, unknown>;
}
// ============ Services ============
export interface Service {
name: string;
type: string;
url?: string;
port?: number;
status: 'running' | 'stopped' | 'unknown';
config?: Record<string, unknown>;
export interface ProjectIntegrationsResponse {
integrations: ProjectIntegrationRecord[];
}
// ============ Chat / Claude ============

View File

@@ -33,6 +33,22 @@
gap: var(--spacing-1);
}
.chat-model-select {
appearance: none;
background-color: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-1) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-foreground);
cursor: pointer;
}
.chat-model-select:focus {
outline: none;
border-color: var(--color-ring);
}
/* Quick Actions */
.chat-quick-actions {
display: flex;

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'preact/hooks';
import { chatSidebarOpen } from '../../state/app';
import { currentProject } from '../../state/project';
import { activeTeam } from '../../state/team';
import { userId as authenticatedUserId } from '../../state/user';
import { endpoints } from '../../api/client';
import { Button } from '../base/Button';
import { Badge } from '../base/Badge';
@@ -30,6 +31,7 @@ export function ChatSidebar() {
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [mcpTools, setMcpTools] = useState<string[]>([]);
const [model, setModel] = useState<'claude' | 'gemini'>('claude');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -74,7 +76,9 @@ export function ChatSidebar() {
project: currentProject.value ? {
id: currentProject.value.id,
name: currentProject.value.name,
path: currentProject.value.path
root_path: currentProject.value.root_path,
// Backwards compatible field for older tool prompts.
path: currentProject.value.root_path
} : null,
currentView: window.location.hash
};
@@ -82,7 +86,8 @@ export function ChatSidebar() {
const response = await endpoints.claude.chat(
userMessage.content,
currentProject.value?.id,
context
context,
{ user_id: authenticatedUserId.value, model }
);
const assistantMessage: Message = {
@@ -146,6 +151,15 @@ export function ChatSidebar() {
<Badge variant="success" size="sm">{mcpTools.length} tools</Badge>
)}
</div>
<select
className="chat-model-select"
value={model}
onChange={(e) => setModel((e.target as HTMLSelectElement).value as 'claude' | 'gemini')}
aria-label="AI model"
>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
</select>
<div className="chat-header-actions">
<Button
variant="ghost"

View File

@@ -33,6 +33,16 @@
justify-content: flex-end;
}
/* User */
.header-user {
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Logo */
.header-logo {
display: flex;

View File

@@ -8,7 +8,12 @@ import {
} from '../../state/app';
import { activeTeam, setActiveTeam, TEAM_CONFIGS, TeamId } from '../../state/team';
import { currentProject, projects, setCurrentProject } from '../../state/project';
import { isAuthenticated, loginWithAtlassian, logout, user } from '../../state/user';
import { Button } from '../base/Button';
import { Badge } from '../base/Badge';
import { Input, Select } from '../base/Input';
import { Modal } from '../shared/Modal';
import { useState } from 'preact/hooks';
import './Header.css';
// Icons as inline SVG
@@ -48,6 +53,15 @@ const MoonIcon = () => (
export function Header() {
const teamKeys = Object.keys(TEAM_CONFIGS) as TeamId[];
const [loginOpen, setLoginOpen] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [credentials, setCredentials] = useState({
url: '',
email: '',
api_token: '',
service: 'jira' as 'jira' | 'confluence'
});
const cycleTheme = () => {
const themes = ['light', 'dark', 'auto'] as const;
@@ -56,6 +70,20 @@ export function Header() {
setTheme(themes[nextIndex]);
};
async function handleLogin() {
setLoggingIn(true);
setLoginError(null);
try {
await loginWithAtlassian(credentials);
setLoginOpen(false);
setCredentials({ url: '', email: '', api_token: '', service: 'jira' });
} catch (err) {
setLoginError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoggingIn(false);
}
}
return (
<header className="header">
<div className="header-left">
@@ -105,6 +133,18 @@ export function Header() {
</div>
<div className="header-right">
{/* Auth */}
{isAuthenticated.value ? (
<>
<span className="header-user">
{user.value?.display_name || user.value?.email || 'User'}
</span>
<Button variant="ghost" size="sm" onClick={logout}>Logout</Button>
</>
) : (
<Button variant="ghost" size="sm" onClick={() => setLoginOpen(true)}>Login</Button>
)}
{/* Theme Toggle */}
<Button
variant="ghost"
@@ -125,6 +165,66 @@ export function Header() {
AI
</Button>
</div>
<Modal
isOpen={loginOpen}
onClose={() => { setLoginOpen(false); setLoginError(null); }}
title="Login"
size="md"
footer={
<>
<Button variant="ghost" onClick={() => { setLoginOpen(false); setLoginError(null); }} disabled={loggingIn}>
Cancel
</Button>
<Button variant="primary" onClick={handleLogin} loading={loggingIn}>
Login
</Button>
</>
}
>
<div className="settings-form">
{loginError && (
<div className="form-error">
<Badge variant="error">{loginError}</Badge>
</div>
)}
<Input
label="Atlassian URL"
value={credentials.url}
onChange={(e) => setCredentials(c => ({ ...c, url: (e.target as HTMLInputElement).value }))}
placeholder="https://your-domain.atlassian.net"
fullWidth
/>
<Select
label="Service"
value={credentials.service}
onChange={(e) => setCredentials(c => ({ ...c, service: (e.target as HTMLSelectElement).value as 'jira' | 'confluence' }))}
options={[
{ value: 'jira', label: 'Jira' },
{ value: 'confluence', label: 'Confluence' }
]}
/>
<Input
label="Email"
type="email"
value={credentials.email}
onChange={(e) => setCredentials(c => ({ ...c, email: (e.target as HTMLInputElement).value }))}
placeholder="you@company.com"
fullWidth
/>
<Input
label="API Token"
type="password"
value={credentials.api_token}
onChange={(e) => setCredentials(c => ({ ...c, api_token: (e.target as HTMLInputElement).value }))}
placeholder="••••••••••••"
fullWidth
/>
<p className="text-muted" style={{ margin: 0 }}>
This creates a DSS user and stores a session token in your browser.
</p>
</div>
</Modal>
</header>
);
}

View File

@@ -1,5 +1,6 @@
import { lazy, Suspense } from 'preact/compat';
import { Spinner } from '../base/Spinner';
import { activeTeam, TEAM_CONFIGS, TeamId } from '../../state/team';
import './Stage.css';
// Lazy load workdesks
@@ -12,6 +13,13 @@ interface StageProps {
activeTool: string | null;
}
const WORKDESK_BY_TEAM: Record<TeamId, any> = {
ui: UIWorkdesk,
ux: UXWorkdesk,
qa: QAWorkdesk,
admin: AdminWorkdesk
};
// Loading fallback
function StageLoader() {
return (
@@ -22,55 +30,31 @@ function StageLoader() {
);
}
// Tool component mapping
function getToolComponent(toolId: string | null) {
// For now, return placeholder based on tool category
// Later we'll add specific tool components
function findOwnerTeam(toolId: string): TeamId {
if (toolId === 'dashboard') return activeTeam.value;
switch (toolId) {
case 'dashboard':
case 'figma-extraction':
case 'figma-components':
case 'storybook-figma-compare':
case 'storybook-live-compare':
case 'project-analysis':
case 'quick-wins':
case 'regression-testing':
case 'code-generator':
return <UIWorkdesk activeTool={toolId} />;
const preferred = activeTeam.value;
if (TEAM_CONFIGS[preferred].tools.some(t => t.id === toolId)) return preferred;
case 'figma-plugin':
case 'token-list':
case 'asset-list':
case 'component-list':
case 'navigation-demos':
return <UXWorkdesk activeTool={toolId} />;
case 'figma-live-compare':
case 'esre-editor':
case 'console-viewer':
case 'network-monitor':
case 'error-tracker':
return <QAWorkdesk activeTool={toolId} />;
case 'settings':
case 'projects':
case 'integrations':
case 'audit-log':
case 'cache-management':
case 'health-monitor':
return <AdminWorkdesk activeTool={toolId} />;
default:
return <UIWorkdesk activeTool="dashboard" />;
const teams = Object.keys(TEAM_CONFIGS) as TeamId[];
for (const team of teams) {
if (TEAM_CONFIGS[team].tools.some(t => t.id === toolId)) return team;
}
return preferred;
}
export function Stage({ activeTool }: StageProps) {
const rawToolId = activeTool || 'dashboard';
const parts = rawToolId.split('/').filter(Boolean);
const toolId = (parts.length >= 2 && (parts[0] as TeamId) in TEAM_CONFIGS) ? parts[1] : rawToolId;
const team = findOwnerTeam(toolId);
const Workdesk = WORKDESK_BY_TEAM[team];
return (
<main className="stage" role="main">
<Suspense fallback={<StageLoader />}>
{getToolComponent(activeTool)}
<Workdesk activeTool={toolId} />
</Suspense>
</main>
);

View File

@@ -7,7 +7,7 @@ export * from './team';
export * from './user';
import { loadProjects } from './project';
import { loadUserPreferences } from './user';
import { loadUserPreferences, refreshUser } from './user';
/**
* Initialize the application state
@@ -17,6 +17,9 @@ export async function initializeApp(): Promise<void> {
// Load user preferences from localStorage
loadUserPreferences();
// Restore session if token exists
await refreshUser();
// Load projects from API
await loadProjects();
}

View File

@@ -1,30 +1,11 @@
import { signal, computed } from '@preact/signals';
import { api } from '../api/client';
// Types
export interface Project {
id: string;
name: string;
description?: string;
path?: string;
figmaFileKey?: string;
storybookUrl?: string;
createdAt: string;
updatedAt: string;
status: 'active' | 'archived' | 'draft';
}
export interface ProjectStats {
components: number;
tokens: number;
figmaFiles: number;
healthScore: number;
}
import { endpoints } from '../api/client';
import type { Project, ProjectCreateData, ProjectDashboardSummary, ProjectUpdateData } from '../api/types';
// Project State Signals
export const projects = signal<Project[]>([]);
export const currentProjectId = signal<string | null>(null);
export const projectStats = signal<ProjectStats | null>(null);
export const projectDashboard = signal<ProjectDashboardSummary | null>(null);
export const projectsLoading = signal(false);
export const projectsError = signal<string | null>(null);
@@ -45,7 +26,7 @@ export async function loadProjects(): Promise<void> {
projectsError.value = null;
try {
const data = await api.get<Project[]>('/projects');
const data = await endpoints.projects.list();
projects.value = data;
// Set first project as current if none selected
@@ -64,34 +45,34 @@ export async function setCurrentProject(projectId: string): Promise<void> {
currentProjectId.value = projectId;
localStorage.setItem('dss-current-project', projectId);
// Load project stats
await loadProjectStats(projectId);
// Load dashboard summary (optional)
await loadProjectDashboard(projectId);
}
export async function loadProjectStats(projectId: string): Promise<void> {
export async function loadProjectDashboard(projectId: string): Promise<void> {
try {
const stats = await api.get<ProjectStats>(`/projects/${projectId}/stats`);
projectStats.value = stats;
const summary = await endpoints.projects.dashboard(projectId);
projectDashboard.value = summary;
} catch (error) {
console.error('Failed to load project stats:', error);
projectStats.value = null;
console.error('Failed to load project dashboard:', error);
projectDashboard.value = null;
}
}
export async function createProject(data: Partial<Project>): Promise<Project> {
const project = await api.post<Project>('/projects', data);
export async function createProject(data: ProjectCreateData): Promise<Project> {
const project = await endpoints.projects.create(data);
projects.value = [...projects.value, project];
return project;
}
export async function updateProject(projectId: string, data: Partial<Project>): Promise<Project> {
const updated = await api.put<Project>(`/projects/${projectId}`, data);
export async function updateProject(projectId: string, data: ProjectUpdateData): Promise<Project> {
const updated = await endpoints.projects.update(projectId, data);
projects.value = projects.value.map(p => p.id === projectId ? updated : p);
return updated;
}
export async function deleteProject(projectId: string): Promise<void> {
await api.delete(`/projects/${projectId}`);
await endpoints.projects.delete(projectId);
projects.value = projects.value.filter(p => p.id !== projectId);
// Clear current if deleted

View File

@@ -1,4 +1,6 @@
import { signal, computed } from '@preact/signals';
import { endpoints } from '../api/client';
import type { AuthLoginRequest, AuthLoginResponse, UserProfile } from '../api/types';
// Types
export interface UserPreferences {
@@ -10,14 +12,6 @@ export interface UserPreferences {
notificationsEnabled: boolean;
}
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
role: 'admin' | 'developer' | 'viewer';
}
// Default preferences
const DEFAULT_PREFERENCES: UserPreferences = {
theme: 'auto',
@@ -29,16 +23,16 @@ const DEFAULT_PREFERENCES: UserPreferences = {
};
// User State Signals
export const user = signal<User | null>(null);
export const user = signal<UserProfile | null>(null);
export const preferences = signal<UserPreferences>(DEFAULT_PREFERENCES);
// Computed Values
export const isAuthenticated = computed(() => user.value !== null);
export const isAdmin = computed(() => user.value?.role === 'admin');
export const userName = computed(() => user.value?.name ?? 'Guest');
export const userId = computed(() => user.value?.id ?? 1);
export const userName = computed(() => user.value?.display_name ?? user.value?.email ?? 'Guest');
export const userInitials = computed(() => {
const name = user.value?.name ?? 'Guest';
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const name = user.value?.display_name ?? user.value?.email ?? 'Guest';
return name.split(' ').filter(Boolean).map(n => n[0]).join('').toUpperCase().slice(0, 2);
});
// Actions
@@ -66,7 +60,7 @@ export function updatePreferences(updates: Partial<UserPreferences>): void {
saveUserPreferences();
}
export function setUser(userData: User | null): void {
export function setUser(userData: UserProfile | null): void {
user.value = userData;
}
@@ -75,6 +69,40 @@ export function logout(): void {
localStorage.removeItem('dss-token');
}
export async function refreshUser(): Promise<void> {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('dss-token');
if (!token) {
user.value = null;
return;
}
try {
const profile = await endpoints.auth.me();
user.value = profile;
} catch {
// Token is invalid/expired; clear it.
localStorage.removeItem('dss-token');
user.value = null;
}
}
export async function loginWithAtlassian(credentials: AuthLoginRequest): Promise<AuthLoginResponse> {
const response = await endpoints.auth.login(credentials);
localStorage.setItem('dss-token', response.token);
// Prefer server truth; also supports older login response shapes.
user.value = {
id: response.user.id,
email: response.user.email,
display_name: response.user.display_name,
atlassian_url: response.user.atlassian_url,
atlassian_service: response.user.service,
last_login: new Date().toISOString(),
};
return response;
}
// Keyboard shortcuts state
export const keyboardShortcutsEnabled = computed(() =>
preferences.value.keyboardShortcutsEnabled

File diff suppressed because it is too large Load Diff

View File

@@ -63,24 +63,22 @@ function QADashboard() {
if (esreResult.status === 'fulfilled') {
const esres = esreResult.value;
const passed = esres.filter(e => e.last_result === 'pass').length;
const total = esres.length;
const healthScore = total > 0 ? Math.round((passed / total) * 100) : 100;
setMetrics({
healthScore,
healthScore: 100,
esreDefinitions: total,
testsRun: total,
testsPassed: passed
testsRun: 0,
testsPassed: 0
});
}
if (auditResult.status === 'fulfilled') {
const audits = auditResult.value as AuditEntry[];
setRecentTests(audits.slice(0, 5).map((entry, idx) => ({
const audits = auditResult.value.activities;
setRecentTests(audits.slice(0, 5).map((entry: AuditEntry, idx: number) => ({
id: idx,
name: entry.action || 'Test',
status: entry.details?.status as string || 'info',
status: (entry.details?.status as string) || entry.severity || 'info',
time: formatTimeAgo(entry.timestamp)
})));
}
@@ -240,11 +238,9 @@ function ESREEditorTool() {
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<ESRECreateData>({
name: '',
component_name: '',
description: '',
definition_text: '',
expected_value: '',
selector: '',
css_property: ''
component_name: ''
});
const [error, setError] = useState<string | null>(null);
@@ -276,8 +272,8 @@ function ESREEditorTool() {
return;
}
if (!formData.name || !formData.component_name || !formData.expected_value) {
setError('Name, Component, and Expected Value are required');
if (!formData.name || !formData.definition_text) {
setError('Name and Definition Text are required');
return;
}
@@ -285,14 +281,17 @@ function ESREEditorTool() {
setError(null);
try {
await endpoints.esre.create(projectId, formData);
await endpoints.esre.create(projectId, {
name: formData.name,
definition_text: formData.definition_text,
expected_value: formData.expected_value ? formData.expected_value : undefined,
component_name: formData.component_name ? formData.component_name : undefined
});
setFormData({
name: '',
component_name: '',
description: '',
definition_text: '',
expected_value: '',
selector: '',
css_property: ''
component_name: ''
});
loadESRE();
} catch (err) {
@@ -348,39 +347,25 @@ function ESREEditorTool() {
/>
<Input
label="Component Name"
value={formData.component_name}
value={formData.component_name || ''}
onChange={(e) => setFormData(d => ({ ...d, component_name: (e.target as HTMLInputElement).value }))}
placeholder="e.g., Button"
fullWidth
/>
<Textarea
label="Description"
value={formData.description}
onChange={(e) => setFormData(d => ({ ...d, description: (e.target as HTMLTextAreaElement).value }))}
label="Definition Text"
value={formData.definition_text}
onChange={(e) => setFormData(d => ({ ...d, definition_text: (e.target as HTMLTextAreaElement).value }))}
placeholder="Describe the expected behavior..."
fullWidth
/>
<Input
label="Expected Value"
value={formData.expected_value}
value={formData.expected_value || ''}
onChange={(e) => setFormData(d => ({ ...d, expected_value: (e.target as HTMLInputElement).value }))}
placeholder="e.g., 4px or var(--radius-md)"
fullWidth
/>
<Input
label="CSS Selector (optional)"
value={formData.selector}
onChange={(e) => setFormData(d => ({ ...d, selector: (e.target as HTMLInputElement).value }))}
placeholder="e.g., .btn-primary"
fullWidth
/>
<Input
label="CSS Property (optional)"
value={formData.css_property}
onChange={(e) => setFormData(d => ({ ...d, css_property: (e.target as HTMLInputElement).value }))}
placeholder="e.g., border-radius"
fullWidth
/>
</div>
{error && (
<div className="form-error">
@@ -416,16 +401,6 @@ function ESREEditorTool() {
<div className="esre-expected">
<code>{esre.expected_value}</code>
</div>
<div className="esre-status">
{esre.last_result && (
<Badge
variant={esre.last_result === 'pass' ? 'success' : esre.last_result === 'fail' ? 'error' : 'default'}
size="sm"
>
{esre.last_result}
</Badge>
)}
</div>
<div className="esre-actions">
<Button variant="ghost" size="sm" onClick={() => handleDelete(esre.id)}>
Delete
@@ -758,9 +733,9 @@ function TestResultsTool() {
setResults(esres.map(esre => ({
id: esre.id,
name: esre.name,
status: esre.last_result || 'skip',
status: 'skip',
duration: 0,
error: esre.last_result === 'fail' ? 'Expected value mismatch' : undefined
error: undefined
})));
} catch (err) {
console.error('Failed to load results:', err);
@@ -788,7 +763,7 @@ function TestResultsTool() {
<div className="workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">Test Results</h1>
<p className="workdesk-subtitle">ESRE test execution results</p>
<p className="workdesk-subtitle">ESRE definitions (runner not implemented yet)</p>
</div>
{/* Summary */}

View File

@@ -7,7 +7,14 @@ import { Input, Select } from '../components/base/Input';
import { Spinner } from '../components/base/Spinner';
import { endpoints } from '../api/client';
import { currentProject } from '../state/project';
import type { TokenDrift, Component, FigmaExtractResult } from '../api/types';
import type {
Component,
FigmaExtractComponentsResult,
FigmaExtractTokensResult,
FigmaHealthStatus,
TokenDrift,
TokenDriftStats,
} from '../api/types';
import './Workdesk.css';
interface UIWorkdeskProps {
@@ -45,7 +52,7 @@ function UIDashboard() {
time: string;
status: string;
}>>([]);
const [figmaHealth, setFigmaHealth] = useState<{ connected: boolean } | null>(null);
const [figmaHealth, setFigmaHealth] = useState<FigmaHealthStatus | null>(null);
useEffect(() => {
loadDashboardData();
@@ -92,12 +99,12 @@ function UIDashboard() {
}
if (driftResult.status === 'fulfilled') {
const drifts = driftResult.value;
const { stats } = driftResult.value;
setMetrics(m => ({
...m,
tokenDrift: drifts.length,
criticalIssues: drifts.filter(d => d.severity === 'critical').length,
warnings: drifts.filter(d => d.severity === 'medium' || d.severity === 'low').length
tokenDrift: stats.total,
criticalIssues: stats.by_severity.critical || 0,
warnings: (stats.by_severity.warning || 0) + (stats.by_severity.info || 0)
}));
}
}
@@ -125,22 +132,24 @@ function UIDashboard() {
switch (actionId) {
case 'extractTokens':
window.location.hash = '#ui/figma-extraction';
window.location.hash = '#figma-extraction';
break;
case 'extractComponents':
window.location.hash = '#ui/figma-components';
window.location.hash = '#figma-components';
break;
case 'generateCode':
window.location.hash = '#ui/code-generator';
window.location.hash = '#code-generator';
break;
case 'syncTokens':
// Quick sync using default settings
try {
const fileKey = currentProject.value?.path;
if (fileKey) {
const fileKey = currentProject.value?.figma_file_key;
if (!fileKey) {
alert('No Figma file key configured for this project. Set it in Admin → Projects.');
return;
}
await endpoints.figma.syncTokens(fileKey, './tokens.css', 'css');
loadDashboardData();
}
} catch (err) {
console.error('Sync failed:', err);
}
@@ -176,9 +185,9 @@ function UIDashboard() {
{/* Figma Connection Status */}
{figmaHealth && (
<div className={`connection-status ${figmaHealth.connected ? 'connected' : 'disconnected'}`}>
<Badge variant={figmaHealth.connected ? 'success' : 'error'} size="sm">
Figma: {figmaHealth.connected ? 'Connected' : 'Not Connected'}
<div className={`connection-status ${figmaHealth.status === 'ok' ? 'connected' : 'disconnected'}`}>
<Badge variant={figmaHealth.status === 'ok' ? 'success' : 'warning'} size="sm" title={figmaHealth.message}>
Figma: {figmaHealth.status} ({figmaHealth.mode})
</Badge>
</div>
)}
@@ -263,12 +272,15 @@ function UIDashboard() {
function FigmaExtractionTool() {
const [fileKey, setFileKey] = useState('');
const [nodeId, setNodeId] = useState('');
const [format, setFormat] = useState('css');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<FigmaExtractResult | null>(null);
const [result, setResult] = useState<FigmaExtractTokensResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setFileKey(currentProject.value?.figma_file_key || '');
}, [currentProject.value?.id]);
async function handleExtract() {
if (!fileKey.trim()) {
setError('Please enter a Figma file key or URL');
@@ -282,7 +294,7 @@ function FigmaExtractionTool() {
try {
// Extract file key from URL if needed
const key = extractFigmaFileKey(fileKey);
const extractResult = await endpoints.figma.extractVariables(key, nodeId || undefined);
const extractResult = await endpoints.figma.extractVariables(key, format);
setResult(extractResult);
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed');
@@ -298,8 +310,8 @@ function FigmaExtractionTool() {
try {
const key = extractFigmaFileKey(fileKey);
const targetPath = `./tokens.${format === 'scss' ? 'scss' : format === 'json' ? 'json' : 'css'}`;
await endpoints.figma.syncTokens(key, targetPath, format);
alert(`Tokens synced to ${targetPath}`);
const syncResult = await endpoints.figma.syncTokens(key, targetPath, format);
alert(`${syncResult.tokens_synced} tokens synced to ${targetPath}${syncResult.has_changes ? '' : ' (no changes)'}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync failed');
} finally {
@@ -325,14 +337,6 @@ function FigmaExtractionTool() {
placeholder="Enter Figma file key or paste URL"
fullWidth
/>
<Input
label="Node ID (optional)"
value={nodeId}
onChange={(e) => setNodeId((e.target as HTMLInputElement).value)}
placeholder="Specific node ID to extract from"
hint="Leave empty to extract from entire file"
fullWidth
/>
<Select
label="Output Format"
value={format}
@@ -368,22 +372,13 @@ function FigmaExtractionTool() {
<Card variant="bordered" padding="md">
<CardHeader
title="Extraction Results"
subtitle={`${result.count} tokens extracted`}
subtitle={`${result.tokens_count} tokens extracted`}
/>
<CardContent>
{result.warnings && result.warnings.length > 0 && (
<div className="result-warnings">
{result.warnings.map((warning, idx) => (
<Badge key={idx} variant="warning" size="sm">{warning}</Badge>
))}
</div>
)}
<div className="tokens-preview">
<pre className="code-preview">
{result.items.slice(0, 10).map(item => (
`${item.name}: ${JSON.stringify(item.value)}\n`
)).join('')}
{result.items.length > 10 && `\n... and ${result.items.length - 10} more`}
{result.tokens.slice(0, 10).map(t => `${t.name}: ${JSON.stringify(t.value)}`).join('\n')}
{result.tokens.length > 10 && `\n... and ${result.tokens.length - 10} more`}
</pre>
</div>
</CardContent>
@@ -396,9 +391,13 @@ function FigmaExtractionTool() {
function FigmaComponentsTool() {
const [fileKey, setFileKey] = useState('');
const [loading, setLoading] = useState(false);
const [components, setComponents] = useState<FigmaExtractResult | null>(null);
const [components, setComponents] = useState<FigmaExtractComponentsResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setFileKey(currentProject.value?.figma_file_key || '');
}, [currentProject.value?.id]);
async function handleExtract() {
if (!fileKey.trim()) {
setError('Please enter a Figma file key or URL');
@@ -456,14 +455,14 @@ function FigmaComponentsTool() {
<Card variant="bordered" padding="md">
<CardHeader
title="Components Found"
subtitle={`${components.count} components`}
subtitle={`${components.components_count} components`}
/>
<CardContent>
<div className="components-list">
{components.items.map((comp, idx) => (
<div key={idx} className="component-item">
{components.components.map(comp => (
<div key={comp.key} className="component-item">
<span className="component-name">{comp.name}</span>
<Badge size="sm">{comp.type}</Badge>
<Badge size="sm">{comp.key}</Badge>
</div>
))}
</div>
@@ -478,6 +477,7 @@ function CodeGeneratorTool() {
const [components, setComponents] = useState<Component[]>([]);
const [selectedComponent, setSelectedComponent] = useState('');
const [framework, setFramework] = useState('react');
const [fileKey, setFileKey] = useState('');
const [loading, setLoading] = useState(false);
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -486,6 +486,10 @@ function CodeGeneratorTool() {
loadComponents();
}, []);
useEffect(() => {
setFileKey(currentProject.value?.figma_file_key || '');
}, [currentProject.value?.id]);
async function loadComponents() {
const projectId = currentProject.value?.id;
if (!projectId) return;
@@ -503,6 +507,10 @@ function CodeGeneratorTool() {
setError('Please select a component');
return;
}
if (!fileKey.trim()) {
setError('Set a Figma file key (Admin → Projects) or enter one here.');
return;
}
setLoading(true);
setError(null);
@@ -511,10 +519,7 @@ function CodeGeneratorTool() {
const component = components.find(c => c.id === selectedComponent);
if (!component) throw new Error('Component not found');
const result = await endpoints.figma.generateCode(
{ name: component.name, figma_node_id: component.figma_node_id },
framework
);
const result = await endpoints.figma.generateCode(fileKey, component.name, framework);
setGeneratedCode(result.code);
} catch (err) {
setError(err instanceof Error ? err.message : 'Code generation failed');
@@ -534,13 +539,20 @@ function CodeGeneratorTool() {
<CardHeader title="Generate Component" />
<CardContent>
<div className="settings-form">
<Input
label="Figma File Key"
value={fileKey}
onChange={(e) => setFileKey((e.target as HTMLInputElement).value)}
placeholder="Figma file key"
fullWidth
/>
<Select
label="Component"
value={selectedComponent}
onChange={(e) => setSelectedComponent((e.target as HTMLSelectElement).value)}
options={[
{ value: '', label: 'Select a component...' },
...components.map(c => ({ value: c.id, label: c.display_name || c.name }))
...components.map(c => ({ value: c.id, label: c.name }))
]}
/>
<Select
@@ -590,6 +602,7 @@ function CodeGeneratorTool() {
function TokenDriftTool() {
const [drifts, setDrifts] = useState<TokenDrift[]>([]);
const [stats, setStats] = useState<TokenDriftStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -605,7 +618,8 @@ function TokenDriftTool() {
try {
const result = await endpoints.tokens.drift(projectId);
setDrifts(result);
setDrifts(result.drifts);
setStats(result.stats);
} catch (err) {
console.error('Failed to load drifts:', err);
} finally {
@@ -618,8 +632,8 @@ function TokenDriftTool() {
if (!projectId) return;
try {
await endpoints.tokens.updateDriftStatus(projectId, driftId, 'resolved');
loadDrifts();
await endpoints.tokens.updateDriftStatus(projectId, driftId, 'fixed');
void loadDrifts();
} catch (err) {
console.error('Failed to resolve drift:', err);
}
@@ -646,7 +660,7 @@ function TokenDriftTool() {
<Card variant="bordered" padding="md">
<CardHeader
title="Drift Issues"
subtitle={`${drifts.length} issues found`}
subtitle={`${stats?.total ?? drifts.length} issues found`}
action={<Button variant="outline" size="sm" onClick={loadDrifts}>Refresh</Button>}
/>
<CardContent>
@@ -657,23 +671,22 @@ function TokenDriftTool() {
{drifts.map(drift => (
<div key={drift.id} className="drift-item">
<div className="drift-info">
<span className="drift-token">{drift.token_name}</span>
<span className="drift-file">{drift.file_path}</span>
<span className="drift-token">{drift.component_id} {drift.property_name}</span>
<span className="drift-file">{drift.file_path}:{drift.line_number}</span>
</div>
<div className="drift-values">
<code className="drift-expected">Expected: {drift.expected_value}</code>
<code className="drift-actual">Actual: {drift.actual_value}</code>
<code className="drift-expected">Hardcoded: {drift.hardcoded_value}</code>
<code className="drift-actual">Suggested: {drift.suggested_token || '-'}</code>
</div>
<div className="drift-actions">
<Badge variant={
drift.severity === 'critical' ? 'error' :
drift.severity === 'high' ? 'error' :
drift.severity === 'medium' ? 'warning' : 'default'
drift.severity === 'warning' ? 'warning' : 'default'
} size="sm">
{drift.severity}
</Badge>
<Button variant="ghost" size="sm" onClick={() => handleResolve(drift.id)}>
Resolve
Mark Fixed
</Button>
</div>
</div>
@@ -689,26 +702,69 @@ function TokenDriftTool() {
function QuickWinsTool() {
const [loading, setLoading] = useState(false);
const [quickWins, setQuickWins] = useState<Array<{
id: number;
id: string;
title: string;
priority: string;
effort: string;
priority?: string;
effort?: string;
file?: string;
}>>([]);
const [rawResult, setRawResult] = useState<unknown | null>(null);
const [error, setError] = useState<string | null>(null);
async function scanProject() {
setLoading(true);
try {
// Use discovery scan to find quick wins
setError(null);
setRawResult(null);
setQuickWins([]);
const projectId = currentProject.value?.id;
if (!projectId) {
setError('Select a project first');
return;
}
// Prefer an MCP tool (portable across environments); fall back to discovery scan only if needed.
const tools = await endpoints.mcp.tools();
const candidate = tools.find(t =>
/quick|win/i.test(t.name) || /quick|win/i.test(t.description)
);
if (!candidate) {
await endpoints.discovery.scan();
// Mock data for now - will be replaced with actual API response parsing
setQuickWins([
{ id: 1, title: 'Replace hardcoded color #333', priority: 'high', effort: 'low', file: 'Button.tsx' },
{ id: 2, title: 'Use spacing token instead of 16px', priority: 'medium', effort: 'low', file: 'Card.tsx' },
{ id: 3, title: 'Update font weight to token', priority: 'low', effort: 'low', file: 'Header.tsx' }
]);
setError('No MCP "quick wins" tool found; ran discovery scan instead.');
return;
}
type ExecResponse = { success: boolean; result?: unknown; error?: string };
const exec = await endpoints.mcp.execute(candidate.name, { project_id: projectId, arguments: {} }) as ExecResponse;
if (!exec.success) {
throw new Error(exec.error || 'Quick wins tool failed');
}
const result = exec.result;
setRawResult(result ?? null);
const listCandidate =
Array.isArray(result) ? result :
(result && typeof result === 'object' && Array.isArray((result as Record<string, unknown>).quick_wins)) ? (result as Record<string, unknown>).quick_wins :
(result && typeof result === 'object' && Array.isArray((result as Record<string, unknown>).items)) ? (result as Record<string, unknown>).items :
null;
if (!Array.isArray(listCandidate)) return;
setQuickWins(listCandidate.map((item, idx) => {
const obj = (item && typeof item === 'object') ? (item as Record<string, unknown>) : {};
return {
id: String(obj.id || idx),
title: String(obj.title || obj.message || obj.name || 'Quick win'),
priority: typeof obj.priority === 'string' ? obj.priority : undefined,
effort: typeof obj.effort === 'string' ? obj.effort : undefined,
file: typeof obj.file === 'string' ? obj.file : undefined,
};
}));
} catch (err) {
console.error('Scan failed:', err);
setError(err instanceof Error ? err.message : 'Scan failed');
} finally {
setLoading(false);
}
@@ -731,6 +787,11 @@ function QuickWinsTool() {
}
/>
<CardContent>
{error && (
<div className="form-error">
<Badge variant="error">{error}</Badge>
</div>
)}
{quickWins.length === 0 ? (
<p className="text-muted">Click "Scan Project" to find quick wins</p>
) : (
@@ -742,20 +803,32 @@ function QuickWinsTool() {
{win.file && <span className="quick-win-file">{win.file}</span>}
</div>
<div className="quick-win-badges">
{win.priority && (
<Badge
variant={win.priority === 'high' ? 'error' : win.priority === 'medium' ? 'warning' : 'default'}
size="sm"
>
{win.priority}
</Badge>
)}
{win.effort && (
<Badge variant="success" size="sm">
{win.effort} effort
</Badge>
)}
</div>
</div>
))}
</div>
)}
{rawResult && (
<div style={{ marginTop: 12 }}>
<details>
<summary className="text-muted">Raw tool output</summary>
<pre className="code-preview">{JSON.stringify(rawResult, null, 2)}</pre>
</details>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -7,7 +7,7 @@ import { Input } from '../components/base/Input';
import { Spinner } from '../components/base/Spinner';
import { endpoints } from '../api/client';
import { currentProject } from '../state/project';
import type { FigmaFile } from '../api/types';
import type { Component, FigmaFile, FigmaHealthStatus, FigmaStyleDefinition, FigmaToken } from '../api/types';
import './Workdesk.css';
interface UXWorkdeskProps {
@@ -34,7 +34,7 @@ export default function UXWorkdesk({ activeTool }: UXWorkdeskProps) {
function UXDashboard() {
const [loading, setLoading] = useState(true);
const [figmaFiles, setFigmaFiles] = useState<FigmaFile[]>([]);
const [figmaHealth, setFigmaHealth] = useState<{ connected: boolean } | null>(null);
const [figmaHealth, setFigmaHealth] = useState<FigmaHealthStatus | null>(null);
const [metrics, setMetrics] = useState({
figmaFiles: 0,
synced: 0,
@@ -52,7 +52,7 @@ function UXDashboard() {
const projectId = currentProject.value?.id;
// Load Figma health
const healthResult = await endpoints.figma.health().catch(() => ({ connected: false }));
const healthResult = await endpoints.figma.health().catch(() => null);
setFigmaHealth(healthResult);
// Load project-specific data
@@ -60,8 +60,8 @@ function UXDashboard() {
const filesResult = await endpoints.projects.figmaFiles(projectId).catch(() => []);
setFigmaFiles(filesResult);
const synced = filesResult.filter(f => f.status === 'synced').length;
const pending = filesResult.filter(f => f.status === 'pending').length;
const synced = filesResult.filter(f => f.sync_status === 'synced').length;
const pending = filesResult.filter(f => f.sync_status === 'pending').length;
setMetrics({
figmaFiles: filesResult.length,
@@ -119,9 +119,9 @@ function UXDashboard() {
{/* Connection Status */}
{figmaHealth && (
<div className={`connection-status ${figmaHealth.connected ? 'connected' : 'disconnected'}`}>
<Badge variant={figmaHealth.connected ? 'success' : 'error'} size="sm">
Figma: {figmaHealth.connected ? 'Connected' : 'Not Connected'}
<div className={`connection-status ${figmaHealth.status === 'ok' ? 'connected' : 'disconnected'}`}>
<Badge variant={figmaHealth.status === 'ok' ? 'success' : 'warning'} size="sm" title={figmaHealth.message}>
Figma: {figmaHealth.status} ({figmaHealth.mode})
</Badge>
</div>
)}
@@ -173,15 +173,15 @@ function UXDashboard() {
{figmaFiles.map(file => (
<div key={file.id} className="figma-file-item">
<div className="figma-file-info">
<span className="figma-file-name">{file.name}</span>
<span className="figma-file-name">{file.file_name}</span>
<span className="figma-file-key">{file.file_key}</span>
</div>
<div className="figma-file-status">
<Badge
variant={file.status === 'synced' ? 'success' : file.status === 'error' ? 'error' : 'warning'}
variant={file.sync_status === 'synced' ? 'success' : file.sync_status === 'error' ? 'error' : 'warning'}
size="sm"
>
{file.status === 'synced' ? 'Synced' : file.status === 'error' ? 'Error' : 'Pending'}
{file.sync_status === 'synced' ? 'Synced' : file.sync_status === 'error' ? 'Error' : 'Pending'}
</Badge>
{file.last_synced && (
<span className="figma-file-sync">{formatTimeAgo(file.last_synced)}</span>
@@ -201,7 +201,7 @@ function UXDashboard() {
}
function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
const [formData, setFormData] = useState({ name: '', fileKey: '' });
const [formData, setFormData] = useState({ fileName: '', figmaUrl: '' });
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -212,8 +212,8 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
return;
}
if (!formData.name || !formData.fileKey) {
setError('Name and File Key are required');
if (!formData.fileName || !formData.figmaUrl) {
setError('File Name and Figma URL/File Key are required');
return;
}
@@ -222,9 +222,18 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
try {
// Extract file key from URL if needed
const fileKey = extractFigmaFileKey(formData.fileKey);
await endpoints.projects.addFigmaFile(projectId, { name: formData.name, fileKey });
setFormData({ name: '', fileKey: '' });
const fileKey = extractFigmaFileKey(formData.figmaUrl);
const figmaUrl = formData.figmaUrl.includes('figma.com')
? formData.figmaUrl
: `https://www.figma.com/file/${fileKey}`;
await endpoints.projects.addFigmaFile(projectId, {
figma_url: figmaUrl,
file_name: formData.fileName,
file_key: fileKey
});
setFormData({ fileName: '', figmaUrl: '' });
onAdded();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add Figma file');
@@ -240,15 +249,15 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
<form className="add-figma-form" onSubmit={(e) => { e.preventDefault(); handleAdd(); }}>
<Input
label="File Name"
value={formData.name}
onChange={(e) => setFormData(d => ({ ...d, name: (e.target as HTMLInputElement).value }))}
value={formData.fileName}
onChange={(e) => setFormData(d => ({ ...d, fileName: (e.target as HTMLInputElement).value }))}
placeholder="e.g., Design System"
fullWidth
/>
<Input
label="Figma URL or File Key"
value={formData.fileKey}
onChange={(e) => setFormData(d => ({ ...d, fileKey: (e.target as HTMLInputElement).value }))}
value={formData.figmaUrl}
onChange={(e) => setFormData(d => ({ ...d, figmaUrl: (e.target as HTMLInputElement).value }))}
placeholder="https://figma.com/file/... or file key"
fullWidth
/>
@@ -288,12 +297,12 @@ function TokenListTool() {
if (projectId) {
const files = await endpoints.projects.figmaFiles(projectId);
if (files.length > 0) {
const result = await endpoints.figma.extractVariables(files[0].file_key);
setTokens(result.items.map(item => ({
name: item.name,
value: String(item.value),
type: item.type,
category: item.metadata?.category as string
const result = await endpoints.figma.extractVariables(files[0].file_key, 'json');
setTokens(result.tokens.map((token: FigmaToken) => ({
name: token.name,
value: typeof token.value === 'string' ? token.value : JSON.stringify(token.value),
type: token.type,
category: token.category
})));
}
}
@@ -462,15 +471,15 @@ function FigmaFilesTool() {
{files.map(file => (
<div key={file.id} className="figma-file-item">
<div className="figma-file-info">
<span className="figma-file-name">{file.name}</span>
<span className="figma-file-name">{file.file_name}</span>
<span className="figma-file-key">{file.file_key}</span>
</div>
<div className="figma-file-status">
<Badge
variant={file.status === 'synced' ? 'success' : file.status === 'error' ? 'error' : 'warning'}
variant={file.sync_status === 'synced' ? 'success' : file.sync_status === 'error' ? 'error' : 'warning'}
size="sm"
>
{file.status}
{file.sync_status}
</Badge>
</div>
<div className="figma-file-actions">
@@ -522,13 +531,13 @@ function AssetListTool() {
if (files.length > 0) {
const result = await endpoints.figma.extractStyles(files[0].file_key);
// Transform styles into assets
const extractedAssets = result.items
.filter(item => item.type === 'effect' || item.type === 'paint')
.map((item, idx) => ({
id: String(idx),
name: item.name,
const extractedAssets = result.styles
.filter((style: FigmaStyleDefinition) => style.type === 'effect' || style.type === 'paint')
.map((style: FigmaStyleDefinition, idx: number) => ({
id: style.key || String(idx),
name: style.name,
type: 'icon' as const,
format: 'svg',
format: 'figma-style',
size: '-'
}));
setAssets(extractedAssets);
@@ -643,12 +652,12 @@ function ComponentListTool() {
const projectId = currentProject.value?.id;
if (projectId) {
const result = await endpoints.projects.components(projectId);
setComponents(result.map(c => ({
setComponents(result.map((c: Component) => ({
id: c.id,
name: c.display_name || c.name,
name: c.name,
description: c.description,
variants: c.variants?.length || 0,
status: (c as unknown as { status?: string }).status === 'active' ? 'ready' : 'in-progress'
status: c.code_generated ? 'ready' : 'planned'
})));
}
} catch (err) {

View File

@@ -783,22 +783,22 @@ async def list_workflows():
return {"workflows": workflows, "count": len(workflows), "directory": str(workflows_dir)}
@app.get("/api/config")
async def get_config():
@app.get("/api/public-config")
async def get_public_config():
"""
Public configuration endpoint.
Returns ONLY safe, non-sensitive configuration values that are safe
to expose to the client browser.
SECURITY: This endpoint is the ONLY place where configuration is exposed.
All other config values (secrets, API keys, etc.) must be server-only.
NOTE: The Admin UI uses `/api/config` for runtime settings (with secrets masked).
This endpoint is kept for backwards compatibility with older clients.
"""
# Import here to avoid circular imports
try:
from config import get_public_config
from config import get_public_config as _get_public_config
return get_public_config()
return _get_public_config()
except ImportError:
# Fallback for legacy deployments
return {
@@ -848,6 +848,7 @@ async def create_project(project: ProjectCreate):
name=project.name,
description=project.description,
figma_file_key=project.figma_file_key,
root_path=project.root_path,
)
ActivityLog.log(
action="project_created",

View File

@@ -218,7 +218,13 @@ class Projects:
(base / subdir).mkdir(parents=True, exist_ok=True)
@staticmethod
def create(id: str, name: str, description: str = "", figma_file_key: str = "") -> Dict:
def create(
id: str,
name: str,
description: str = "",
figma_file_key: str = "",
root_path: str = "",
) -> Dict:
"""Create a new project."""
Projects._init_project_structure(id)
@@ -228,6 +234,7 @@ class Projects:
"name": name,
"description": description,
"figma_file_key": figma_file_key,
"root_path": root_path,
"status": "active",
"created_at": now,
"updated_at": now,