diff --git a/internal/adapter/subprocess_test.go b/internal/adapter/subprocess_test.go index 918b875..ea3814c 100644 --- a/internal/adapter/subprocess_test.go +++ b/internal/adapter/subprocess_test.go @@ -2,6 +2,8 @@ package adapter import ( "context" + "os" + "runtime" "testing" "time" ) @@ -41,10 +43,18 @@ func TestExecute_Success(t *testing.T) { func TestExecute_WithWorkDir(t *testing.T) { executor := NewSubprocessExecutor() - executor.WorkDir = "/tmp" + executor.WorkDir = os.TempDir() ctx := context.Background() - output, err := executor.Execute(ctx, "pwd") + var output *ToolOutput + var err error + + if runtime.GOOS == "windows" { + output, err = executor.Execute(ctx, "cmd", "/c", "cd") + } else { + output, err = executor.Execute(ctx, "pwd") + } + if err != nil { t.Fatalf("Execute failed: %v", err) } @@ -61,7 +71,15 @@ func TestExecute_WithEnv(t *testing.T) { } ctx := context.Background() - output, err := executor.Execute(ctx, "sh", "-c", "echo $TEST_VAR") + var output *ToolOutput + var err error + + if runtime.GOOS == "windows" { + output, err = executor.Execute(ctx, "cmd", "/c", "echo %TEST_VAR%") + } else { + output, err = executor.Execute(ctx, "sh", "-c", "echo $TEST_VAR") + } + if err != nil { t.Fatalf("Execute failed: %v", err) } @@ -75,7 +93,15 @@ func TestExecute_NonZeroExit(t *testing.T) { executor := NewSubprocessExecutor() ctx := context.Background() - output, err := executor.Execute(ctx, "sh", "-c", "exit 1") + var output *ToolOutput + var err error + + if runtime.GOOS == "windows" { + output, err = executor.Execute(ctx, "cmd", "/c", "exit 1") + } else { + output, err = executor.Execute(ctx, "sh", "-c", "exit 1") + } + if err != nil { t.Fatalf("Execute should not return error for non-zero exit: %v", err) } @@ -87,10 +113,18 @@ func TestExecute_NonZeroExit(t *testing.T) { func TestExecute_Timeout(t *testing.T) { executor := NewSubprocessExecutor() - executor.Timeout = 10 * time.Millisecond + executor.Timeout = 100 * time.Millisecond ctx := context.Background() - output, err := executor.Execute(ctx, "sleep", "1") + var output *ToolOutput + var err error + + if runtime.GOOS == "windows" { + output, err = executor.Execute(ctx, "cmd", "/c", "ping -n 3 127.0.0.1") + } else { + output, err = executor.Execute(ctx, "sleep", "1") + } + // Timeout can result in either error or killed exit code if err == nil && (output == nil || output.ExitCode == 0) { t.Error("Expected timeout error or non-zero exit code") diff --git a/internal/auth/README.md b/internal/auth/README.md deleted file mode 100644 index c860b9f..0000000 --- a/internal/auth/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# auth - -GitHub OAuth 인증 플로우를 관리합니다. - -로컬 콜백 서버를 실행하여 OAuth 인증 과정을 처리하고 액세스 토큰을 발급받습니다. - -**사용자**: cmd -**의존성**: config, github diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go deleted file mode 100644 index e7eae87..0000000 --- a/internal/auth/oauth.go +++ /dev/null @@ -1,128 +0,0 @@ -package auth - -import ( - "context" - _ "embed" - "fmt" - "net/http" - "github.com/DevSymphony/sym-cli/internal/config" - "github.com/DevSymphony/sym-cli/internal/github" - "time" - - "github.com/pkg/browser" -) - -//go:embed static/login-success.html -var loginSuccessHTML string - -const ( - // symphonyclient integration: default port 3000 → 8787 - callbackPort = 8787 - redirectURI = "http://localhost:8787/oauth/callback" -) - -// StartOAuthFlow initiates the OAuth flow and waits for the callback -func StartOAuthFlow() error { - // Load config - cfg, err := config.LoadConfig() - if err != nil { - return err - } - - // Create a channel to receive the authorization code - codeChan := make(chan string, 1) - errChan := make(chan error, 1) - - // Create HTTP server for callback - mux := http.NewServeMux() - mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - if code == "" { - errChan <- fmt.Errorf("no authorization code received") - http.Error(w, "Authorization failed: no code received", http.StatusBadRequest) - return - } - - codeChan <- code - - // Send success message to browser using embedded HTML - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(loginSuccessHTML)) - }) - - // Serve the Tailwind CSS file - mux.HandleFunc("/styles/output.css", func(w http.ResponseWriter, r *http.Request) { - // Serve the built CSS file from the server's static directory - http.ServeFile(w, r, "internal/server/static/styles/output.css") - }) - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", callbackPort), - Handler: mux, - } - - // Start server in goroutine - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errChan <- err - } - }() - - // Give server time to start - time.Sleep(100 * time.Millisecond) - - // Open browser - authURL := github.GetAuthURL(cfg.GitHubHost, cfg.ClientID, redirectURI) - fmt.Printf("Opening browser for authentication...\n") - fmt.Printf("If browser doesn't open, visit: %s\n\n", authURL) - - if err := browser.OpenURL(authURL); err != nil { - fmt.Printf("Could not open browser automatically: %v\n", err) - fmt.Printf("Please manually open: %s\n", authURL) - } - - // Wait for callback or error - var code string - select { - case code = <-codeChan: - // Success - case err := <-errChan: - _ = server.Shutdown(context.Background()) - return err - case <-time.After(5 * time.Minute): - _ = server.Shutdown(context.Background()) - return fmt.Errorf("authentication timeout") - } - - // Shutdown server - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = server.Shutdown(ctx) - - // Exchange code for token - fmt.Println("Exchanging code for access token...") - accessToken, err := github.ExchangeCodeForToken(cfg.GitHubHost, cfg.ClientID, cfg.ClientSecret, code) - if err != nil { - return fmt.Errorf("failed to exchange code for token: %w", err) - } - - // Save token - token := &config.Token{ - AccessToken: accessToken, - } - - if err := config.SaveToken(token); err != nil { - return fmt.Errorf("failed to save token: %w", err) - } - - // Verify token by getting user info - client := github.NewClient(cfg.GitHubHost, accessToken) - user, err := client.GetCurrentUser() - if err != nil { - return fmt.Errorf("failed to verify token: %w", err) - } - - fmt.Printf("\n✓ Successfully authenticated as %s\n", user.Login) - - return nil -} diff --git a/internal/auth/server.go b/internal/auth/server.go deleted file mode 100644 index 9b08097..0000000 --- a/internal/auth/server.go +++ /dev/null @@ -1,186 +0,0 @@ -package auth - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/pkg/browser" -) - -// sessionResponse is the response from /authStart -type sessionResponse struct { - SessionCode string `json:"session_code"` - AuthURL string `json:"auth_url"` - ExpiresIn int `json:"expires_in"` -} - -// statusResponse is the response from /authStatus -type statusResponse struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` - GithubToken string `json:"github_token,omitempty"` - GithubUsername string `json:"github_username,omitempty"` - GithubID int64 `json:"github_id,omitempty"` - GithubName string `json:"github_name,omitempty"` -} - -// AuthenticateWithServer performs authentication using the Sym auth server -func AuthenticateWithServer(serverURL string) (string, string, error) { - // 1. Start authentication session - session, err := startAuthSession(serverURL) - if err != nil { - return "", "", fmt.Errorf("failed to start auth session: %w", err) - } - - fmt.Printf("\n🔐 Symphony CLI 인증\n") - fmt.Printf(" 세션 코드: %s\n", session.SessionCode) - fmt.Printf(" 만료 시간: %d초 후\n\n", session.ExpiresIn) - - // 2. Open browser - fmt.Println("브라우저를 열어서 GitHub 로그인을 진행합니다...") - fmt.Printf("URL: %s\n\n", session.AuthURL) - - if err := browser.OpenURL(session.AuthURL); err != nil { - fmt.Printf("⚠️ 브라우저를 자동으로 열 수 없습니다.\n") - fmt.Printf(" 수동으로 다음 URL을 열어주세요:\n") - fmt.Printf(" %s\n\n", session.AuthURL) - } - - // 3. Poll for status - fmt.Print("승인 대기 중") - token, username, err := pollForToken(serverURL, session.SessionCode, session.ExpiresIn) - if err != nil { - return "", "", err - } - - fmt.Printf("\n\n✅ 인증 성공! (%s)\n", username) - - return token, username, nil -} - -// startAuthSession starts a new authentication session -func startAuthSession(serverURL string) (*sessionResponse, error) { - url := serverURL + "/authStart" - - requestBody := map[string]string{ - "device_name": "CLI", - } - - jsonData, err := json.Marshal(requestBody) - if err != nil { - return nil, err - } - - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to connect to auth server: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("server returned error: %s (status: %d)", string(body), resp.StatusCode) - } - - var session sessionResponse - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { - return nil, fmt.Errorf("failed to parse server response: %w", err) - } - - return &session, nil -} - -// pollForToken polls the server for authentication status -func pollForToken(serverURL, sessionCode string, expiresIn int) (string, string, error) { - url := fmt.Sprintf("%s/authStatus/%s", serverURL, sessionCode) - - // Poll every 3 seconds - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - - // Setup timeout timer - timeoutTimer := time.After(time.Duration(expiresIn) * time.Second) - - for { - select { - case <-timeoutTimer: - return "", "", fmt.Errorf("authentication timeout (%d초). 다시 시도해주세요", expiresIn) - - case <-ticker.C: - // Check status - status, err := checkAuthStatus(url) - if err != nil { - // Retry on error - fmt.Print(".") - continue - } - - switch status.Status { - case "pending": - // Still waiting - fmt.Print(".") - continue - - case "approved": - // Success! - if status.GithubToken == "" { - return "", "", fmt.Errorf("server did not return token") - } - return status.GithubToken, status.GithubUsername, nil - - case "denied": - return "", "", fmt.Errorf("인증이 거부되었습니다") - - case "expired": - return "", "", fmt.Errorf("세션이 만료되었습니다. 다시 시도해주세요") - - default: - return "", "", fmt.Errorf("unknown status: %s", status.Status) - } - } - } -} - -// checkAuthStatus checks the authentication status -func checkAuthStatus(url string) (*statusResponse, error) { - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("invalid session code") - } - - if resp.StatusCode == http.StatusGone { - // Session expired - var status statusResponse - _ = json.NewDecoder(resp.Body).Decode(&status) - return &status, nil - } - - if resp.StatusCode == http.StatusForbidden { - // Denied - var status statusResponse - _ = json.NewDecoder(resp.Body).Decode(&status) - return &status, nil - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("server error: %s (status: %d)", string(body), resp.StatusCode) - } - - var status statusResponse - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, err - } - - return &status, nil -} diff --git a/internal/auth/static/login-success.html b/internal/auth/static/login-success.html deleted file mode 100644 index 6177015..0000000 --- a/internal/auth/static/login-success.html +++ /dev/null @@ -1,269 +0,0 @@ - - - - - - Symphony - 인증 성공 - - - -
- -
- -
-
- - - -
-
- - -

인증 성공!

- - -

- Symphony 인증이 성공적으로 완료되었습니다.
- 이제 이 창을 닫고 터미널로 돌아가세요. -

- - -
-
- - - -
-

다음 단계

-
    -
  • • 터미널에서 symphony my-role로 역할 확인
  • -
  • symphony dashboard로 대시보드 실행
  • -
-
-
-
- - - - - -

- 이 창은 자동으로 닫히지 않습니다 -

-
- - -
-
- - - - Symphony -
-

- GitHub Repository Role & Policy Management -

-
-
- - diff --git a/internal/cmd/config.go b/internal/cmd/config.go deleted file mode 100644 index cc1d74a..0000000 --- a/internal/cmd/config.go +++ /dev/null @@ -1,285 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "os" - "strings" - "github.com/DevSymphony/sym-cli/internal/config" - - "github.com/spf13/cobra" -) - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Configure Symphony settings", - Long: `Configure Symphony with your GitHub host and OAuth application credentials. - -Examples: - sym config # Interactive configuration - sym config --show # Show current configuration - sym config --reset # Reset to default (server mode) - sym config --use-custom-oauth # Switch to custom OAuth mode`, - Run: runConfig, -} - -var ( - configHost string - configShow bool - configReset bool - configID string - configSecret string - useCustomOAuth bool - configServerURL string -) - -func init() { - configCmd.Flags().StringVar(&configHost, "host", "", "GitHub host (e.g., github.com or ghes.company.com)") - configCmd.Flags().StringVar(&configID, "client-id", "", "OAuth App Client ID") - configCmd.Flags().StringVar(&configSecret, "client-secret", "", "OAuth App Client Secret") - configCmd.Flags().BoolVar(&useCustomOAuth, "use-custom-oauth", false, "Use custom OAuth App (for GitHub Enterprise)") - configCmd.Flags().StringVar(&configServerURL, "server-url", "", "Symphony auth server URL") - configCmd.Flags().BoolVar(&configShow, "show", false, "Show current configuration") - configCmd.Flags().BoolVar(&configReset, "reset", false, "Reset configuration to default (server mode)") -} - -func runConfig(cmd *cobra.Command, args []string) { - if configShow { - showConfig() - return - } - - if configReset { - resetConfig() - return - } - - // Load existing config or create new one - cfg, err := config.LoadConfig() - if err != nil { - cfg = &config.Config{ - AuthMode: "server", // default - } - } - - // Handle server URL configuration - if configServerURL != "" { - cfg.ServerURL = configServerURL - if err := config.SaveConfig(cfg); err != nil { - fmt.Printf("❌ Failed to save configuration: %v\n", err) - os.Exit(1) - } - fmt.Println("✓ Server URL updated successfully!") - fmt.Printf(" Server URL: %s\n", cfg.ServerURL) - return - } - - // Handle custom OAuth configuration - if useCustomOAuth { - configureCustomOAuth(cfg) - return - } - - // No flags provided - show interactive menu - showConfigMenu(cfg) -} - -func showConfigMenu(cfg *config.Config) { - fmt.Println("\n🎵 Symphony 설정") - fmt.Println() - fmt.Println("인증 모드를 선택하세요:") - fmt.Println(" 1. 서버 인증 (기본, 권장)") - fmt.Println(" - OAuth App 설정 불필요") - fmt.Println(" - 브라우저에서 GitHub 로그인만 하면 됨") - fmt.Println() - fmt.Println(" 2. Custom OAuth (GitHub Enterprise 사용자)") - fmt.Println(" - 자체 OAuth App 필요") - fmt.Println(" - 기업용 GitHub 서버 지원") - fmt.Println() - - reader := bufio.NewReader(os.Stdin) - fmt.Print("선택 [1]: ") - input, _ := reader.ReadString('\n') - choice := strings.TrimSpace(input) - - if choice == "" { - choice = "1" - } - - switch choice { - case "1": - // Server mode - cfg.AuthMode = "server" - fmt.Println("\n✓ 서버 인증 모드로 설정되었습니다") - fmt.Println() - fmt.Println("다음 단계:") - fmt.Println(" sym login # GitHub 로그인") - - case "2": - // Custom OAuth mode - configureCustomOAuth(cfg) - return - - default: - fmt.Println("잘못된 선택입니다") - os.Exit(1) - } - - // Save config - if err := config.SaveConfig(cfg); err != nil { - fmt.Printf("❌ Failed to save configuration: %v\n", err) - os.Exit(1) - } -} - -func configureCustomOAuth(cfg *config.Config) { - fmt.Println("\n🔧 Custom OAuth 설정") - fmt.Println() - - reader := bufio.NewReader(os.Stdin) - - // GitHub Host - if configHost != "" { - cfg.GitHubHost = configHost - } else { - defaultHost := cfg.GitHubHost - if defaultHost == "" { - defaultHost = "github.com" - } - fmt.Printf("GitHub Host [%s]: ", defaultHost) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - if input != "" { - cfg.GitHubHost = input - } else { - cfg.GitHubHost = defaultHost - } - } - - // Client ID - if configID != "" { - cfg.ClientID = configID - } else { - fmt.Printf("OAuth Client ID [%s]: ", maskString(cfg.ClientID)) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - if input != "" { - cfg.ClientID = input - } - } - - // Client Secret - if configSecret != "" { - cfg.ClientSecret = configSecret - } else { - fmt.Printf("OAuth Client Secret [%s]: ", maskString(cfg.ClientSecret)) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - if input != "" { - cfg.ClientSecret = input - } - } - - // Validate - if cfg.GitHubHost == "" || cfg.ClientID == "" || cfg.ClientSecret == "" { - fmt.Println("\n❌ Error: All fields are required") - os.Exit(1) - } - - // Set mode to custom - cfg.AuthMode = "custom" - - // Save config - if err := config.SaveConfig(cfg); err != nil { - fmt.Printf("❌ Failed to save configuration: %v\n", err) - os.Exit(1) - } - - fmt.Println("\n✓ Configuration saved successfully!") - fmt.Printf(" Mode: Custom OAuth\n") - fmt.Printf(" GitHub Host: %s\n", cfg.GitHubHost) - fmt.Printf(" Client ID: %s\n", maskString(cfg.ClientID)) - fmt.Println("\nNext step: Run 'sym login' to authenticate") -} - -func showConfig() { - cfg, err := config.LoadConfig() - if err != nil { - fmt.Printf("❌ No configuration found: %v\n", err) - fmt.Println("Run 'sym config' to set up") - os.Exit(1) - } - - fmt.Println("Current Configuration:") - fmt.Printf(" Authentication Mode: %s\n", cfg.GetAuthMode()) - - if cfg.IsCustomOAuth() { - // Custom OAuth mode - fmt.Printf(" GitHub Host: %s\n", cfg.GitHubHost) - fmt.Printf(" Client ID: %s\n", maskString(cfg.ClientID)) - fmt.Printf(" Client Secret: %s\n", maskString(cfg.ClientSecret)) - } else { - // Server mode - fmt.Printf(" Server URL: %s\n", cfg.GetServerURL()) - } - - fmt.Printf("\nConfig file: %s\n", config.GetConfigPath()) - - if config.IsLoggedIn() { - fmt.Println("\n✓ Logged in") - fmt.Printf("Token file: %s\n", config.GetTokenPath()) - } else { - fmt.Println("\n⚠ Not logged in") - fmt.Println("Run 'sym login' to authenticate") - } -} - -func resetConfig() { - fmt.Println("🔄 Resetting configuration to default...") - fmt.Println() - - // Check if already logged in - if config.IsLoggedIn() { - fmt.Println("⚠️ Warning: You are currently logged in.") - fmt.Print(" Resetting config will keep your token, but you may need to login again.\n") - fmt.Print(" Continue? (y/N): ") - - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) - - if response != "y" && response != "yes" { - fmt.Println("\n❌ Reset cancelled") - os.Exit(0) - } - fmt.Println() - } - - // Create default config (server mode) - defaultCfg := &config.Config{ - AuthMode: "server", - } - - // Save config - if err := config.SaveConfig(defaultCfg); err != nil { - fmt.Printf("❌ Failed to reset configuration: %v\n", err) - os.Exit(1) - } - - fmt.Println("✓ Configuration reset to default!") - fmt.Println() - fmt.Println(" Authentication Mode: server (default)") - fmt.Printf(" Server URL: %s\n", defaultCfg.GetServerURL()) - fmt.Println() - fmt.Println("Next step: Run 'sym login' to authenticate") -} - -func maskString(s string) string { - if s == "" { - return "" - } - if len(s) <= 8 { - return "****" - } - return s[:4] + "****" + s[len(s)-4:] -} diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index c238938..15e8501 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -3,8 +3,7 @@ package cmd import ( "fmt" "os" - "github.com/DevSymphony/sym-cli/internal/config" - "github.com/DevSymphony/sym-cli/internal/git" + "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/internal/server" @@ -15,43 +14,26 @@ var dashboardCmd = &cobra.Command{ Use: "dashboard", Aliases: []string{"dash"}, Short: "Start the web dashboard", - Long: `Start a local web server to manage roles through a browser interface. + Long: `Start a local web server to manage roles and policies through a browser interface. The dashboard provides a visual interface for: - - Viewing all users and their roles - - Adding/removing users from roles (admin only) - - Viewing current repository information`, + - Selecting your role + - Managing role permissions + - Editing coding policies and rules`, Run: runDashboard, } var dashboardPort int func init() { - // symphonyclient integration: default port 3000 → 8787 dashboardCmd.Flags().IntVarP(&dashboardPort, "port", "p", 8787, "Port to run the dashboard on") } func runDashboard(cmd *cobra.Command, args []string) { - // Check if logged in - if !config.IsLoggedIn() { - fmt.Println("❌ Not logged in") - // symphonyclient integration: symphony → sym command - fmt.Println("Run 'sym login' first") - os.Exit(1) - } - - // Check if in git repository - if !git.IsGitRepo() { - fmt.Println("❌ Not a git repository") - fmt.Println("Navigate to a git repository before running this command") - os.Exit(1) - } - // Check if roles.json exists exists, err := roles.RolesExists() if err != nil || !exists { fmt.Println("❌ roles.json not found") - // symphonyclient integration: symphony → sym command fmt.Println("Run 'sym init' to create it") os.Exit(1) } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index feae6c7..db11c01 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -6,29 +6,24 @@ import ( "path/filepath" "github.com/DevSymphony/sym-cli/internal/adapter/registry" - "github.com/DevSymphony/sym-cli/internal/config" "github.com/DevSymphony/sym-cli/internal/envutil" - "github.com/DevSymphony/sym-cli/internal/git" - "github.com/DevSymphony/sym-cli/internal/github" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" - "github.com/DevSymphony/sym-cli/pkg/schema" // symphonyclient integration + "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/spf13/cobra" ) var initCmd = &cobra.Command{ Use: "init", - Short: "Initialize roles.json for the current repository", - Long: `Create a .sym/roles.json file in the current repository and -automatically set the current user as the first admin. + Short: "Initialize Symphony for the current directory", + Long: `Create a .sym directory with roles.json and user-policy.json files. This command: - 1. Checks if you're logged in - 2. Gets your GitHub username - 3. Verifies the current directory is a git repository - 4. Creates .sym/roles.json with you as admin - 5. Prompts you to commit and push the changes`, + 1. Creates .sym/roles.json with default roles (admin, developer, viewer) + 2. Creates .sym/user-policy.json with default RBAC configuration + 3. Sets your role to admin (can be changed later via dashboard) + 4. Optionally registers MCP server for AI tools`, Run: runInit, } @@ -74,40 +69,6 @@ func runInit(cmd *cobra.Command, args []string) { return } - // Check if logged in - if !config.IsLoggedIn() { - fmt.Println("❌ Not logged in") - fmt.Println("Run 'sym login' first") - os.Exit(1) - } - - // Check if in git repository - if !git.IsGitRepo() { - fmt.Println("❌ Not a git repository") - fmt.Println("Navigate to a git repository before running this command") - os.Exit(1) - } - - // Get current user - cfg, err := config.LoadConfig() - if err != nil { - fmt.Printf("❌ Failed to load config: %v\n", err) - os.Exit(1) - } - - token, err := config.LoadToken() - if err != nil { - fmt.Printf("❌ Failed to load token: %v\n", err) - os.Exit(1) - } - - client := github.NewClient(cfg.GetGitHubHost(), token.AccessToken) - user, err := client.GetCurrentUser() - if err != nil { - fmt.Printf("❌ Failed to get current user: %v\n", err) - os.Exit(1) - } - // Check if roles.json already exists exists, err := roles.RolesExists() if err != nil { @@ -128,9 +89,9 @@ func runInit(cmd *cobra.Command, args []string) { } } - // Create roles with current user as admin + // Create default roles (empty user lists - users select their own role) newRoles := roles.Roles{ - "admin": []string{user.Login}, + "admin": []string{}, "developer": []string{}, "viewer": []string{}, } @@ -143,11 +104,10 @@ func runInit(cmd *cobra.Command, args []string) { rolesPath, _ := roles.GetRolesPath() fmt.Println("✓ roles.json created successfully!") fmt.Printf(" Location: %s\n", rolesPath) - fmt.Printf(" You (%s) have been set as admin\n", user.Login) // Create default policy file with RBAC roles fmt.Println("\nCreating default policy file...") - if err := createDefaultPolicy(cfg); err != nil { + if err := createDefaultPolicy(); err != nil { fmt.Printf("⚠ Warning: Failed to create policy file: %v\n", err) fmt.Println("You can manually create it later using the dashboard") } else { @@ -162,6 +122,13 @@ func runInit(cmd *cobra.Command, args []string) { fmt.Println("✓ .sym/.env created with default policy path") } + // Set default role to admin during initialization + if err := roles.SetCurrentRole("admin"); err != nil { + fmt.Printf("⚠ Warning: Failed to save role selection: %v\n", err) + } else { + fmt.Println("✓ Your role has been set to: admin (default for initialization)") + } + // MCP registration prompt if !skipMCPRegister { promptMCPRegistration() @@ -179,17 +146,20 @@ func runInit(cmd *cobra.Command, args []string) { fmt.Println(" sym dashboard") fmt.Println() fmt.Println("Dashboard features:") - fmt.Println(" 📋 Manage roles - Add/remove team members, configure permissions") + fmt.Println(" 📋 Manage roles - Configure permissions for each role") fmt.Println(" 📝 Edit policies - Create and modify coding conventions") + fmt.Println(" 🎭 Change role - Select a different role anytime") fmt.Println(" ✅ Test validation - Check rules against your code in real-time") fmt.Println() - fmt.Println("After setup, commit and push .sym/ folder to share with your team.") + fmt.Println("After setup, commit and push .sym/roles.json and .sym/user-policy.json to share with your team.") } // createDefaultPolicy creates a default policy file with RBAC roles -func createDefaultPolicy(cfg *config.Config) error { +func createDefaultPolicy() error { + defaultPolicyPath := ".sym/user-policy.json" + // Check if policy file already exists - exists, err := policy.PolicyExists(cfg.PolicyPath) + exists, err := policy.PolicyExists(defaultPolicyPath) if err != nil { return err } @@ -232,12 +202,10 @@ func createDefaultPolicy(cfg *config.Config) error { Rules: []schema.UserRule{}, } - return policy.SavePolicy(defaultPolicy, cfg.PolicyPath) + return policy.SavePolicy(defaultPolicy, defaultPolicyPath) } -// removeExistingCodePolicy removes all files generated by convert command -// including linter configurations and code-policy.json -// initializeEnvFile creates .sym/.env with default POLICY_PATH if not exists +// initializeEnvFile creates .sym/.env with default configuration func initializeEnvFile() error { envPath := filepath.Join(".sym", ".env") defaultPolicyPath := ".sym/user-policy.json" @@ -254,13 +222,14 @@ func initializeEnvFile() error { return envutil.SaveKeyToEnvFile(envPath, "POLICY_PATH", defaultPolicyPath) } - // .env doesn't exist, create it with default POLICY_PATH - content := fmt.Sprintf("# Policy configuration\nPOLICY_PATH=%s\n", defaultPolicyPath) + // .env doesn't exist, create it with default settings + content := fmt.Sprintf("# Symphony local configuration\nPOLICY_PATH=%s\nCURRENT_ROLE=admin\n", defaultPolicyPath) return os.WriteFile(envPath, []byte(content), 0644) } +// removeExistingCodePolicy removes generated linter config files when --force flag is used func removeExistingCodePolicy() error { - // Files generated by convert command (dynamically retrieved from registry) + // Get list of generated files from registry convertGeneratedFiles := []string{"code-policy.json"} convertGeneratedFiles = append(convertGeneratedFiles, registry.Global().GetAllConfigFiles()...) diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go index 040f893..6b8e8e8 100644 --- a/internal/cmd/llm.go +++ b/internal/cmd/llm.go @@ -450,6 +450,7 @@ func promptLLMBackendSetup() { Items: items, Templates: templates, Size: len(items), + Stdout: &bellSkipper{}, } index, _, err := selectPrompt.Run() diff --git a/internal/cmd/login.go b/internal/cmd/login.go deleted file mode 100644 index c01e04f..0000000 --- a/internal/cmd/login.go +++ /dev/null @@ -1,105 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "github.com/DevSymphony/sym-cli/internal/auth" - "github.com/DevSymphony/sym-cli/internal/config" - - "github.com/spf13/cobra" -) - -var loginCmd = &cobra.Command{ - Use: "login", - Short: "Authenticate with GitHub", - Long: `Start the OAuth flow to authenticate with GitHub. - -This will open your browser to complete the authentication process.`, - Run: runLogin, -} - -func runLogin(cmd *cobra.Command, args []string) { - // Check if already logged in - if config.IsLoggedIn() { - fmt.Println("⚠️ 이미 로그인되어 있습니다") - fmt.Println(" 다시 인증하려면 먼저 'sym logout'을 실행하세요") - os.Exit(0) - } - - // Load or create config - cfg, err := config.LoadConfig() - if err != nil { - // Config doesn't exist - create default config with server mode - cfg = &config.Config{ - AuthMode: "server", - } - if err := config.SaveConfig(cfg); err != nil { - fmt.Printf("❌ Failed to create config: %v\n", err) - os.Exit(1) - } - } - - // Choose authentication method based on mode - if cfg.IsCustomOAuth() { - // Custom OAuth mode (Enterprise) - loginWithCustomOAuth(cfg) - } else { - // Server mode (default) - loginWithServer(cfg) - } -} - -// loginWithServer authenticates using Symphony auth server -func loginWithServer(cfg *config.Config) { - serverURL := cfg.GetServerURL() - - fmt.Println("🎵 Symphony CLI 인증") - fmt.Printf(" 서버: %s\n", serverURL) - fmt.Println() - - // Authenticate with server - token, username, err := auth.AuthenticateWithServer(serverURL) - if err != nil { - fmt.Printf("\n❌ 인증 실패: %v\n", err) - fmt.Println() - fmt.Println("💡 문제가 계속되면 다음을 시도해보세요:") - fmt.Println(" 1. 네트워크 연결 확인") - fmt.Println(" 2. 서버 상태 확인: " + serverURL) - fmt.Println(" 3. Enterprise 사용자는 --use-custom-oauth 옵션 사용") - os.Exit(1) - } - - // Save token - if err := config.SaveToken(&config.Token{AccessToken: token}); err != nil { - fmt.Printf("❌ Failed to save token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\n환영합니다, %s!\n", username) - fmt.Println("\n이제 Symphony 명령어를 사용할 수 있습니다:") - fmt.Println(" sym whoami - 현재 사용자 확인") - fmt.Println(" sym init - 저장소 초기화") - fmt.Println(" sym dashboard - 웹 대시보드 실행") -} - -// loginWithCustomOAuth authenticates using custom OAuth app (Enterprise) -func loginWithCustomOAuth(cfg *config.Config) { - // Validate custom OAuth config - if cfg.GitHubHost == "" || cfg.ClientID == "" || cfg.ClientSecret == "" { - fmt.Println("❌ Custom OAuth 설정이 완료되지 않았습니다") - fmt.Println() - fmt.Println("다음 명령어로 설정을 완료하세요:") - fmt.Println(" sym config --use-custom-oauth") - os.Exit(1) - } - - fmt.Println("🔐 Custom OAuth 인증") - fmt.Printf(" GitHub: %s\n", cfg.GitHubHost) - fmt.Println() - - // Start OAuth flow - if err := auth.StartOAuthFlow(); err != nil { - fmt.Printf("❌ Authentication failed: %v\n", err) - os.Exit(1) - } -} diff --git a/internal/cmd/logout.go b/internal/cmd/logout.go deleted file mode 100644 index 42bdafc..0000000 --- a/internal/cmd/logout.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "github.com/DevSymphony/sym-cli/internal/config" - - "github.com/spf13/cobra" -) - -var logoutCmd = &cobra.Command{ - Use: "logout", - Short: "Log out and remove stored credentials", - Long: `Remove the stored access token and log out of Symphony.`, - Run: runLogout, -} - -func runLogout(cmd *cobra.Command, args []string) { - if !config.IsLoggedIn() { - fmt.Println("⚠ Not logged in") - os.Exit(0) - } - - if err := config.DeleteToken(); err != nil { - fmt.Printf("❌ Failed to log out: %v\n", err) - os.Exit(1) - } - - fmt.Println("✓ Successfully logged out") -} diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index a6179b8..a4da0b7 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -13,7 +13,7 @@ import ( ) // MCPRegistrationConfig represents the MCP configuration structure -// Used for Claude Code, Cursor +// Used for Claude Desktop, Claude Code, Cursor, Cline (mcpServers format) type MCPRegistrationConfig struct { MCPServers map[string]MCPServerConfig `json:"mcpServers"` } @@ -25,7 +25,7 @@ type VSCodeMCPConfig struct { } // MCPServerConfig represents a single MCP server configuration -// Used for Claude Code, Cursor +// Used for Claude Desktop, Claude Code, Cursor, Cline (mcpServers format) type MCPServerConfig struct { Type string `json:"type,omitempty"` // Optional for Claude Code, recommended for Cursor Command string `json:"command"` @@ -41,6 +41,34 @@ type VSCodeServerConfig struct { Env map[string]string `json:"env,omitempty"` } +// editorItem represents an editor option with selection state +type editorItem struct { + Name string + AppID string + Selected bool + IsSubmit bool + IsSkip bool +} + +// bellSkipper wraps os.Stdout to skip bell characters (prevents alert sound) +type bellSkipper struct{} + +func (bs *bellSkipper) Write(b []byte) (int, error) { + const bell = '\a' + // Filter out bell characters + filtered := make([]byte, 0, len(b)) + for _, c := range b { + if c != bell { + filtered = append(filtered, c) + } + } + return os.Stdout.Write(filtered) +} + +func (bs *bellSkipper) Close() error { + return nil +} + // promptMCPRegistration prompts user to register Symphony as MCP server func promptMCPRegistration() { // Check if npx is available @@ -62,79 +90,110 @@ func promptMCPRegistration() { fmt.Println("\n📡 Would you like to register Symphony as an MCP server?") fmt.Println(" (Symphony MCP provides code convention tools for AI assistants)") + fmt.Println(" Press Enter to toggle selection, then select Submit to apply") fmt.Println() - // Create selection prompt - items := []string{ - "Claude Desktop (global)", - "Claude Code (project)", - "Cursor (project)", - "VS Code Copilot (project)", - "Cline (project)", - "All", - "Skip", + // Initialize editor items + items := []editorItem{ + {Name: "Claude Desktop (global)", AppID: "claude-desktop"}, + {Name: "Claude Code (project)", AppID: "claude-code"}, + {Name: "Cursor (project)", AppID: "cursor"}, + {Name: "VS Code Copilot (project)", AppID: "vscode"}, + {Name: "Cline (global)", AppID: "cline"}, + {Name: "Submit", IsSubmit: true}, } - templates := &promptui.SelectTemplates{ - Label: "{{ . }}?", - Active: "▸ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "✓ {{ . | green }}", - } + // Track cursor position across loop iterations + cursorPos := 0 - prompt := promptui.Select{ - Label: "Select option", - Items: items, - Templates: templates, - Size: 6, - } - - index, _, err := prompt.Run() - if err != nil { - fmt.Println("\nSkipped MCP registration") - return - } + // Multi-select loop + for { + // Count selected items first + selectedCount := 0 + for _, item := range items { + if item.Selected { + selectedCount++ + } + } - switch index { - case 0: // Claude Desktop (global) - if err := registerMCP("claude-desktop"); err != nil { - fmt.Printf("❌ Failed to register Claude Desktop: %v\n", err) - } else { - fmt.Println("\n✅ MCP registration complete! Restart Claude Desktop to use Symphony.") + // Build display items with checkboxes + displayItems := make([]string, len(items)) + for i, item := range items { + if item.IsSubmit { + if selectedCount == 0 { + displayItems[i] = "⏭ Skip" + } else { + displayItems[i] = fmt.Sprintf("✅ Submit (%d selected)", selectedCount) + } + } else { + checkbox := "☐" + if item.Selected { + checkbox = "☑" + } + displayItems[i] = fmt.Sprintf("%s %s", checkbox, item.Name) + } } - case 1: // Claude Code (project) - if err := registerMCP("claude-code"); err != nil { - fmt.Printf("❌ Failed to register Claude Code: %v\n", err) - } else { - fmt.Println("\n✅ MCP registration complete! Reload Claude Code to use Symphony.") + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "▸ {{ . | cyan }}", + Inactive: " {{ . }}", + Selected: "{{ . }}", } - case 2: // Cursor (project) - if err := registerMCP("cursor"); err != nil { - fmt.Printf("❌ Failed to register Cursor: %v\n", err) - } else { - fmt.Println("\n✅ MCP registration complete! Reload Cursor to use Symphony.") + + prompt := promptui.Select{ + Label: "Select editors (Enter to toggle)", + Items: displayItems, + Templates: templates, + Size: 6, + HideSelected: true, + CursorPos: cursorPos, + Stdout: &bellSkipper{}, } - case 3: // VS Code/Cline (project) - if err := registerMCP("vscode"); err != nil { - fmt.Printf("❌ Failed to register VS Code: %v\n", err) - } else { - fmt.Println("\n✅ MCP registration complete! Reload VS Code to use Symphony.") + + index, _, err := prompt.Run() + if err != nil { + fmt.Println("\nSkipped MCP registration") + return } - case 4: // All - apps := []string{"claude-desktop", "claude-code", "cursor", "vscode"} - successCount := 0 - for _, app := range apps { - if registerMCP(app) == nil { - successCount++ + + selectedItem := &items[index] + + if selectedItem.IsSubmit { + // Collect selected apps + var selectedApps []string + for _, item := range items { + if item.Selected && item.AppID != "" { + selectedApps = append(selectedApps, item.AppID) + } } + + // If no editors selected, act as Skip + if len(selectedApps) == 0 { + fmt.Println("Skipped MCP registration") + fmt.Println("\n💡 Tip: Run 'sym init --register-mcp' to register MCP later") + return + } + + // Register all selected apps + successCount := 0 + for _, appID := range selectedApps { + if registerMCP(appID) == nil { + successCount++ + } + } + + if successCount > 0 { + fmt.Printf("\n✅ MCP registration complete! Registered to %d app(s).\n", successCount) + fmt.Println(" Restart/reload the apps to use Symphony.") + } + return } - if successCount > 0 { - fmt.Printf("\n✅ MCP registration complete! Registered to %d app(s).\n", successCount) - fmt.Println(" Restart/reload the apps to use Symphony.") - } - case 5: // Skip - fmt.Println("Skipped MCP registration") - fmt.Println("\n💡 Tip: Run 'sym init --register-mcp' to register MCP later") + + // Toggle selection for editor items + selectedItem.Selected = !selectedItem.Selected + // Preserve cursor position for next iteration + cursorPos = index } } @@ -148,7 +207,7 @@ func registerMCP(app string) error { } // Check if this is a project-specific config - isProjectConfig := app != "claude-desktop" + isProjectConfig := app != "claude-desktop" && app != "cline" if isProjectConfig { fmt.Printf("\n✓ Configuring %s (project-specific)\n", getAppDisplayName(app)) @@ -170,7 +229,7 @@ func registerMCP(app string) error { var data []byte if app == "vscode" { - // VS Code uses different format + // VS Code Copilot uses different MCP config format var vscodeConfig VSCodeMCPConfig if fileExists { @@ -214,7 +273,7 @@ func registerMCP(app string) error { return fmt.Errorf("failed to marshal config: %w", err) } } else { - // Claude Code, Cursor use standard format + // Claude Desktop, Claude Code, Cursor, Cline use mcpServers format var config MCPRegistrationConfig if fileExists { @@ -273,7 +332,8 @@ func registerMCP(app string) error { fmt.Printf(" ✓ Symphony MCP server registered\n") // Create instructions file for project-specific configs - if isProjectConfig { + // Note: Cline has global MCP config but project-specific .clinerules + if isProjectConfig || app == "cline" { if err := createInstructionsFile(app); err != nil { fmt.Printf(" ⚠ Failed to create instructions file: %v\n", err) } @@ -311,6 +371,16 @@ func getMCPConfigPath(app string) string { case "vscode": // Project-specific configuration path = filepath.Join(cwd, ".vscode", "mcp.json") + case "cline": + // Global configuration (VS Code extension storage) + switch runtime.GOOS { + case "windows": + path = filepath.Join(os.Getenv("APPDATA"), "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + case "darwin": + path = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + case "linux": + path = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") + } } return path @@ -326,7 +396,9 @@ func getAppDisplayName(app string) string { case "cursor": return "Cursor" case "vscode": - return "VS Code/Cline" + return "VS Code Copilot" + case "cline": + return "Cline" default: return app } @@ -359,6 +431,11 @@ func createInstructionsFile(app string) error { instructionsPath = filepath.Join(".github", "instructions", "symphony.instructions.md") content = getVSCodeInstructions() appendMode = false + case "cline": + // Use .clinerules for Cline (Markdown format in project root) + instructionsPath = ".clinerules" + content = getClineInstructions() + appendMode = true default: return nil // No instructions file for this app } @@ -405,6 +482,16 @@ func createInstructionsFile(app string) error { return fmt.Errorf("failed to write file: %w", err) } + // Add VS Code instructions directory to .gitignore + if app == "vscode" { + gitignorePath := ".github/instructions/" + if err := ensureGitignore(gitignorePath); err != nil { + fmt.Printf(" ⚠ Warning: Failed to update .gitignore: %v\n", err) + } else { + fmt.Printf(" ✓ Added %s to .gitignore\n", gitignorePath) + } + } + return nil } @@ -515,3 +602,30 @@ Check MCP → Query Conventions → Write Code → Validate → Fix → Commit Auto-generated by Symphony ` } + +// getClineInstructions returns instructions for Cline (.clinerules) +func getClineInstructions() string { + return `# Symphony Code Conventions + +This project uses Symphony MCP for automated code convention management. + +## Required Workflow + +### Before Writing Code +1. **Verify Symphony MCP is active** - Check that the Symphony MCP server is available +2. **Query conventions** - Use the symphony/query_conventions tool + - Select appropriate category: security, style, documentation, error_handling, architecture, performance, testing + - Filter by programming language as needed + +### After Writing Code +1. **Validate all changes** - Use the symphony/validate_code tool to check against project conventions +2. **Fix any violations** - Address all issues before proceeding +3. **Only commit after validation passes** + +## Summary +Always: Check MCP → Query Conventions → Write Code → Validate → Fix → Commit + +--- +Auto-generated by Symphony +` +} diff --git a/internal/cmd/my_role.go b/internal/cmd/my_role.go index 9c9498e..a119837 100644 --- a/internal/cmd/my_role.go +++ b/internal/cmd/my_role.go @@ -1,12 +1,13 @@ package cmd import ( + "bufio" "encoding/json" "fmt" "os" - "github.com/DevSymphony/sym-cli/internal/config" - "github.com/DevSymphony/sym-cli/internal/git" - "github.com/DevSymphony/sym-cli/internal/github" + "strconv" + "strings" + "github.com/DevSymphony/sym-cli/internal/roles" "github.com/spf13/cobra" @@ -14,96 +15,117 @@ import ( var myRoleCmd = &cobra.Command{ Use: "my-role", - Short: "Check your role in the current repository", - Long: `Display your role in the current repository based on roles.json. + Short: "Check or change your currently selected role", + Long: `Display your currently selected role or change it. -Output can be formatted as JSON using --json flag for scripting purposes.`, +Output can be formatted as JSON using --json flag for scripting purposes. +Use --select flag to interactively select a new role.`, Run: runMyRole, } -var myRoleJSON bool +var ( + myRoleJSON bool + myRoleSelect bool +) func init() { myRoleCmd.Flags().BoolVar(&myRoleJSON, "json", false, "Output in JSON format") + myRoleCmd.Flags().BoolVar(&myRoleSelect, "select", false, "Interactively select a new role") } func runMyRole(cmd *cobra.Command, args []string) { - // Check if logged in - if !config.IsLoggedIn() { + // Check if roles.json exists + exists, err := roles.RolesExists() + if err != nil || !exists { if myRoleJSON { - output := map[string]string{"error": "not logged in"} + output := map[string]string{"error": "roles.json not found"} _ = json.NewEncoder(os.Stdout).Encode(output) } else { - fmt.Println("❌ Not logged in") - fmt.Println("Run 'sym login' first") + fmt.Println("❌ roles.json not found") + fmt.Println("Run 'sym init' first") } os.Exit(1) } - // Check if in git repository - if !git.IsGitRepo() { - if myRoleJSON { - output := map[string]string{"error": "not a git repository"} - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - fmt.Println("❌ Not a git repository") - fmt.Println("Navigate to a git repository before running this command") - } - os.Exit(1) + // If --select flag is provided, prompt for role selection + if myRoleSelect { + selectNewRole() + return } - // Get current user - cfg, err := config.LoadConfig() + // Get current role + role, err := roles.GetCurrentRole() if err != nil { - handleError("Failed to load config", err, myRoleJSON) + handleError("Failed to get current role", err, myRoleJSON) os.Exit(1) } - token, err := config.LoadToken() - if err != nil { - handleError("Failed to load token", err, myRoleJSON) - os.Exit(1) + if myRoleJSON { + output := map[string]string{ + "role": role, + } + _ = json.NewEncoder(os.Stdout).Encode(output) + } else { + if role == "" { + fmt.Println("⚠ No role selected") + fmt.Println("Run 'sym my-role --select' to select a role") + fmt.Println("Or use the dashboard: 'sym dashboard'") + } else { + fmt.Printf("Current role: %s\n", role) + fmt.Println("\nTo change your role:") + fmt.Println(" sym my-role --select") + } } +} - client := github.NewClient(cfg.GetGitHubHost(), token.AccessToken) - user, err := client.GetCurrentUser() +func selectNewRole() { + availableRoles, err := roles.GetAvailableRoles() if err != nil { - handleError("Failed to get current user", err, myRoleJSON) + fmt.Printf("❌ Failed to get available roles: %v\n", err) os.Exit(1) } - // Get user role - role, err := roles.GetUserRole(user.Login) - if err != nil { - handleError("Failed to get role", err, myRoleJSON) + if len(availableRoles) == 0 { + fmt.Println("❌ No roles defined in roles.json") os.Exit(1) } - // Get repo info - owner, repo, err := git.GetRepoInfo() - if err != nil { - handleError("Failed to get repository info", err, myRoleJSON) - os.Exit(1) - } + currentRole, _ := roles.GetCurrentRole() - if myRoleJSON { - output := map[string]string{ - "username": user.Login, - "role": role, - "owner": owner, - "repo": repo, + fmt.Println("🎭 Select your role:") + fmt.Println() + for i, role := range availableRoles { + marker := " " + if role == currentRole { + marker = "→ " } - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - fmt.Printf("Repository: %s/%s\n", owner, repo) - fmt.Printf("User: %s\n", user.Login) - fmt.Printf("Role: %s\n", role) + fmt.Printf("%s%d. %s\n", marker, i+1, role) + } + fmt.Println() - if role == "none" { - fmt.Println("\n⚠ You don't have any role assigned in this repository") - fmt.Println("Contact an admin to get access") - } + reader := bufio.NewReader(os.Stdin) + fmt.Print("Enter number (1-" + strconv.Itoa(len(availableRoles)) + "): ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + fmt.Println("⚠ No selection made") + return + } + + num, err := strconv.Atoi(input) + if err != nil || num < 1 || num > len(availableRoles) { + fmt.Println("❌ Invalid selection") + os.Exit(1) } + + selectedRole := availableRoles[num-1] + if err := roles.SetCurrentRole(selectedRole); err != nil { + fmt.Printf("❌ Failed to save role: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Your role has been changed to: %s\n", selectedRole) } func handleError(msg string, err error, jsonMode bool) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 92553c8..89ae22c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,7 +11,6 @@ import ( // Used by convert and validate commands var verbose bool -// symphonyclient integration: Updated root command from symphony to sym var rootCmd = &cobra.Command{ Use: "sym", Short: "sym - Code Convention Management Tool with RBAC", @@ -20,7 +19,7 @@ var rootCmd = &cobra.Command{ Features: - Natural language policy definition (A → B schema conversion) - Multi-engine code validation (Pattern, Length, Style, AST) - - Role-based file access control with GitHub OAuth + - Local role-based file access control - Web dashboard for policy and role management - Template system for popular frameworks`, } @@ -36,14 +35,10 @@ func init() { // Global flags rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") - // symphonyclient integration: Added symphonyclient commands - rootCmd.AddCommand(configCmd) - rootCmd.AddCommand(loginCmd) - rootCmd.AddCommand(logoutCmd) + // Core commands rootCmd.AddCommand(initCmd) rootCmd.AddCommand(dashboardCmd) rootCmd.AddCommand(myRoleCmd) - rootCmd.AddCommand(whoamiCmd) rootCmd.AddCommand(policyCmd) // Note: mcpCmd is registered in mcp.go's init() diff --git a/internal/cmd/whoami.go b/internal/cmd/whoami.go deleted file mode 100644 index c7602de..0000000 --- a/internal/cmd/whoami.go +++ /dev/null @@ -1,89 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "github.com/DevSymphony/sym-cli/internal/config" - "github.com/DevSymphony/sym-cli/internal/github" - - "github.com/spf13/cobra" -) - -var whoamiCmd = &cobra.Command{ - Use: "whoami", - Short: "Display the current authenticated user", - Long: `Show information about the currently authenticated GitHub user. - -Output can be formatted as JSON using --json flag for scripting purposes.`, - Run: runWhoami, -} - -var whoamiJSON bool - -func init() { - whoamiCmd.Flags().BoolVar(&whoamiJSON, "json", false, "Output in JSON format") -} - -func runWhoami(cmd *cobra.Command, args []string) { - // Check if logged in - if !config.IsLoggedIn() { - if whoamiJSON { - output := map[string]string{"error": "not logged in"} - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - fmt.Println("❌ Not logged in") - fmt.Println("Run 'sym login' first") - } - os.Exit(1) - } - - // Get current user - cfg, err := config.LoadConfig() - if err != nil { - handleWhoamiError("Failed to load config", err, whoamiJSON) - os.Exit(1) - } - - token, err := config.LoadToken() - if err != nil { - handleWhoamiError("Failed to load token", err, whoamiJSON) - os.Exit(1) - } - - client := github.NewClient(cfg.GetGitHubHost(), token.AccessToken) - user, err := client.GetCurrentUser() - if err != nil { - handleWhoamiError("Failed to get current user", err, whoamiJSON) - os.Exit(1) - } - - if whoamiJSON { - output := map[string]interface{}{ - "username": user.Login, - "name": user.Name, - "email": user.Email, - "id": user.ID, - } - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - fmt.Printf("Username: %s\n", user.Login) - if user.Name != "" { - fmt.Printf("Name: %s\n", user.Name) - } - if user.Email != "" { - fmt.Printf("Email: %s\n", user.Email) - } - fmt.Printf("GitHub ID: %d\n", user.ID) - fmt.Printf("Host: %s\n", cfg.GetGitHubHost()) - } -} - -func handleWhoamiError(msg string, err error, jsonMode bool) { - if jsonMode { - output := map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)} - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - fmt.Printf("❌ %s: %v\n", msg, err) - } -} diff --git a/internal/config/config.go b/internal/config/config.go index 1950d87..552b0cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,29 +8,14 @@ import ( "runtime" ) +// Config represents the Symphony CLI configuration type Config struct { - // Authentication mode: "server" (default) or "custom" - AuthMode string `json:"auth_mode,omitempty"` - - // Server authentication (default) - ServerURL string `json:"server_url,omitempty"` // Symphony auth server URL - - // Custom OAuth (Enterprise용, 선택사항) - GitHubHost string `json:"github_host,omitempty"` // "github.com" or custom GHES host - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - - PolicyPath string `json:"policy_path,omitempty"` // symphonyclient integration: Custom path for user-policy.json (default: .sym/user-policy.json) -} - -type Token struct { - AccessToken string `json:"access_token"` + PolicyPath string `json:"policy_path,omitempty"` // Custom path for user-policy.json } var ( configDir string configPath string - tokenPath string ) func init() { @@ -38,10 +23,8 @@ func init() { if runtime.GOOS == "windows" { homeDir = os.Getenv("USERPROFILE") } - // symphonyclient integration: symphony → sym directory configDir = filepath.Join(homeDir, ".config", "sym") configPath = filepath.Join(configDir, "config.json") - tokenPath = filepath.Join(configDir, "token.json") } // ensureConfigDir creates the config directory if it doesn't exist @@ -54,8 +37,7 @@ func LoadConfig() (*Config, error) { data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { - // symphonyclient integration: symphony → sym command - return nil, fmt.Errorf("configuration not found. Run 'sym config' to set up") + return nil, fmt.Errorf("configuration not found. Run 'sym init' to set up") } return nil, err } @@ -82,98 +64,8 @@ func SaveConfig(cfg *Config) error { return os.WriteFile(configPath, data, 0600) } -// LoadToken loads the access token from file -func LoadToken() (*Token, error) { - data, err := os.ReadFile(tokenPath) - if err != nil { - if os.IsNotExist(err) { - // symphonyclient integration: symphony → sym command - return nil, fmt.Errorf("not logged in. Run 'sym login' first") - } - return nil, err - } - - var token Token - if err := json.Unmarshal(data, &token); err != nil { - return nil, fmt.Errorf("invalid token file: %w", err) - } - - return &token, nil -} - -// SaveToken saves the access token to file -func SaveToken(token *Token) error { - if err := ensureConfigDir(); err != nil { - return err - } - - data, err := json.MarshalIndent(token, "", " ") - if err != nil { - return err - } - - return os.WriteFile(tokenPath, data, 0600) -} - -// DeleteToken removes the token file (logout) -func DeleteToken() error { - err := os.Remove(tokenPath) - if err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// IsLoggedIn checks if a valid token exists -func IsLoggedIn() bool { - _, err := LoadToken() - return err == nil -} // GetConfigPath returns the config file path func GetConfigPath() string { return configPath } - -// GetTokenPath returns the token file path -func GetTokenPath() string { - return tokenPath -} - -// GetAuthMode returns the authentication mode (defaults to "server") -func (c *Config) GetAuthMode() string { - if c.AuthMode == "" { - // symphonyclient integration: SYMPHONY → SYM environment variable - if mode := os.Getenv("SYM_AUTH_MODE"); mode != "" { - return mode - } - return "server" // default - } - return c.AuthMode -} - -// GetServerURL returns the auth server URL (with defaults) -func (c *Config) GetServerURL() string { - if c.ServerURL == "" { - // symphonyclient integration: SYMPHONY → SYM environment variable - if url := os.Getenv("SYM_SERVER_URL"); url != "" { - return url - } - // Default server URL (symphonyclient auth server - kept for compatibility) - return "https://symphony-server-98207.web.app" - } - return c.ServerURL -} - -// IsCustomOAuth returns true if using custom OAuth mode -func (c *Config) IsCustomOAuth() bool { - return c.GetAuthMode() == "custom" -} - -// GetGitHubHost returns the GitHub host (defaults to github.com for server mode) -func (c *Config) GetGitHubHost() string { - if c.GitHubHost == "" { - return "github.com" // default - } - return c.GitHubHost -} diff --git a/internal/envutil/env.go b/internal/envutil/env.go index feb8240..78b1f5e 100644 --- a/internal/envutil/env.go +++ b/internal/envutil/env.go @@ -89,9 +89,8 @@ func SaveKeyToEnvFile(envPath, key, value string) error { // If key not found, add it at the end if !keyFound { if len(lines) > 0 && lines[len(lines)-1] != "" { - lines = append(lines, "") // Add blank line before new section + lines = append(lines, "") // Add blank line before new key } - lines = append(lines, "# Policy configuration") lines = append(lines, key+"="+value) } diff --git a/internal/github/README.md b/internal/github/README.md deleted file mode 100644 index 5e67fe0..0000000 --- a/internal/github/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# github - -GitHub API 클라이언트를 제공합니다. - -OAuth 인증 URL 생성, 사용자 정보 조회, 리포지토리 정보 조회 등의 기능을 제공합니다. - -**사용자**: auth, cmd, server -**의존성**: 없음 diff --git a/internal/github/client.go b/internal/github/client.go deleted file mode 100644 index 90fb3b7..0000000 --- a/internal/github/client.go +++ /dev/null @@ -1,122 +0,0 @@ -package github - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -type Client struct { - BaseURL string - AccessToken string - HTTPClient *http.Client -} - -type User struct { - Login string `json:"login"` - ID int `json:"id"` - Name string `json:"name"` - Email string `json:"email"` -} - -type OAuthTokenResponse struct { - AccessToken string `json:"access_token"` -} - -// NewClient creates a new GitHub API client -func NewClient(host, accessToken string) *Client { - baseURL := fmt.Sprintf("https://%s/api/v3", host) - if host == "github.com" { - baseURL = "https://api.github.com" - } - - return &Client{ - BaseURL: baseURL, - AccessToken: accessToken, - HTTPClient: &http.Client{}, - } -} - -// GetCurrentUser fetches the authenticated user's information -func (c *Client) GetCurrentUser() (*User, error) { - req, err := http.NewRequest("GET", c.BaseURL+"/user", nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+c.AccessToken) - req.Header.Set("Accept", "application/vnd.github+json") - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body)) - } - - var user User - if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { - return nil, err - } - - return &user, nil -} - -// ExchangeCodeForToken exchanges an OAuth code for an access token -func ExchangeCodeForToken(host, clientID, clientSecret, code string) (string, error) { - tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", host) - - data := url.Values{} - data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) - data.Set("code", code) - - req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("token exchange failed: %s - %s", resp.Status, string(body)) - } - - var tokenResp OAuthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", err - } - - if tokenResp.AccessToken == "" { - return "", fmt.Errorf("no access token received") - } - - return tokenResp.AccessToken, nil -} - -// GetAuthURL returns the OAuth authorization URL -func GetAuthURL(host, clientID, redirectURI string) string { - authURL := fmt.Sprintf("https://%s/login/oauth/authorize", host) - params := url.Values{} - params.Set("client_id", clientID) - params.Set("redirect_uri", redirectURI) - params.Set("scope", "repo,read:user") - - return authURL + "?" + params.Encode() -} diff --git a/internal/policy/manager.go b/internal/policy/manager.go index 52777c8..7e86feb 100644 --- a/internal/policy/manager.go +++ b/internal/policy/manager.go @@ -6,10 +6,9 @@ import ( "os" "path/filepath" "github.com/DevSymphony/sym-cli/internal/git" - "github.com/DevSymphony/sym-cli/pkg/schema" // symphonyclient integration: use unified schema types + "github.com/DevSymphony/sym-cli/pkg/schema" ) -// symphonyclient integration: .github → .sym directory var defaultPolicyPath = ".sym/user-policy.json" // GetPolicyPath returns the configured or default policy file path @@ -38,7 +37,6 @@ func LoadPolicy(customPath string) (*schema.UserPolicy, error) { if err != nil { if os.IsNotExist(err) { // Return empty policy if file doesn't exist - // symphonyclient integration: use schema.UserRule return &schema.UserPolicy{ Version: "1.0.0", Rules: []schema.UserRule{}, diff --git a/internal/policy/templates.go b/internal/policy/templates.go index 4995bfc..074ae46 100644 --- a/internal/policy/templates.go +++ b/internal/policy/templates.go @@ -8,7 +8,7 @@ import ( "path" "strings" - "github.com/DevSymphony/sym-cli/pkg/schema" // symphonyclient integration + "github.com/DevSymphony/sym-cli/pkg/schema" ) //go:embed templates/*.json diff --git a/internal/roles/rbac.go b/internal/roles/rbac.go index e966d42..710a029 100644 --- a/internal/roles/rbac.go +++ b/internal/roles/rbac.go @@ -135,10 +135,18 @@ func ValidateFilePermissions(username string, files []string) (*ValidationResult return nil, fmt.Errorf("failed to get user role: %w", err) } - if userRole == "none" { + return ValidateFilePermissionsForRole(userRole, files) +} + +// ValidateFilePermissionsForRole validates if a role can modify the given files +// This is the local role-based version that takes a role name directly +// Returns ValidationResult with Allowed=true if all files are permitted, +// or Allowed=false with a list of denied files +func ValidateFilePermissionsForRole(role string, files []string) (*ValidationResult, error) { + if role == "" || role == "none" { return &ValidationResult{ Allowed: false, - DeniedFiles: files, // All files denied if user has no role + DeniedFiles: files, // All files denied if no role selected }, nil } @@ -158,7 +166,7 @@ func ValidateFilePermissions(username string, files []string) (*ValidationResult } // Get role configuration from policy - roleConfig, exists := userPolicy.RBAC.Roles[userRole] + roleConfig, exists := userPolicy.RBAC.Roles[role] if !exists { // Role not defined in policy, deny all return &ValidationResult{ diff --git a/internal/roles/roles.go b/internal/roles/roles.go index 298e5d6..b168dd3 100644 --- a/internal/roles/roles.go +++ b/internal/roles/roles.go @@ -5,21 +5,43 @@ import ( "fmt" "os" "path/filepath" - "github.com/DevSymphony/sym-cli/internal/git" + "sort" + + "github.com/DevSymphony/sym-cli/internal/envutil" ) // Roles represents a map of role names to lists of usernames // This allows dynamic role creation instead of hardcoded admin/developer/viewer type Roles map[string][]string -// GetRolesPath returns the path to the roles.json file in the current repo +// Environment variable key for current role +const currentRoleKey = "CURRENT_ROLE" + +// getSymDir returns the .sym directory path (current working directory based) +func getSymDir() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(cwd, ".sym"), nil +} + +// getEnvPath returns the path to .sym/.env file +func getEnvPath() (string, error) { + symDir, err := getSymDir() + if err != nil { + return "", err + } + return filepath.Join(symDir, ".env"), nil +} + +// GetRolesPath returns the path to the roles.json file in the current directory func GetRolesPath() (string, error) { - repoRoot, err := git.GetRepoRoot() + symDir, err := getSymDir() if err != nil { return "", err } - // symphonyclient integration: .github → .sym directory - return filepath.Join(repoRoot, ".sym", "roles.json"), nil + return filepath.Join(symDir, "roles.json"), nil } // LoadRoles loads the roles from the .sym/roles.json file @@ -32,7 +54,6 @@ func LoadRoles() (Roles, error) { data, err := os.ReadFile(rolesPath) if err != nil { if os.IsNotExist(err) { - // symphonyclient integration: symphony → sym command return nil, fmt.Errorf("roles.json not found. Run 'sym init' to create it") } return nil, err @@ -53,7 +74,7 @@ func SaveRoles(roles Roles) error { return err } - // symphonyclient integration: Ensure .sym directory exists + // Ensure .sym directory exists symDir := filepath.Dir(rolesPath) if err := os.MkdirAll(symDir, 0755); err != nil { return err @@ -102,3 +123,61 @@ func RolesExists() (bool, error) { } return false, err } + +// GetCurrentRole returns the currently selected role from .sym/.env (CURRENT_ROLE key) +// If no role is selected, returns empty string and nil error +func GetCurrentRole() (string, error) { + envPath, err := getEnvPath() + if err != nil { + return "", err + } + + role := envutil.LoadKeyFromEnvFile(envPath, currentRoleKey) + return role, nil +} + +// SetCurrentRole saves the selected role to .sym/.env (CURRENT_ROLE key) +func SetCurrentRole(role string) error { + envPath, err := getEnvPath() + if err != nil { + return err + } + + return envutil.SaveKeyToEnvFile(envPath, currentRoleKey, role) +} + +// CurrentRoleExists checks if CURRENT_ROLE is set in .sym/.env +func CurrentRoleExists() (bool, error) { + role, err := GetCurrentRole() + if err != nil { + return false, err + } + return role != "", nil +} + +// GetAvailableRoles returns all role names defined in roles.json +// Returns roles sorted alphabetically for consistent ordering +func GetAvailableRoles() ([]string, error) { + roles, err := LoadRoles() + if err != nil { + return nil, err + } + + roleNames := make([]string, 0, len(roles)) + for roleName := range roles { + roleNames = append(roleNames, roleName) + } + sort.Strings(roleNames) + return roleNames, nil +} + +// IsValidRole checks if a role name exists in roles.json +func IsValidRole(role string) (bool, error) { + roles, err := LoadRoles() + if err != nil { + return false, err + } + + _, exists := roles[role] + return exists, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index b6947b3..1f2d03a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,15 +13,12 @@ import ( "strings" "time" - "github.com/DevSymphony/sym-cli/internal/config" "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/envutil" - "github.com/DevSymphony/sym-cli/internal/git" - "github.com/DevSymphony/sym-cli/internal/github" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" - "github.com/DevSymphony/sym-cli/pkg/schema" // symphonyclient integration + "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/pkg/browser" ) @@ -30,31 +27,13 @@ import ( var staticFiles embed.FS type Server struct { - port int - cfg *config.Config - token *config.Token - githubClient *github.Client + port int } // NewServer creates a new dashboard server func NewServer(port int) (*Server, error) { - cfg, err := config.LoadConfig() - if err != nil { - return nil, err - } - - token, err := config.LoadToken() - if err != nil { - return nil, err - } - - githubClient := github.NewClient(cfg.GetGitHubHost(), token.AccessToken) - return &Server{ - port: port, - cfg: cfg, - token: token, - githubClient: githubClient, + port: port, }, nil } @@ -64,8 +43,10 @@ func (s *Server) Start() error { // API endpoints mux.HandleFunc("/api/me", s.handleGetMe) + mux.HandleFunc("/api/select-role", s.handleSelectRole) + mux.HandleFunc("/api/available-roles", s.handleAvailableRoles) mux.HandleFunc("/api/roles", s.handleRoles) - mux.HandleFunc("/api/repo-info", s.handleRepoInfo) + mux.HandleFunc("/api/project-info", s.handleProjectInfo) // Policy API endpoints mux.HandleFunc("/api/policy", s.handlePolicy) @@ -115,48 +96,25 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { }) } -func (s *Server) hasPermission(username, permission string) (bool, error) { +// hasPermissionForRole checks if a role has a specific permission +func (s *Server) hasPermissionForRole(role, permission string) (bool, error) { // Load policy to check RBAC permissions - policyData, err := policy.LoadPolicy(s.cfg.PolicyPath) - if err != nil { - return false, fmt.Errorf("failed to load policy: %w", err) + policyPath := envutil.GetPolicyPath() + if policyPath == "" { + policyPath = ".sym/user-policy.json" } - return s.hasPermissionWithPolicy(username, permission, policyData) -} - -func (s *Server) hasPermissionWithPolicy(username, permission string, policyData *schema.UserPolicy) (bool, error) { - // Load user's role from roles.json - userRole, err := roles.GetUserRole(username) + policyData, err := policy.LoadPolicy(policyPath) if err != nil { - return false, fmt.Errorf("failed to get user role: %w", err) + return false, fmt.Errorf("failed to load policy: %w", err) } - return s.checkPermissionForRole(userRole, permission, policyData) + return s.checkPermissionForRole(role, permission, policyData) } -func (s *Server) hasPermissionWithRoles(username, permission string, rolesData roles.Roles) (bool, error) { - // Find user's role from the given roles - userRole := "none" - for roleName, usernames := range rolesData { - for _, user := range usernames { - if user == username { - userRole = roleName - break - } - } - if userRole != "none" { - break - } - } - - // Load policy to check RBAC permissions - policyData, err := policy.LoadPolicy(s.cfg.PolicyPath) - if err != nil { - return false, fmt.Errorf("failed to load policy: %w", err) - } - - return s.checkPermissionForRole(userRole, permission, policyData) +// hasPermissionForRoleWithPolicy checks permission using provided policy data +func (s *Server) hasPermissionForRoleWithPolicy(role, permission string, policyData *schema.UserPolicy) (bool, error) { + return s.checkPermissionForRole(role, permission, policyData) } // checkPermissionForRole checks if a role has a specific permission in the policy @@ -194,36 +152,26 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { return } - user, err := s.githubClient.GetCurrentUser() + // Get current role from local file + role, err := roles.GetCurrentRole() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current role: %v", err), http.StatusInternalServerError) return } - // Get user role - role, err := roles.GetUserRole(user.Login) + // Get user permissions based on current role + canEditPolicy, err := s.hasPermissionForRole(role, "editPolicy") if err != nil { - http.Error(w, fmt.Sprintf("Failed to get role: %v", err), http.StatusInternalServerError) - return - } - - // Get user permissions - canEditPolicy, err := s.hasPermission(user.Login, "editPolicy") - if err != nil { - // If there's an error checking permissions, default to false canEditPolicy = false } - canEditRoles, err := s.hasPermission(user.Login, "editRoles") + canEditRoles, err := s.hasPermissionForRole(role, "editRoles") if err != nil { - // If there's an error checking permissions, default to false canEditRoles = false } response := map[string]interface{}{ - "username": user.Login, - "name": user.Name, - "role": role, + "role": role, "permissions": map[string]bool{ "canEditPolicy": canEditPolicy, "canEditRoles": canEditRoles, @@ -234,6 +182,70 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) } +// handleSelectRole handles POST request to select a role +func (s *Server) handleSelectRole(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + // Validate that the role exists + valid, err := roles.IsValidRole(req.Role) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to validate role: %v", err), http.StatusInternalServerError) + return + } + if !valid { + http.Error(w, fmt.Sprintf("Invalid role: %s", req.Role), http.StatusBadRequest) + return + } + + // Save the selected role + if err := roles.SetCurrentRole(req.Role); err != nil { + http.Error(w, fmt.Sprintf("Failed to save role: %v", err), http.StatusInternalServerError) + return + } + + // Get permissions for the new role + canEditPolicy, _ := s.hasPermissionForRole(req.Role, "editPolicy") + canEditRoles, _ := s.hasPermissionForRole(req.Role, "editRoles") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "role": req.Role, + "permissions": map[string]bool{ + "canEditPolicy": canEditPolicy, + "canEditRoles": canEditRoles, + }, + }) +} + +// handleAvailableRoles returns the list of available roles +func (s *Server) handleAvailableRoles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + availableRoles, err := roles.GetAvailableRoles() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get available roles: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(availableRoles) +} + // handleRoles handles GET and POST requests for roles func (s *Server) handleRoles(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -260,36 +272,35 @@ func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) { // handleUpdateRoles updates the roles (requires editRoles permission) func (s *Server) handleUpdateRoles(w http.ResponseWriter, r *http.Request) { - // Get current user - user, err := s.githubClient.GetCurrentUser() + // Get current role + currentRole, err := roles.GetCurrentRole() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current role: %v", err), http.StatusInternalServerError) return } - // Parse request body first (need to check permission against the NEW roles) - body, err := io.ReadAll(r.Body) + // Check if current role has permission to edit roles + canEdit, err := s.hasPermissionForRole(currentRole, "editRoles") if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) + http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) return } - var newRoles roles.Roles - if err := json.Unmarshal(body, &newRoles); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) + if !canEdit { + http.Error(w, "Forbidden: You don't have permission to update roles", http.StatusForbidden) return } - // Check if user has permission to edit roles using the NEW roles - // (because the user's role might have changed) - canEdit, err := s.hasPermissionWithRoles(user.Login, "editRoles", newRoles) + // Parse request body + body, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) + http.Error(w, "Failed to read request body", http.StatusBadRequest) return } - if !canEdit { - http.Error(w, "Forbidden: You don't have permission to update roles", http.StatusForbidden) + var newRoles roles.Roles + if err := json.Unmarshal(body, &newRoles); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) return } @@ -306,21 +317,22 @@ func (s *Server) handleUpdateRoles(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleRepoInfo(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleProjectInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - owner, repo, err := git.GetRepoInfo() + // Get current working directory name as project name + cwd, err := os.Getwd() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get repo info: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current directory: %v", err), http.StatusInternalServerError) return } + projectName := filepath.Base(cwd) response := map[string]string{ - "owner": owner, - "repo": repo, + "project": projectName, } w.Header().Set("Content-Type", "application/json") @@ -360,10 +372,10 @@ func (s *Server) handleGetPolicy(w http.ResponseWriter, r *http.Request) { // handleSavePolicy saves the policy (requires editPolicy permission) func (s *Server) handleSavePolicy(w http.ResponseWriter, r *http.Request) { - // Get current user - user, err := s.githubClient.GetCurrentUser() + // Get current role + currentRole, err := roles.GetCurrentRole() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current role: %v", err), http.StatusInternalServerError) return } @@ -380,9 +392,8 @@ func (s *Server) handleSavePolicy(w http.ResponseWriter, r *http.Request) { return } - // Check if user has permission to edit policy using the NEW policy - // (because the user's role might have changed and the new role might only exist in the new policy) - canEdit, err := s.hasPermissionWithPolicy(user.Login, "editPolicy", &newPolicy) + // Check if current role has permission to edit policy using the NEW policy + canEdit, err := s.hasPermissionForRoleWithPolicy(currentRole, "editPolicy", &newPolicy) if err != nil { http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) return @@ -447,15 +458,15 @@ func (s *Server) handlePolicyPath(w http.ResponseWriter, r *http.Request) { // handleSetPolicyPath sets the policy path (requires editPolicy permission) func (s *Server) handleSetPolicyPath(w http.ResponseWriter, r *http.Request) { - // Get current user - user, err := s.githubClient.GetCurrentUser() + // Get current role + currentRole, err := roles.GetCurrentRole() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current role: %v", err), http.StatusInternalServerError) return } - // Check if user has permission to edit policy - canEdit, err := s.hasPermission(user.Login, "editPolicy") + // Check if current role has permission to edit policy + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") if err != nil { http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) return @@ -628,15 +639,15 @@ func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { return } - // Get current user - user, err := s.githubClient.GetCurrentUser() + // Get current role + currentRole, err := roles.GetCurrentRole() if err != nil { - http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to get current role: %v", err), http.StatusInternalServerError) return } - // Check if user has permission to edit policy - canEdit, err := s.hasPermission(user.Login, "editPolicy") + // Check if current role has permission to edit policy + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") if err != nil { http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) return diff --git a/internal/server/static/icons/edit.svg b/internal/server/static/icons/edit.svg new file mode 100644 index 0000000..ee2a66d --- /dev/null +++ b/internal/server/static/icons/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/server/static/index-roles.html b/internal/server/static/index-roles.html deleted file mode 100644 index 1be1ac5..0000000 --- a/internal/server/static/index-roles.html +++ /dev/null @@ -1,502 +0,0 @@ - - - - - - Symphony - Role Management Dashboard - - - -
-
-

🎵 Symphony Role Management

-

Loading repository info...

- -
- -
- -
-

역할 관리

- -
- Loading roles... -
-
-
- - - - diff --git a/internal/server/static/index.html b/internal/server/static/index.html index cd8a372..3b62652 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -23,12 +23,9 @@

코딩 컨벤션 및 정책 관리 도구

-
-
-
-
Loading...
- ... -
+
+ Loading... +
📁 Loading...
@@ -51,20 +48,17 @@

+ +

- Users - 사용자 및 역할 관리 + Role + 역할 선택

- 사용자 목록 + 현재 역할
-
- - -
-
+

대시보드에서 사용할 역할을 선택하세요. 선택한 역할에 따라 편집 권한이 결정됩니다.

+
diff --git a/internal/server/static/policy-editor.js b/internal/server/static/policy-editor.js index b06b9d5..8c5a46a 100644 --- a/internal/server/static/policy-editor.js +++ b/internal/server/static/policy-editor.js @@ -9,7 +9,7 @@ let appState = { defaults: {}, rules: [] }, - users: [], + availableRoles: [], templates: [], isDirty: false, originalRules: null, // Store original rules to detect changes @@ -24,6 +24,59 @@ let appState = { } }; +// ==================== Role Color System ==================== +// Hash-based color generation for consistent role colors +function getRoleColor(roleName) { + // Preset colors for default roles + const presetColors = { + 'admin': { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-300', bgHex: '#f3e8ff' }, + 'developer': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300', bgHex: '#dbeafe' }, + 'viewer': { bg: 'bg-gray-100', text: 'text-gray-700', border: 'border-gray-300', bgHex: '#f3f4f6' } + }; + + if (presetColors[roleName]) return presetColors[roleName]; + + // Dynamic colors for custom roles - generate consistent color from role name hash + const dynamicColors = [ + { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300', bgHex: '#dcfce7' }, + { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300', bgHex: '#fef9c3' }, + { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300', bgHex: '#fee2e2' }, + { bg: 'bg-pink-100', text: 'text-pink-700', border: 'border-pink-300', bgHex: '#fce7f3' }, + { bg: 'bg-indigo-100', text: 'text-indigo-700', border: 'border-indigo-300', bgHex: '#e0e7ff' }, + { bg: 'bg-teal-100', text: 'text-teal-700', border: 'border-teal-300', bgHex: '#ccfbf1' }, + { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300', bgHex: '#ffedd5' }, + ]; + + // Calculate hash from role name for consistent color assignment + const hash = roleName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return dynamicColors[hash % dynamicColors.length]; +} + +// Get permission badges based on permissions +function getPermissionBadges(permissions) { + const badges = []; + + if (permissions?.canEditPolicy) { + badges.push({ text: '정책 편집', icon: '/icons/edit.svg', bg: 'bg-green-100', textColor: 'text-green-700' }); + } + if (permissions?.canEditRoles) { + badges.push({ text: '역할 편집', icon: '/icons/users.svg', bg: 'bg-blue-100', textColor: 'text-blue-700' }); + } + + if (badges.length === 0) { + badges.push({ text: '읽기 전용', icon: '/icons/lock.svg', bg: 'bg-gray-100', textColor: 'text-gray-600' }); + } + + return badges; +} + +// Render permission badge HTML with icon +function renderPermissionBadge(badge) { + return ` + ${badge.text} + `; +} + // ==================== API Calls ==================== const API = { async getMe() { @@ -31,8 +84,23 @@ const API = { return await res.json(); }, - async getRepoInfo() { - const res = await fetch('/api/repo-info'); + async getProjectInfo() { + const res = await fetch('/api/project-info'); + return await res.json(); + }, + + async getAvailableRoles() { + const res = await fetch('/api/available-roles'); + return await res.json(); + }, + + async selectRole(role) { + const res = await fetch('/api/select-role', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }) + }); + if (!res.ok) throw new Error(await res.text()); return await res.json(); }, @@ -313,165 +381,78 @@ function addLanguagesToDefaults(languages) { return addedCount; } -// ==================== User Management ==================== -function renderUsers() { - const container = document.getElementById('users-container'); - const searchTerm = document.getElementById('user-search').value.toLowerCase(); +// ==================== Role Selection ==================== +function renderRoleSelection() { + const container = document.getElementById('role-selection-container'); + if (!container) return; - const filteredUsers = appState.users.filter(u => - u.username.toLowerCase().includes(searchTerm) - ); + const availableRoles = appState.availableRoles || []; + const currentRole = appState.currentUser?.role || ''; - if (filteredUsers.length === 0) { - container.innerHTML = '
사용자가 없습니다
'; + if (availableRoles.length === 0) { + container.innerHTML = '
사용 가능한 역할이 없습니다
'; return; } - container.innerHTML = filteredUsers.map(user => { - // Get all available roles from RBAC + default roles - const availableRoles = getAvailableRoles(); - const roleOptions = availableRoles.map(role => - `` - ).join(''); + container.innerHTML = availableRoles.map(role => { + const isCurrentRole = role === currentRole; + const roleConfig = appState.policy.rbac?.roles?.[role] || {}; + const roleColor = getRoleColor(role); + const permBadges = getPermissionBadges({ canEditPolicy: roleConfig.canEditPolicy, canEditRoles: roleConfig.canEditRoles }); + const badgesHtml = permBadges.map(renderPermissionBadge).join(''); return ` -
-
-
- ${user.username.charAt(0).toUpperCase()} -
-
-
${user.username}
-
GitHub ID
-
+ -
-
+
${badgesHtml}
+ `; }).join(''); - // Attach event listeners - document.querySelectorAll('.user-role-select').forEach(select => { - select.addEventListener('change', handleUserRoleChange); - }); - - document.querySelectorAll('.delete-user-btn').forEach(btn => { - btn.addEventListener('click', handleDeleteUser); - }); - - // Apply permissions to dynamically rendered elements - if (appState.currentUser?.permissions) { - applyPermissions(); - } -} - -async function handleUserRoleChange(e) { - const username = e.target.dataset.username; - const newRole = e.target.value; - - const user = appState.users.find(u => u.username === username); - if (user) { - user.role = newRole; - - // If this is the current user, update the badge immediately - if (username === appState.currentUser.username) { - appState.currentUser.role = newRole; - updateUserRoleBadge(newRole); - } - - await syncUsersToRoles(); - showToast(`${username}의 역할이 ${newRole}로 변경되었습니다`); - markDirty(); - } -} - -async function handleDeleteUser(e) { - const username = e.target.dataset.username; - - // Safety check: Ensure at least one policy editor remains - const userToDelete = appState.users.find(u => u.username === username); - if (!userToDelete) return; - - // Check if this user has policy edit permission - const userRole = appState.policy.rbac?.roles?.[userToDelete.role]; - if (userRole && userRole.canEditPolicy) { - // Count how many users with policy edit permission exist - const policyEditors = appState.users.filter(u => { - const role = appState.policy.rbac?.roles?.[u.role]; - return role && role.canEditPolicy; + // Attach click handlers + container.querySelectorAll('.role-select-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const selectedRole = e.currentTarget.dataset.role; + if (selectedRole !== currentRole) { + await selectRole(selectedRole); + } }); - - // If this is the last policy editor, prevent deletion - if (policyEditors.length === 1) { - alert(`❌ ${username}은(는) 유일한 정책 편집자입니다.\n\n최소 한 명의 정책 편집자는 남아있어야 합니다.\n먼저 다른 사용자에게 정책 편집 권한을 부여한 후 삭제해주세요.`); - return; - } - } - - if (!confirm(`${username}을(를) 삭제하시겠습니까?`)) return; - - appState.users = appState.users.filter(u => u.username !== username); - await syncUsersToRoles(); - renderUsers(); - showToast(`${username}이(가) 삭제되었습니다`); - markDirty(); + }); } -async function handleAddUser() { - const username = prompt('새 사용자의 GitHub ID를 입력하세요:'); - if (!username || !username.trim()) return; - - const trimmedUsername = username.trim(); - - if (appState.users.some(u => u.username === trimmedUsername)) { - showToast('이미 존재하는 사용자입니다', 'error'); - return; - } +async function selectRole(role) { + try { + const result = await API.selectRole(role); - appState.users.push({ username: trimmedUsername, role: 'viewer' }); - await syncUsersToRoles(); - renderUsers(); - showToast(`${trimmedUsername}이(가) 추가되었습니다`); - markDirty(); -} + // Update current user state + appState.currentUser.role = result.role; + appState.currentUser.permissions = result.permissions; -async function syncUsersToRoles() { - const roles = { admin: [], developer: [], viewer: [] }; + // Update header UI + updateUserRoleBadge(result.role); + updatePermissionBadges(result.permissions); - appState.users.forEach(user => { - if (roles[user.role]) { - roles[user.role].push(user.username); - } - }); + // Re-render all UI sections first + renderRoleSelection(); + renderRBAC(); + renderRules(); + renderLanguageTags(); - // Update policy RBAC if needed - // (This function updates appState.users based on roles.json) -} + // Apply permissions AFTER all renders so new elements get proper state + applyPermissions(); -async function loadUsersFromRoles() { - try { - const rolesData = await API.getRoles(); - const users = []; - - // Load all roles dynamically, not just admin/developer/viewer - Object.entries(rolesData).forEach(([role, usernames]) => { - if (Array.isArray(usernames)) { - usernames.forEach(username => { - users.push({ username, role }); - }); - } - }); + showToast(`역할이 '${result.role}'(으)로 변경되었습니다`); - appState.users = users; - renderUsers(); } catch (error) { - console.error('Failed to load users:', error); - showToast('사용자 목록을 불러오는데 실패했습니다', 'error'); + console.error('Failed to select role:', error); + showToast('역할 변경에 실패했습니다: ' + error.message, 'error'); } } @@ -782,19 +763,18 @@ function handleRBACUpdate(e) { appState.policy.rbac.roles[newRoleName] = appState.policy.rbac.roles[oldRoleName]; delete appState.policy.rbac.roles[oldRoleName]; - // Update all users with this role - appState.users.forEach(user => { - if (user.role === oldRoleName) { - user.role = newRoleName; - } - }); - roleCard.dataset.roleName = newRoleName; // Update all related elements roleCard.querySelectorAll('[data-role-name]').forEach(el => { el.dataset.roleName = newRoleName; }); - renderUsers(); // Update user role dropdowns when role name changes + + // Update available roles list + const index = appState.availableRoles.indexOf(oldRoleName); + if (index !== -1) { + appState.availableRoles[index] = newRoleName; + } + renderRoleSelection(); showToast(`역할 이름이 "${oldRoleName}"에서 "${newRoleName}"(으)로 변경되었습니다`); } } else { @@ -820,19 +800,24 @@ function handleRBACUpdate(e) { function handleDeleteRBACRole(e) { const roleName = e.target.dataset.roleName; - // Check if any users have this role - const usersWithRole = appState.users.filter(u => u.role === roleName); - if (usersWithRole.length > 0) { - const usernames = usersWithRole.map(u => u.username).join(', '); - alert(`❌ 이 역할을 사용 중인 사용자가 있어 삭제할 수 없습니다.\n\n사용자: ${usernames}\n\n먼저 해당 사용자들의 역할을 변경한 후 삭제해주세요.`); + // Check if this is the currently selected role + if (roleName === appState.currentUser?.role) { + alert(`❌ 현재 선택된 역할은 삭제할 수 없습니다.\n\n다른 역할을 선택한 후 삭제해주세요.`); return; } if (!confirm(`${roleName} 역할을 삭제하시겠습니까?`)) return; delete appState.policy.rbac.roles[roleName]; + + // Update available roles list + const index = appState.availableRoles.indexOf(roleName); + if (index !== -1) { + appState.availableRoles.splice(index, 1); + } + renderRBAC(); - renderUsers(); // Update user role dropdowns + renderRoleSelection(); showToast(`${roleName} 역할이 삭제되었습니다`); markDirty(); } @@ -1019,12 +1004,6 @@ async function savePolicy() { } try { - // Sync current user role first (in case it was changed in the UI) - const currentUserInList = appState.users.find(u => u.username === appState.currentUser.username); - if (currentUserInList) { - appState.currentUser.role = currentUserInList.role; - } - // Validate: At least one role must have canEditPolicy permission if (appState.policy.rbac && appState.policy.rbac.roles) { const hasAtLeastOneEditor = Object.values(appState.policy.rbac.roles).some(role => role.canEditPolicy); @@ -1034,25 +1013,16 @@ async function savePolicy() { } } - // Check if current user is losing policy edit privileges - const currentUser = appState.users.find(u => u.username === appState.currentUser.username); + // Check if current role is losing policy edit privileges const currentRole = appState.currentUser.role; - const newRole = currentUser?.role; - - if (currentRole && newRole && currentRole !== newRole) { - // Check if current user is losing policy edit permission - const currentRoleData = appState.policy.rbac?.roles?.[currentRole]; - const newRoleData = appState.policy.rbac?.roles?.[newRole]; - - if (currentRoleData?.canEditPolicy && !newRoleData?.canEditPolicy) { - const confirmLoss = confirm( - `⚠️ 경고: 자신의 정책 편집 권한을 제거하려고 합니다.\n\n` + - `현재 역할: ${currentRole}\n` + - `변경될 역할: ${newRole}\n\n` + - `정책 편집 권한을 잃으면 정책을 수정할 수 없게 됩니다.\n계속하시겠습니까?` - ); - if (!confirmLoss) return; - } + const currentRoleData = appState.policy.rbac?.roles?.[currentRole]; + + if (currentRoleData && !currentRoleData.canEditPolicy) { + const confirmLoss = confirm( + `⚠️ 경고: 현재 역할(${currentRole})에서 정책 편집 권한을 제거하려고 합니다.\n\n` + + `정책 편집 권한을 잃으면 정책을 수정할 수 없게 됩니다.\n계속하시겠습니까?` + ); + if (!confirmLoss) return; } // Update defaults from UI @@ -1062,28 +1032,11 @@ async function savePolicy() { appState.policy.defaults.severity = document.getElementById('defaults-severity').value || undefined; appState.policy.defaults.defaultLanguage = document.getElementById('defaults-default-language').value || undefined; - // Collect roles from users - const roles = {}; - appState.users.forEach(user => { - if (!roles[user.role]) { - roles[user.role] = []; - } - roles[user.role].push(user.username); - }); - - // Ensure default roles exist even if empty - if (!roles.admin) roles.admin = []; - if (!roles.developer) roles.developer = []; - if (!roles.viewer) roles.viewer = []; - - console.log('[DEBUG] Saving roles:', roles); - console.log('[DEBUG] Current user role:', appState.currentUser.role); + console.log('[DEBUG] Current role:', appState.currentUser.role); console.log('[DEBUG] Policy RBAC roles:', Object.keys(appState.policy.rbac?.roles || {})); - // IMPORTANT: Save policy FIRST (so RBAC roles are defined) - // Then save roles (so permission checks can find the role in RBAC) + // Save policy await API.savePolicy(appState.policy); - await API.saveRoles(roles); // Save policy path if changed const newPath = document.getElementById('policy-path-input').value.trim(); @@ -1181,7 +1134,6 @@ async function loadPolicy() { console.log('Policy loaded. Rules count:', appState.policy.rules.length); console.log('Original rules stored:', appState.originalRules.substring(0, 100) + '...'); console.log('Original RBAC stored:', appState.originalRBAC.substring(0, 100) + '...'); - await loadUsersFromRoles(); renderAll(); showToast('정책을 불러왔습니다'); } catch (error) { @@ -1247,23 +1199,25 @@ async function saveSettings() { // Update user role badge display function updateUserRoleBadge(role) { const roleBadge = document.getElementById('user-role-badge'); + const roleColor = getRoleColor(role); roleBadge.textContent = role; - roleBadge.className = `badge text-xs px-2 py-1 rounded-full ${ - role === 'admin' ? 'bg-yellow-400 text-slate-900' : - role === 'developer' ? 'bg-blue-500 text-white' : - 'bg-gray-400 text-white' - }`; + roleBadge.className = `font-semibold text-sm px-3 py-1 rounded-full ${roleColor.bg} ${roleColor.text}`; +} + +function updatePermissionBadges(permissions) { + const container = document.getElementById('permission-badges'); + if (!container) return; + const badges = getPermissionBadges(permissions); + container.innerHTML = badges.map(renderPermissionBadge).join(''); } // Update user info display in header function updateUserInfo() { - const currentUser = appState.users.find(u => u.username === appState.currentUser.username); - if (currentUser) { - // Update appState.currentUser.role - appState.currentUser.role = currentUser.role; - + // In role-based mode, just update the display with current role + const currentRole = appState.currentUser.role; + if (currentRole) { // Update badge display - updateUserRoleBadge(currentUser.role); + updateUserRoleBadge(currentRole); } } @@ -1282,8 +1236,8 @@ function renderAll() { // Rules renderRules(); - // Users (to update role dropdowns if RBAC roles changed) - renderUsers(); + // Role selection + renderRoleSelection(); } // ==================== Permission-Based UI ==================== @@ -1294,8 +1248,57 @@ function applyPermissions() { console.log('[Permissions] canEditPolicy:', canEditPolicy, 'canEditRoles:', canEditRoles); - // When canEditPolicy = false: Read-only policy UI - if (!canEditPolicy) { + if (canEditPolicy) { + // Show save buttons + document.getElementById('save-btn')?.classList.remove('hidden'); + document.getElementById('floating-save-btn')?.classList.remove('hidden'); + + // Show template button + document.getElementById('template-btn')?.classList.remove('hidden'); + + // Enable RBAC inputs and show add/delete buttons + document.querySelectorAll('.role-name-input, .role-allowWrite-input, .role-denyWrite-input').forEach(el => { + el.disabled = false; + el.classList.remove('bg-gray-200', 'cursor-not-allowed'); + }); + document.querySelectorAll('.role-canEditPolicy-input, .role-canEditRoles-input').forEach(el => { + el.disabled = false; + el.classList.remove('cursor-not-allowed'); + }); + document.querySelectorAll('.delete-rbac-role-btn').forEach(el => el.classList.remove('hidden')); + document.getElementById('add-role-btn')?.classList.remove('hidden'); + + // Enable defaults inputs + const langInput = document.getElementById('defaults-language-input'); + if (langInput) { + langInput.disabled = false; + langInput.classList.remove('bg-gray-200', 'cursor-not-allowed'); + } + document.getElementById('add-language-btn')?.classList.remove('hidden'); + const defaultLang = document.getElementById('defaults-default-language'); + if (defaultLang) { + defaultLang.disabled = false; + defaultLang.classList.remove('bg-gray-200', 'cursor-not-allowed'); + } + const severity = document.getElementById('defaults-severity'); + if (severity) { + severity.disabled = false; + severity.classList.remove('bg-gray-200', 'cursor-not-allowed'); + } + // Show remove buttons on language tags + document.querySelectorAll('.remove-language-btn').forEach(el => el.classList.remove('hidden')); + + // Show rule add/edit/delete buttons + document.getElementById('add-rule-btn')?.classList.remove('hidden'); + document.getElementById('add-rule-btn-bottom')?.classList.remove('hidden'); + document.querySelectorAll('.delete-rule-btn').forEach(el => el.classList.remove('hidden')); + + // Enable rule inputs + document.querySelectorAll('.say-input, .category-select, .language-select, .example-input').forEach(el => { + el.disabled = false; + el.classList.remove('bg-gray-200', 'cursor-not-allowed'); + }); + } else { // Hide save buttons document.getElementById('save-btn')?.classList.add('hidden'); document.getElementById('floating-save-btn')?.classList.add('hidden'); @@ -1316,13 +1319,22 @@ function applyPermissions() { document.getElementById('add-role-btn')?.classList.add('hidden'); // Disable defaults inputs - document.getElementById('defaults-language-input').disabled = true; - document.getElementById('defaults-language-input').classList.add('bg-gray-200', 'cursor-not-allowed'); + const langInput = document.getElementById('defaults-language-input'); + if (langInput) { + langInput.disabled = true; + langInput.classList.add('bg-gray-200', 'cursor-not-allowed'); + } document.getElementById('add-language-btn')?.classList.add('hidden'); - document.getElementById('defaults-default-language').disabled = true; - document.getElementById('defaults-default-language').classList.add('bg-gray-200', 'cursor-not-allowed'); - document.getElementById('defaults-severity').disabled = true; - document.getElementById('defaults-severity').classList.add('bg-gray-200', 'cursor-not-allowed'); + const defaultLang = document.getElementById('defaults-default-language'); + if (defaultLang) { + defaultLang.disabled = true; + defaultLang.classList.add('bg-gray-200', 'cursor-not-allowed'); + } + const severity = document.getElementById('defaults-severity'); + if (severity) { + severity.disabled = true; + severity.classList.add('bg-gray-200', 'cursor-not-allowed'); + } // Hide remove buttons on language tags document.querySelectorAll('.remove-language-btn').forEach(el => el.classList.add('hidden')); @@ -1338,61 +1350,29 @@ function applyPermissions() { }); } - // When canEditRoles = false: Read-only user management - if (!canEditRoles) { - // Hide "Add User" button - document.getElementById('add-user-btn')?.classList.add('hidden'); - - // Hide user delete buttons - document.querySelectorAll('.delete-user-btn').forEach(el => el.classList.add('hidden')); - - // Disable role dropdown - document.querySelectorAll('.user-role-select').forEach(el => { - el.disabled = true; - el.classList.add('bg-gray-200', 'cursor-not-allowed'); - }); - } - - // Add read-only badge if any permission is missing - if (!canEditPolicy || !canEditRoles) { - const roleBadgeContainer = document.getElementById('user-role-badge').parentElement; - if (!document.getElementById('readonly-badge')) { - const readonlyBadge = document.createElement('span'); - readonlyBadge.id = 'readonly-badge'; - readonlyBadge.className = 'badge text-xs px-2 py-1 rounded-full bg-gray-500 text-white flex items-center gap-1'; - readonlyBadge.innerHTML = ` - Lock - 읽기 전용 - `; - readonlyBadge.title = !canEditPolicy && !canEditRoles ? '정책과 역할 모두 읽기 전용입니다' : - !canEditPolicy ? '정책이 읽기 전용입니다' : - '역할이 읽기 전용입니다'; - roleBadgeContainer.appendChild(readonlyBadge); - } - } + // Clean up deprecated UI elements + const existingReadonlyBadge = document.getElementById('readonly-badge'); + if (existingReadonlyBadge) existingReadonlyBadge.remove(); } // ==================== Initialize ==================== async function init() { try { - // Load current user + // Load current user and role appState.currentUser = await API.getMe(); - document.getElementById('user-name').textContent = appState.currentUser.username; - document.getElementById('user-avatar').textContent = appState.currentUser.username.charAt(0).toUpperCase(); - - const roleBadge = document.getElementById('user-role-badge'); - roleBadge.textContent = appState.currentUser.role; - roleBadge.className = `badge text-xs px-2 py-1 rounded-full ${ - appState.currentUser.role === 'admin' ? 'bg-yellow-400 text-slate-900' : - appState.currentUser.role === 'developer' ? 'bg-blue-500 text-white' : - 'bg-gray-400 text-white' - }`; - - // Load repo info - const repoInfo = await API.getRepoInfo(); - document.getElementById('repo-info').textContent = `📁 ${repoInfo.owner}/${repoInfo.repo}`; - - // Load policy and users + + // Display current role in header + updateUserRoleBadge(appState.currentUser.role || '역할 미선택'); + updatePermissionBadges(appState.currentUser.permissions); + + // Load project info + const projectInfo = await API.getProjectInfo(); + document.getElementById('repo-info').textContent = `📁 ${projectInfo.project}`; + + // Load available roles for selection + appState.availableRoles = await API.getAvailableRoles(); + + // Load policy await loadPolicy(); await loadSettings(); await loadTemplates(); @@ -1428,10 +1408,6 @@ document.addEventListener('DOMContentLoaded', () => { showModal('template-modal'); }); - // User management - document.getElementById('add-user-btn').addEventListener('click', handleAddUser); - document.getElementById('user-search').addEventListener('input', () => renderUsers()); - // Rules management document.getElementById('add-rule-btn').addEventListener('click', handleAddRule); document.getElementById('add-rule-btn-bottom').addEventListener('click', handleAddRule); diff --git a/internal/server/static/styles/output.css b/internal/server/static/styles/output.css index 843cdd6..168431f 100644 --- a/internal/server/static/styles/output.css +++ b/internal/server/static/styles/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.fixed{position:fixed}.inset-0{inset:0}.bottom-8{bottom:2rem}.right-8{right:2rem}.z-50{z-index:50}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-ml-2{margin-left:-.5rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.max-h-96{max-height:24rem}.min-h-\[32px\]{min-height:32px}.w-10{width:2.5rem}.w-16{width:4rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-blue-400{--tw-gradient-from:#60a5fa var(--tw-gradient-from-position);--tw-gradient-to:rgba(96,165,250,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-purple-500{--tw-gradient-to:#a855f7 var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-none{line-height:1}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2,.ring-4{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-yellow-400{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@font-face{font-family:Pretendard;font-weight:400;font-style:normal;src:url(/fonts/Pretendard-Regular.woff2) format("woff2");font-display:swap}@font-face{font-family:Pretendard;font-weight:500;font-style:normal;src:url(/fonts/Pretendard-Medium.woff2) format("woff2");font-display:swap}@font-face{font-family:Pretendard;font-weight:700;font-style:normal;src:url(/fonts/Pretendard-Bold.woff2) format("woff2");font-display:swap}body,html{font-family:Pretendard,-apple-system,BlinkMacSystemFont,system-ui,Roboto,Helvetica Neue,Segoe UI,Apple SD Gothic Neo,Noto Sans KR,Malgun Gothic,sans-serif}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#e2e8f0}::-webkit-scrollbar-thumb{background:#94a3b8;border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#64748b}summary::marker{color:#64748b}details[open]>summary{border-bottom-color:#cbd5e1}.toast{position:fixed;top:20px;right:20px;z-index:1000;transition:all .3s}.spinner{border:3px solid #f3f3f3;border-top-color:#3498db;border-radius:50%;width:20px;height:20px;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.modal-overlay{background:rgba(0,0,0,.5);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}@keyframes fadeIn{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes checkmark{0%{transform:scale(0) rotate(0deg)}50%{transform:scale(1.2) rotate(180deg)}to{transform:scale(1) rotate(1turn)}}.fade-in{animation:fadeIn .6s ease-out}.checkmark{animation:checkmark .8s ease-out}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-blue-500:hover{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-green-500:hover{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.hover\:bg-purple-500:hover{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:text-blue-600:hover{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-slate-800:hover{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:h-10{height:2.5rem}.sm\:w-10{width:2.5rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:1.5rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.lg\:p-8{padding:2rem}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.fixed{position:fixed}.inset-0{inset:0}.bottom-8{bottom:2rem}.right-8{right:2rem}.z-50{z-index:50}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-ml-2{margin-left:-.5rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-3{margin-right:.75rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-16{height:4rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.max-h-96{max-height:24rem}.min-h-\[32px\]{min-height:32px}.w-16{width:4rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity,1))}.border-indigo-300{--tw-border-opacity:1;border-color:rgb(165 180 252/var(--tw-border-opacity,1))}.border-orange-300{--tw-border-opacity:1;border-color:rgb(253 186 116/var(--tw-border-opacity,1))}.border-pink-300{--tw-border-opacity:1;border-color:rgb(249 168 212/var(--tw-border-opacity,1))}.border-purple-300{--tw-border-opacity:1;border-color:rgb(216 180 254/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-teal-300{--tw-border-opacity:1;border-color:rgb(94 234 212/var(--tw-border-opacity,1))}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-pink-100{--tw-bg-opacity:1;background-color:rgb(252 231 243/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-teal-100{--tw-bg-opacity:1;background-color:rgb(204 251 241/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-pink-700{--tw-text-opacity:1;color:rgb(190 24 93/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2,.ring-4{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-4{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-yellow-400{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.ring-opacity-50{--tw-ring-opacity:0.5}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}@font-face{font-family:Pretendard;font-weight:400;font-style:normal;src:url(/fonts/Pretendard-Regular.woff2) format("woff2");font-display:swap}@font-face{font-family:Pretendard;font-weight:500;font-style:normal;src:url(/fonts/Pretendard-Medium.woff2) format("woff2");font-display:swap}@font-face{font-family:Pretendard;font-weight:700;font-style:normal;src:url(/fonts/Pretendard-Bold.woff2) format("woff2");font-display:swap}body,html{font-family:Pretendard,-apple-system,BlinkMacSystemFont,system-ui,Roboto,Helvetica Neue,Segoe UI,Apple SD Gothic Neo,Noto Sans KR,Malgun Gothic,sans-serif}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#e2e8f0}::-webkit-scrollbar-thumb{background:#94a3b8;border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#64748b}summary::marker{color:#64748b}details[open]>summary{border-bottom-color:#cbd5e1}.toast{position:fixed;top:20px;right:20px;z-index:1000;transition:all .3s}.spinner{border:3px solid #f3f3f3;border-top-color:#3498db;border-radius:50%;width:20px;height:20px;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.modal-overlay{background:rgba(0,0,0,.5);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}@keyframes fadeIn{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes checkmark{0%{transform:scale(0) rotate(0deg)}50%{transform:scale(1.2) rotate(180deg)}to{transform:scale(1) rotate(1turn)}}.fade-in{animation:fadeIn .6s ease-out}.checkmark{animation:checkmark .8s ease-out}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-blue-500:hover{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-green-500:hover{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.hover\:bg-purple-500:hover{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.hover\:text-blue-600:hover{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.hover\:text-red-500:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-slate-800:hover{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:h-10{height:2.5rem}.sm\:w-10{width:2.5rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:p-6{padding:1.5rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:p-8{padding:2rem}} \ No newline at end of file diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 0327e58..b3294a8 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -12,7 +12,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/adapter" adapterRegistry "github.com/DevSymphony/sym-cli/internal/adapter/registry" - "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/pkg/schema" @@ -360,10 +359,10 @@ func (v *Validator) ValidateChanges(ctx context.Context, changes []GitChange) (* // Check RBAC permissions first if v.policy.Enforce.RBACConfig != nil && v.policy.Enforce.RBACConfig.Enabled { - username, err := git.GetCurrentUser() - if err == nil { + currentRole, err := roles.GetCurrentRole() + if err == nil && currentRole != "" { if v.verbose { - fmt.Printf("🔐 Checking RBAC permissions for user: %s\n", username) + fmt.Printf("🔐 Checking RBAC permissions for role: %s\n", currentRole) } changedFiles := make([]string, 0, len(changes)) @@ -374,13 +373,13 @@ func (v *Validator) ValidateChanges(ctx context.Context, changes []GitChange) (* } if len(changedFiles) > 0 { - rbacResult, err := roles.ValidateFilePermissions(username, changedFiles) + rbacResult, err := roles.ValidateFilePermissionsForRole(currentRole, changedFiles) if err == nil && !rbacResult.Allowed { for _, deniedFile := range rbacResult.DeniedFiles { result.Violations = append(result.Violations, Violation{ RuleID: "rbac-permission-denied", Severity: "error", - Message: fmt.Sprintf("User '%s' does not have permission to modify this file", username), + Message: fmt.Sprintf("Role '%s' does not have permission to modify this file", currentRole), File: deniedFile, Line: 0, Column: 0,