From 21921c1b53f913f2ef2b34ddcbe72ef11d8cd8d0 Mon Sep 17 00:00:00 2001 From: TigErJin Date: Mon, 15 Sep 2025 13:46:19 +0900 Subject: [PATCH] convert to gitea --- Main.ps1 | 387 ++++++++++++++++++++ README.md | 131 +++++++ Scripts/Common.ps1 | 654 ++++++++++++++++++++++++++++++++++ Scripts/UI-Tab-AddUser.ps1 | 490 +++++++++++++++++++++++++ Scripts/UI-Tab-BatchTask.ps1 | 619 ++++++++++++++++++++++++++++++++ Scripts/UI-Tab-DeleteUser.ps1 | 375 +++++++++++++++++++ Scripts/UI-Tab-HardMatch.ps1 | 530 +++++++++++++++++++++++++++ config.json | 9 + logs/ad_tool_2025-06-17.log | 64 ++++ 9 files changed, 3259 insertions(+) create mode 100644 Main.ps1 create mode 100644 README.md create mode 100644 Scripts/Common.ps1 create mode 100644 Scripts/UI-Tab-AddUser.ps1 create mode 100644 Scripts/UI-Tab-BatchTask.ps1 create mode 100644 Scripts/UI-Tab-DeleteUser.ps1 create mode 100644 Scripts/UI-Tab-HardMatch.ps1 create mode 100644 config.json create mode 100644 logs/ad_tool_2025-06-17.log diff --git a/Main.ps1 b/Main.ps1 new file mode 100644 index 0000000..ff635b4 --- /dev/null +++ b/Main.ps1 @@ -0,0 +1,387 @@ +# ================================================================================ +# 파일: Main.ps1 +# 역할: AD/M365 통합 관리 도구 메인 스크립트 +# +# 작성자: 양범진 +# 버전: 1.15 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-12 +# +# 설명: 모든 기능 및 오류 수정이 완료된 최종 안정화 버전 +# 가독성 향상을 위해 코드 포매팅 및 주석 재정리 +# ================================================================================ + +# --- 1단계: 필수 어셈블리 및 모든 스크립트 파일 로드 --- +# Windows Forms 및 Drawing 어셈블리를 로드하여 GUI를 생성 +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# 스크립트 파일이 위치한 경로를 기준으로 관련 스크립트들을 로드 +$scriptRoot = $PSScriptRoot +$ScriptsPath = Join-Path -Path $scriptRoot -ChildPath "Scripts" +. (Join-Path -Path $ScriptsPath -ChildPath "Common.ps1") +. (Join-Path -Path $ScriptsPath -ChildPath "UI-Tab-AddUser.ps1") +. (Join-Path -Path $ScriptsPath -ChildPath "UI-Tab-DeleteUser.ps1") +. (Join-Path -Path $ScriptsPath -ChildPath "UI-Tab-HardMatch.ps1") +. (Join-Path -Path $ScriptsPath -ChildPath "UI-Tab-BatchTask.ps1") + +# --- 2단계: 전역 변수 선언 및 초기화 --- +# 스크립트 전체에서 사용될 변수들을 'script' 스코프로 선언 + +# 설정 및 로그 파일 경로 +$script:configFilePath = Join-Path -Path $scriptRoot -ChildPath "config.json" +$script:logDir = Join-Path -Path $scriptRoot -ChildPath "logs" +if (-not (Test-Path $script:logDir)) { + New-Item -Path $script:logDir -ItemType Directory | Out-Null +} +$script:logFilePath = Join-Path -Path $script:logDir -ChildPath "ad_tool_$(Get-Date -Format 'yyyy-MM-dd').log" + +# 메인 UI 컨트롤 +$script:mainForm = $null +$script:richTextBoxLog = $null +$script:connectAzureADMenu = $null +$script:statusStrip = $null +$script:statusLabelAzure = $null +$script:statusLabelOnPrem = $null +$script:statusLabelJob = $null +$script:iconGreen = $null +$script:iconRed = $null +$script:iconGray = $null + +# 환경 설정 및 상태 변수 +$script:Configuration = $null +$script:CurrentADDomainInfo = $null +$script:CurrentADDomainDN = $null +$script:SynchronizedOURoots = @() + +# '계정 생성' 탭 컨트롤 +$script:add_textBoxLastNameKr = $null +$script:add_textBoxFirstNameKr = $null +$script:add_textBoxAccountNameEn = $null +$script:add_textBoxPassword = $null +$script:add_textBoxSelectedOU = $null +$script:add_treeViewOUs = $null +$script:add_labelOUSyncStatus = $null +$script:add_listBoxLicenses = $null +$script:add_buttonCreate = $null +$script:add_radioSync = $null +$script:add_radioOnPremOnly = $null +$script:add_groupLicense = $null +$script:add_pictureBoxAccountValidation = $null +$script:add_toolTip = $null +$script:add_checkedListBoxServicePlans = $null + +# '계정 삭제' 탭 컨트롤 +$script:del_textBoxSearchUser = $null +$script:del_listBoxFoundUsers = $null +$script:del_buttonAddToDeleteList = $null +$script:del_listBoxDeleteQueue = $null +$script:del_buttonExecuteDelete = $null +$script:del_radioSync = $null +$script:del_radioOnPremOnly = $null +$script:del_selectedADUser = $null +$script:del_deleteQueue = ([System.Collections.ArrayList]::new()) +$script:del_textBoxFoundUserDisplay = $null +$script:del_textBoxFoundUserUPN = $null + +# '하드 매칭' 탭 컨트롤 +$script:hm_textBoxSearchAzure = $null +$script:hm_listBoxFoundAzure = $null +$script:hm_textBoxOnPremUser = $null +$script:hm_textBoxObjectGuid = $null +$script:hm_textBoxConvertedImmutableId = $null +$script:hm_textBoxAzureUser = $null +$script:hm_textBoxImmutableId = $null +$script:hm_labelGuidMatchStatus = $null +$script:hm_groupCreate = $null +$script:hm_textBoxSelectedOU = $null +$script:hm_treeViewOUs = $null +$script:hm_buttonExecuteAction = $null +$script:hm_labelOUSyncStatus = $null +$script:hm_textBoxFirstNameKr = $null +$script:hm_textBoxLastNameKr = $null +$script:hm_textBoxPassword = $null +$script:hm_selectedAzureUser = $null +$script:hm_foundOnPremUser = $null +$script:hm_currentMode = "Idle" + +# '일괄 작업' 탭 컨트롤 +$script:batch_comboBoxTaskType = $null +$script:batch_buttonDownloadTemplate = $null +$script:batch_textBoxCsvPath = $null +$script:batch_buttonBrowseCsv = $null +$script:batch_dataGridView = $null +$script:batch_buttonExecute = $null +$script:batch_progressBar = $null +$script:batch_loadedCsvData = $null + +# --- 3단계: 함수 정의 --- + +# 메인 폼 및 모든 UI 컨트롤을 생성하고 초기화하는 함수 +function Initialize-MainForm { + # 메인 폼 생성 + $script:mainForm = New-Object System.Windows.Forms.Form + $script:mainForm.Text = "AD/M365 통합 관리 도구 v1.0" + $script:mainForm.Size = New-Object System.Drawing.Size(950, 850) + $script:mainForm.StartPosition = "CenterScreen" + $script:mainForm.MinimumSize = New-Object System.Drawing.Size(900, 750) + $script:mainForm.ShowInTaskbar = $false # 초기 로딩 시 작업 표시줄에 보이지 않도록 설정 + $script:mainForm.Opacity = 0 # 초기 로딩 시 투명하게 설정하여 깜빡임 방지 + + # 상단 메뉴 스트립 생성 + $mainMenu = New-Object System.Windows.Forms.MenuStrip + $fileMenu = New-Object System.Windows.Forms.ToolStripMenuItem("파일(&F)") + $script:connectAzureADMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Azure AD 수동 연결/재연결(&C)") + $configBackupMenu = New-Object System.Windows.Forms.ToolStripMenuItem("설정 백업(&B)") + $configRestoreMenu = New-Object System.Windows.Forms.ToolStripMenuItem("설정 복원(&R)") + $exitMenu = New-Object System.Windows.Forms.ToolStripMenuItem("종료(&X)") + $fileMenu.DropDownItems.AddRange(@( + $script:connectAzureADMenu, + (New-Object System.Windows.Forms.ToolStripSeparator), + $configBackupMenu, + $configRestoreMenu, + (New-Object System.Windows.Forms.ToolStripSeparator), + $exitMenu + )) | Out-Null + + $toolsMenu = New-Object System.Windows.Forms.ToolStripMenuItem("도구(&T)") + $syncNowMenu = New-Object System.Windows.Forms.ToolStripMenuItem("AAD Connect 즉시 동기화 실행(&S)...") + $openLogFolderMenu = New-Object System.Windows.Forms.ToolStripMenuItem("로그 폴더 열기(&L)") + $openConfigFileMenu = New-Object System.Windows.Forms.ToolStripMenuItem("설정 파일 열기(&O)") + $toolsMenu.DropDownItems.AddRange(@( + $syncNowMenu, + (New-Object System.Windows.Forms.ToolStripSeparator), + $openLogFolderMenu, + $openConfigFileMenu + )) | Out-Null + + $mainMenu.Items.AddRange(@($fileMenu, $toolsMenu)) | Out-Null + $script:mainForm.Controls.Add($mainMenu) | Out-Null + + # 탭 컨트롤 생성 + $tabControl = New-Object System.Windows.Forms.TabControl + $tabControl.Location = New-Object System.Drawing.Point(10, 30) + $tabControl.Size = New-Object System.Drawing.Size(915, 600) + $tabControl.Anchor = 'Top, Left, Right' + $script:mainForm.Controls.Add($tabControl) | Out-Null + + # 각 기능 탭 페이지 생성 + $tabPageAdd = New-Object System.Windows.Forms.TabPage("계정 생성") + $tabPageDel = New-Object System.Windows.Forms.TabPage("계정 삭제") + $tabPageHardMatch = New-Object System.Windows.Forms.TabPage("하드 매칭") + $tabPageBatch = New-Object System.Windows.Forms.TabPage("일괄 작업 (CSV)") + $tabControl.Controls.AddRange(@($tabPageAdd, $tabPageDel, $tabPageHardMatch, $tabPageBatch)) | Out-Null + + # 각 탭의 UI를 초기화하는 함수 호출 (별도 스크립트 파일에 정의) + Initialize-AddUserTab -parentTab $tabPageAdd + Initialize-DeleteUserTab -parentTab $tabPageDel + Initialize-HardMatchTab -parentTab $tabPageHardMatch + Initialize-BatchTaskTab -parentTab $tabPageBatch + + # 하단 로그 출력 영역 생성 + $labelLog = New-Object System.Windows.Forms.Label + $labelLog.Text = "처리 로그:" + $labelLog.Location = New-Object System.Drawing.Point(10, 640) + $labelLog.Anchor = 'Top, Left' + $labelLog.AutoSize = $true + $script:mainForm.Controls.Add($labelLog) | Out-Null + + $script:richTextBoxLog = New-Object System.Windows.Forms.RichTextBox + $script:richTextBoxLog.Location = New-Object System.Drawing.Point(10, 660) + $script:richTextBoxLog.Size = New-Object System.Drawing.Size(915, 125) + $script:richTextBoxLog.ReadOnly = $true + $script:richTextBoxLog.ScrollBars = "Vertical" + $script:richTextBoxLog.Anchor = 'Top, Left, Right, Bottom' + $script:mainForm.Controls.Add($script:richTextBoxLog) | Out-Null + + # 하단 상태 표시줄 생성 + $script:iconGreen = New-StatusIcon -Color ([System.Drawing.Color]::LimeGreen) + $script:iconRed = New-StatusIcon -Color ([System.Drawing.Color]::Red) + $script:iconGray = New-Object System.Drawing.Bitmap(1, 1) # 회색 아이콘 대신 기본 비트맵 + + $script:statusStrip = New-Object System.Windows.Forms.StatusStrip + $script:statusStrip.SizingGrip = $false + + $script:statusLabelAzure = New-Object System.Windows.Forms.ToolStripStatusLabel + $script:statusLabelAzure.Image = $script:iconRed + $script:statusLabelAzure.Text = "Azure AD: 확인 중..." + + $script:statusLabelOnPrem = New-Object System.Windows.Forms.ToolStripStatusLabel + $script:statusLabelOnPrem.Image = $script:iconGray + $script:statusLabelOnPrem.Text = "On-Prem DC: 확인 중..." + + $script:statusLabelJob = New-Object System.Windows.Forms.ToolStripStatusLabel + $script:statusLabelJob.Spring = $true # 남은 공간을 모두 차지하도록 설정 + $script:statusLabelJob.TextAlign = "MiddleRight" + + $script:statusStrip.Items.AddRange(@( + $script:statusLabelAzure, + (New-Object System.Windows.Forms.ToolStripSeparator), + $script:statusLabelOnPrem, + $script:statusLabelJob + )) | Out-Null + $script:mainForm.Controls.Add($script:statusStrip) | Out-Null + + # 메뉴 항목에 대한 이벤트 핸들러 연결 + $syncNowMenu.Add_Click($syncNowMenu_Click) + $script:connectAzureADMenu.Add_Click({ Connect-AzureAD-WithInteraction }) + $configBackupMenu.Add_Click($configBackupMenu_Click) + $configRestoreMenu.Add_Click($configRestoreMenu_Click) + $exitMenu.Add_Click({ if ($script:mainForm.IsHandleCreated) { $script:mainForm.Close() } }) + $openLogFolderMenu.Add_Click({ Invoke-Item $script:logDir }) + $openConfigFileMenu.Add_Click({ if (Test-Path $script:configFilePath) { Invoke-Item $script:configFilePath } }) +} + +# '설정 백업' 메뉴 클릭 이벤트 핸들러 +$configBackupMenu_Click = { + $sfd = New-Object System.Windows.Forms.SaveFileDialog + $sfd.Filter = "JSON 파일 (*.json)|*.json" + $sfd.FileName = "config.backup.json" + if ($sfd.ShowDialog() -eq "OK") { + try { + Copy-Item -Path $script:configFilePath -Destination $sfd.FileName -Force + [System.Windows.Forms.MessageBox]::Show("설정 파일이 백업되었습니다.", "백업 완료", "OK", "Information") | Out-Null + } + catch { + Show-DetailedErrorDialog -ErrorRecord $_ + } + } +} + +# '설정 복원' 메뉴 클릭 이벤트 핸들러 +$configRestoreMenu_Click = { + $ofd = New-Object System.Windows.Forms.OpenFileDialog + $ofd.Filter = "JSON 파일 (*.json)|*.json" + if ($ofd.ShowDialog() -eq "OK") { + try { + Copy-Item -Path $ofd.FileName -Destination $script:configFilePath -Force + [System.Windows.Forms.MessageBox]::Show("설정을 복원했습니다. 프로그램을 다시 시작해주세요.", "복원 완료", "OK", "Information") | Out-Null + $script:mainForm.Close() + } + catch { + Show-DetailedErrorDialog -ErrorRecord $_ + } + } +} + +# 'AAD Connect 즉시 동기화' 메뉴 클릭 이벤트 핸들러 +$syncNowMenu_Click = { + # 동기화 유형을 선택하는 작은 대화상자 생성 + $syncForm = New-Object System.Windows.Forms.Form + $syncForm.Text = "동기화 유형 선택" + $syncForm.Size = New-Object System.Drawing.Size(300, 180) + $syncForm.StartPosition = "CenterParent" + $syncForm.FormBorderStyle = "FixedDialog" + $syncForm.MaximizeBox = $false + $syncForm.MinimizeBox = $false + + $radioDelta = New-Object System.Windows.Forms.RadioButton + $radioDelta.Text = "증분 동기화 (Delta) - 권장" + $radioDelta.Location = New-Object System.Drawing.Point(20, 20) + $radioDelta.AutoSize = $true + $radioDelta.Checked = $true + + $radioFull = New-Object System.Windows.Forms.RadioButton + $radioFull.Text = "전체 동기화 (Initial/Full)" + $radioFull.Location = New-Object System.Drawing.Point(20, 50) + $radioFull.AutoSize = $true + + $runButton = New-Object System.Windows.Forms.Button + $runButton.Text = "실행" + $runButton.DialogResult = "OK" + $runButton.Location = New-Object System.Drawing.Point(50, 100) + $runButton.Size = New-Object System.Drawing.Size(90, 30) + + $cancelButton = New-Object System.Windows.Forms.Button + $cancelButton.Text = "취소" + $cancelButton.DialogResult = "Cancel" + $cancelButton.Location = New-Object System.Drawing.Point(150, 100) + $cancelButton.Size = New-Object System.Drawing.Size(90, 30) + + $syncForm.Controls.AddRange(@($radioDelta, $radioFull, $runButton, $cancelButton)) | Out-Null + $syncForm.AcceptButton = $runButton + $syncForm.CancelButton = $cancelButton + + # 사용자가 '실행'을 누르면 동기화 실행 + if ($syncForm.ShowDialog($script:mainForm) -eq "OK") { + $policyType = if ($radioDelta.Checked) { "Delta" } else { "Initial" } + Invoke-AadConnectSync -PolicyType $policyType + } + $syncForm.Dispose() +} + +# ================================================================================ +# 프로그램 실행 시작 +# ================================================================================ + +# 1. 관리자 권한 확인 +if (-Not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + [System.Windows.Forms.MessageBox]::Show("관리자 권한으로 실행해야 합니다.", "권한 오류", "OK", "Error") | Out-Null + Exit +} + +# 2. 필수 PowerShell 모듈 확인 +if (-not (Test-RequiredModules)) { + [System.Windows.Forms.MessageBox]::Show("필수 PowerShell 모듈이 없어 프로그램을 종료합니다.", "모듈 오류", "OK", "Error") | Out-Null + Exit +} + +# 3. 설정 파일(config.json) 로드 +if (Test-Path $script:configFilePath) { + try { + $script:Configuration = Get-Content -Path $script:configFilePath -Raw | ConvertFrom-Json + # 이전 버전과의 호환성을 위해 불필요한 속성 제거 + if ($script:Configuration.PSObject.Properties.Name.Contains('AccountNameFormat')) { + $script:Configuration.PSObject.Properties.Remove('AccountNameFormat') + } + } + catch { + [System.Windows.Forms.MessageBox]::Show("설정 파일 읽기 오류. 기본 설정으로 시작합니다.", "설정 오류", "OK", "Warning") | Out-Null + $script:Configuration = $null + } +} + +# 4. 설정 파일이 없거나 오류 발생 시 기본값으로 초기화 +if ($null -eq $script:Configuration) { + $script:Configuration = [ordered]@{ + OnPremDomainController = "dc.example.com" + AADConnectServerName = "" + AzureTenantId = "" + UPNSuffix = "example.com" + SynchronizedOUs = "OU=Users,DC=example,DC=com" + DefaultPassword = "Password123!" + DefaultUsageLocation = "KR" + } +} + +# 5. 메인 폼 UI 초기화 +Initialize-MainForm +Write-Log "AD/M365 통합 관리 도구를 시작합니다." + +# 6. 설정 대화상자 표시 및 사용자 설정 확인 +if (-not (Show-ConfigurationDialog)) { + Write-Log "사용자가 설정을 취소하여 프로그램을 종료합니다." + Exit +} + +# 7. 스크립트 실행 환경(On-Prem AD 연결 등) 초기화 +if (-not (Initialize-ScriptEnvironment)) { + [System.Windows.Forms.MessageBox]::Show("온프레미스 DC에 연결할 수 없습니다. 설정을 확인하고 다시 시작해주세요.", "초기화 오류", "OK", "Error") | Out-Null + Exit +} + +# 8. 메인 폼 표시 및 이벤트 핸들러 등록 +$script:mainForm.ShowInTaskbar = $true +$script:mainForm.Opacity = 1 +$script:mainForm.Activate() + +# 폼이 처음 표시된 후 자동으로 Azure AD에 연결하고 OU 트리를 로드 +$script:mainForm.Add_Shown({ + Connect-AzureAD-Silently + Update-OU-TreeView -TriggerControl $null +}) + +# 메인 폼을 대화상자 형태로 실행하여 사용자가 닫을 때까지 대기 +$script:mainForm.ShowDialog() | Out-Null + +Write-Log "프로그램을 종료합니다." \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8766bbc --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +## AD/M365 통합 관리 도구 + +--- + +### 1. 개요 + +하이브리드 Active Directory (On-Premise AD + Azure AD) 환경에서 사용자 계정의 생성, 삭제, 동기화 관리를 자동화하고 간소화하기 위한 PowerShell 기반의 GUI 도구이다. IT 관리자는 이 도구를 통해 반복적인 작업을 줄이고 휴먼 에러를 최소화할 수 있다. + +모든 작업은 UI가 멈추지 않는 동기 방식으로 처리되며, 스크립트 실행에 필요한 모듈이나 기능은 최초 실행 시 자동으로 설치를 안내하여 사용자 편의성을 높였다. + +### 2. 주요 기능 + +- **계정 생성**: + - On-Premise AD 사용자 생성. + - AAD Connect 동기화 (Delta) 자동 실행. + - Azure AD 사용자 확인 후 M365 라이선스 및 서비스 플랜 할당. + - On-Premise AD에만 사용자를 생성하는 옵션 제공. +- **계정 삭제**: + - On-Premise AD에서 사용자 검색 및 다중 선택. + - 삭제 대기열에 사용자를 추가하여 일괄 삭제. + - AAD Connect 동기화 (Delta) 자동 실행. +- **하드 매칭 (Hard Match)**: + - Azure AD에만 존재하는 사용자(Cloud-Only)를 On-Premise AD 사용자와 연결. + - **시나리오 1**: 기존 On-Premise AD 계정과 매칭 (ImmutableId 설정). + - **시나리오 2**: 신규 On-Premise AD 계정을 생성하여 즉시 매칭. + - 매칭 후 즉시 동기화를 실행하여 상태를 바로잡음. +- **일괄 작업 (CSV)**: + - CSV 파일을 이용한 대량 계정 생성 및 삭제. + - 작업별 CSV 템플릿 다운로드 기능 제공. + - 실행 전 데이터 유효성 검사 (OU 존재 여부, 계정명 중복 등)를 통해 작업 실패율 최소화. +- **편의 기능**: + - 직관적인 GUI 및 작업 상태 실시간 로깅. + - 스크립트 실행에 필요한 모듈(ActiveDirectory, Microsoft.Graph) 자동 검사 및 설치 안내. + - 주요 설정(`config.json`)을 GUI 대화상자를 통해 쉽게 변경 및 저장. + +### 3. 요구 사항 및 환경 + +- **운영 체제**: Windows 10, Windows 11, Windows Server 2016 이상 +- **PowerShell**: 버전 5.1 이상 +- **권한**: 스크립트를 실행하는 사용자는 **로컬 관리자** 권한 및 **Domain Admins** 권한이 필요. +- **필수 모듈/기능**: + - RSAT: Active Directory Domain Services 및 LDS(Lightweight Directory Services) 도구 + - PowerShell Module: `Microsoft.Graph` + > *위 항목들은 스크립트 최초 실행 시 자동으로 설치 여부를 확인하고 설치를 안내함.* +- **네트워크**: On-Premise Domain Controller 및 Azure AD (graph.microsoft.com)와 통신이 가능해야 함. + +### 4. 설치 및 설정 + +1. **파일 다운로드** + - 리포지토리의 모든 파일을 다운로드하여 원하는 위치에 압축을 해제한다. + +2. **설정 파일 (`config.json`) 수정** + - 프로그램을 처음 실행하면 설정 대화상자가 나타나지만, 미리 `config.json` 파일을 자신의 환경에 맞게 수정할 수도 있다. + + | 키 | 설명 | 예시 | + | ----------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | + | `OnPremDomainController` | On-Premise Active Directory 도메인 컨트롤러의 FQDN. | `dc01.mydomain.local` | + | `AADConnectServerName` | Azure AD Connect가 설치된 서버의 이름. 비워두면 로컬에서 동기화 명령을 실행. | `aadc01` | + | `AzureTenantId` | Azure AD 테넌트의 ID. | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | + | `UPNSuffix` | 사용자 계정의 UPN(User Principal Name)으로 사용할 접미사. | `mydomain.com` | + | `SynchronizedOUs` | AAD Connect로 동기화되는 OU의 **이름** 목록. 세미콜론(;)으로 구분. | `Sales;Marketing;Developers` | + | `DefaultPassword` | 계정 생성 시 사용할 기본 초기 비밀번호. | `Password123!` | + | `DefaultUsageLocation` | M365 라이선스 할당 시 필요한 사용 위치 (ISO 3166-1 alpha-2 국가 코드). | `KR` | + +3. **스크립트 실행** + - `Main.ps1` 파일을 마우스 오른쪽 버튼으로 클릭하여 **'PowerShell에서 실행'**을 선택한다. + - **반드시 관리자 권한으로 실행해야 한다.** + - 최초 실행 시 필수 모듈 설치 여부를 확인하며, 설치가 필요할 경우 관리자 권한으로 설치를 진행한다. + - 설정 대화상자가 나타나면 내용을 확인하고 '시작' 버튼을 클릭한다. + +### 5. 사용 방법 + +#### 5.1. 계정 생성 탭 + +1. **작업 모드 선택**: 'AD/Azure AD 동기화' 또는 '온프레미스 AD에만 생성' 중 하나를 선택한다. +2. **사용자 정보 입력**: 한글 성/이름, 영문 계정명, 초기 비밀번호를 입력한다. + - 영문 계정명은 입력이 끝나면 On-Premise 및 Azure AD에 중복되는 계정이 있는지 자동으로 검사한다. +3. **대상 OU 선택**: 사용자를 생성할 On-Premise AD의 조직 단위(OU)를 트리에서 선택한다. + - 선택한 OU가 AD Connect 동기화 대상인지 여부가 하단에 표시된다. +4. **M365 라이선스 할당** (동기화 모드 시): + - 할당할 라이선스를 목록에서 선택한다. + - 선택한 라이선스에 포함된 서비스 플랜 중 비활성화할 항목은 체크를 해제한다. +5. **계정 생성 실행**: 버튼을 클릭하여 작업을 시작한다. 작업 과정은 하단 로그 창에 출력된다. + +#### 5.2. 계정 삭제 탭 + +1. **사용자 검색**: 삭제할 사용자의 이름 또는 계정명 일부를 입력하고 '온프레미스 검색' 버튼을 클릭한다. +2. **삭제 목록에 추가**: 검색 결과에서 삭제할 사용자를 선택(다중 선택 가능)하고 '삭제 목록에 추가' 버튼을 클릭하거나, 항목을 더블클릭한다. +3. **삭제 실행**: 삭제 대기 목록을 확인한 후, '목록 삭제 실행' 버튼을 클릭한다. 최종 확인 대화상자에서 '예'를 누르면 작업이 시작된다. + +#### 5.3. 하드 매칭 탭 + +1. **Azure AD 사용자 검색**: 매칭할 Azure AD 사용자의 이름 또는 UPN을 입력하고 'Azure AD 검색' 버튼을 클릭한다. +2. **사용자 선택**: 검색 결과 목록에서 대상 사용자를 선택한다. +3. **상태 확인 및 작업 선택**: + - **On-Premise 계정이 없는 경우**: '계정 생성 및 하드 매칭' 모드로 전환된다. 신규 생성에 필요한 정보를 입력하고 대상 OU를 선택한다. + - **On-Premise 계정이 있는 경우**: '기존 계정과 하드 매칭' 모드로 전환된다. Azure AD의 `ImmutableId`와 On-Premise AD의 `ObjectGUID`를 비교하여 현재 동기화 상태를 진단하고 필요한 작업을 안내한다. +4. **작업 실행**: 버튼을 클릭하여 매칭 작업을 시작한다. + +#### 5.4. 일괄 작업 (CSV) 탭 + +1. **작업 유형 선택**: '계정 생성' 또는 '계정 삭제'를 선택한다. +2. **템플릿 다운로드**: '템플릿 다운로드' 버튼을 클릭하여 작업에 맞는 CSV 파일을 받는다. +3. **CSV 파일 작성**: 다운로드한 템플릿에 맞춰 작업할 데이터를 입력한다. +4. **파일 불러오기**: '파일 찾아보기' 버튼으로 작성한 CSV 파일을 선택하면 데이터가 그리드에 표시된다. +5. **유효성 검사**: '유효성 검사' 버튼을 클릭하여 데이터의 정합성(필수 값, OU 경로, 계정 중복 등)을 미리 확인한다. +6. **일괄 작업 실행**: 유효성 검사를 통과하면 '일괄 작업 실행' 버튼이 활성화된다. 버튼을 클릭하여 작업을 시작한다. + +### 6. 주요 아키텍처 및 로직 + +- **GUI**: PowerShell에서 `System.Windows.Forms` .NET 어셈블리를 직접 호출하여 모든 UI 요소를 동적으로 생성한다. +- **비동기 처리**: `Invoke-Synchronous` 라는 공용 함수를 통해 시간이 오래 걸리는 작업(AD/Graph API 호출, 동기화 등)을 실행한다. 이 함수는 작업 중 UI 컨트롤을 비활성화하고 커서를 대기 상태로 변경하여 사용자에게 작업 상태를 명확히 알려주고, UI가 멈추는 현상을 방지한다. +- **모듈화**: 기능별로 스크립트 파일을 분리하여 코드의 가독성과 유지보수성을 높였다. + - `Main.ps1`: 프로그램 진입점, 전역 변수 및 메인 폼 초기화. + - `Common.ps1`: 모든 기능에서 공통으로 사용하는 함수 라이브러리 (로그, API 호출 래퍼, 설정 관리 등). + - `UI-Tab-*.ps1`: 각 탭의 UI 생성 및 이벤트 핸들러 로직을 담당. + +### 7. 파일 구조 + +``` +. +├── Main.ps1 # 메인 스크립트 (시작 파일) +├── config.json # 환경 설정 파일 +├── logs/ # 로그 파일 저장 디렉터리 +└── Scripts/ + ├── Common.ps1 # 공용 함수 라이브러리 + ├── UI-Tab-AddUser.ps1 # '계정 생성' 탭 UI 및 로직 + ├── UI-Tab-DeleteUser.ps1 # '계정 삭제' 탭 UI 및 로직 + ├── UI-Tab-HardMatch.ps1 # '하드 매칭' 탭 UI 및 로직 + └── UI-Tab-BatchTask.ps1 # '일괄 작업' 탭 UI 및 로직 +``` diff --git a/Scripts/Common.ps1 b/Scripts/Common.ps1 new file mode 100644 index 0000000..be02284 --- /dev/null +++ b/Scripts/Common.ps1 @@ -0,0 +1,654 @@ +# ================================================================================ +# 파일: Scripts/Common.ps1 +# 역할: 공용 함수 라이브러리 +# +# 작성자: 양범진 +# 버전: 1.15 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-17 +# +# 설명: +# - 프로그램 전반에서 사용되는 공통 함수들을 정의 +# - 스레드 관련 복잡성을 제거하고 안정적인 동기 실행 방식을 사용 +# - OS 종류에 따라 모듈 설치를 다르게 진행(Windows 10/11 선택적 기능 추가) +# ================================================================================ + +#region 공용 유틸리티 함수 +# --- 필수 모듈 검사 및 설치 함수 --- +# 스크립트 실행에 필요한 PowerShell 모듈 및 기능이 있는지 확인하고, 없을 경우 사용자에게 설치를 안내 +function Test-RequiredModules { + # --- OS 종류 확인 --- + # ProductType 1이 일반, 그 외(2,3) 서버. 편의성을 위해 1만으로 구분하여 판단. + $osInfo = Get-CimInstance -ClassName Win32_OperatingSystem + $isClientOS = ($osInfo.ProductType -eq 1) + + # 설치 명령어 정의 + $installCommands = @{ + "ActiveDirectory" = if ($isClientOS) { + "Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'" + } else { + "Install-WindowsFeature -Name 'RSAT-AD-PowerShell' -IncludeAllSubFeature" + } + "Microsoft.Graph" = "Install-Module Microsoft.Graph -Repository PSGallery -Force -AllowClobber -Scope AllUsers -Confirm:`$false" + } + + $missingModules = @() + + # --- 1. ActiveDirectory 기능(RSAT) 설치 여부 확인 --- + $adModuleInstalled = $false + try { + if ($isClientOS) { + Write-Host "일반 OS 감지. 'RSAT: Active Directory...' 기능 설치 여부 확인 중..." + $capability = Get-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0' -ErrorAction SilentlyContinue + if ($capability -and $capability.State -eq 'Installed') { + $adModuleInstalled = $true + } + } else { + Write-Host "서버 OS 감지. 'RSAT-AD-PowerShell' 기능 설치 여부 확인 중..." + $feature = Get-WindowsFeature -Name 'RSAT-AD-PowerShell' -ErrorAction SilentlyContinue + if ($feature -and $feature.Installed) { + $adModuleInstalled = $true + } + } + } catch { + Write-Warning "Active Directory 기능 확인 중 오류 발생: $($_.Exception.Message)" + } + + if (-not $adModuleInstalled) { + $missingModules += "ActiveDirectory" + Write-Host "결과: Active Directory 관리 도구가 설치되지 않았습니다." -ForegroundColor Yellow + } else { + Write-Host "결과: Active Directory 관리 도구가 이미 설치되어 있습니다." -ForegroundColor Green + } + + + # --- 2. Microsoft.Graph 모듈 설치 여부 확인 --- + Write-Host "'Microsoft.Graph' 모듈 설치 여부 확인 중..." + if (-not (Get-Module -ListAvailable -Name "Microsoft.Graph")) { + $missingModules += "Microsoft.Graph" + Write-Host "결과: Microsoft.Graph 모듈이 설치되지 않았습니다." -ForegroundColor Yellow + } else { + Write-Host "결과: Microsoft.Graph 모듈이 이미 설치되어 있습니다." -ForegroundColor Green + } + + + # --- 3. 누락된 모듈/기능이 있으면 설치 진행 --- + if ($missingModules.Count -gt 0) { + $message = "필수 구성 요소가 설치되지 않았습니다:`n`n- $($missingModules -join "`n- ")`n`n지금 설치하시겠습니까? (시간이 소요될 수 있습니다)" + $response = [System.Windows.Forms.MessageBox]::Show($message, "필수 구성 요소 설치", "YesNo", "Question") + + if ($response -eq "Yes") { + if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + [System.Windows.Forms.MessageBox]::Show("설치에는 관리자 권한이 필요합니다. PowerShell을 '관리자 권한으로 실행'한 후 다시 시도해주세요.", "권한 필요", "OK", "Warning") | Out-Null + return $false + } + + foreach ($moduleToInstall in $missingModules) { + Write-Host "'$moduleToInstall' 설치 시작..." + if ($moduleToInstall -eq "ActiveDirectory" -and $isClientOS) { + Write-Host "Windows 기능 설치 중입니다. PC 환경에 따라 수 분 이상 소요될 수 있습니다. 잠시만 기다려주세요..." -ForegroundColor Yellow + } + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-Expression -Command $installCommands[$moduleToInstall] -ErrorAction Stop + Write-Host "'$moduleToInstall' 설치 성공!" -ForegroundColor Green + } catch { + [System.Windows.Forms.MessageBox]::Show("'$moduleToInstall' 자동 설치 실패: $($_.Exception.Message)`n`nPowerShell을 관리자 권한으로 실행했는지 확인해주세요.", "설치 실패", "OK", "Error") | Out-Null + return $false + } + } + [System.Windows.Forms.MessageBox]::Show("설치가 완료되었습니다. 스크립트를 다시 시작해주세요.", "설치 완료", "OK", "Information") | Out-Null + Exit + } else { + Write-Warning "설치를 취소하여 종료합니다." + return $false + } + } + + # --- 4. 설치된 모듈 현재 세션으로 가져오기 --- + try { + Import-Module ActiveDirectory -ErrorAction Stop + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + Import-Module Microsoft.Graph.Users -ErrorAction Stop + Import-Module Microsoft.Graph.Identity.DirectoryManagement -ErrorAction SilentlyContinue + return $true + } catch { + [System.Windows.Forms.MessageBox]::Show("모듈 로드 오류: $($_.Exception.Message)", "오류", "OK", "Error") | Out-Null + return $false + } +} + +# 상세 오류 정보 대화상자 표시 함수 +# 예외 발생 시, 사용자에게 기본 오류 메시지와 함께 상세 정보를 볼 수 있는 대화상자를 표시 +function Show-DetailedErrorDialog { + Param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.ErrorRecord]$ErrorRecord, + [string]$Message = "작업 중 오류 발생" + ) + $errorForm = New-Object System.Windows.Forms.Form + $errorForm.Text = "오류" + $errorForm.Size = New-Object System.Drawing.Size(500, 200) + $errorForm.FormBorderStyle = "FixedDialog" + $errorForm.StartPosition = "CenterParent" + + $iconLabel = New-Object System.Windows.Forms.Label + $iconLabel.Image = [System.Drawing.SystemIcons]::Error.ToBitmap() + $iconLabel.Location = New-Object System.Drawing.Point(20, 20) + $iconLabel.Size = New-Object System.Drawing.Size(32, 32) + + $msgLabel = New-Object System.Windows.Forms.Label + $msgLabel.Text = "$Message`n$($ErrorRecord.Exception.Message)" + $msgLabel.Location = New-Object System.Drawing.Point(60, 20) + $msgLabel.Size = New-Object System.Drawing.Size(420, 60) + + $okButton = New-Object System.Windows.Forms.Button + $okButton.Text = "확인" + $okButton.Location = New-Object System.Drawing.Point(210, 120) + $okButton.Size = New-Object System.Drawing.Size(80, 25) + $okButton.DialogResult = "OK" + + $detailsButton = New-Object System.Windows.Forms.Button + $detailsButton.Text = "자세히(&D) >>" + $detailsButton.Location = New-Object System.Drawing.Point(380, 120) + $detailsButton.Size = New-Object System.Drawing.Size(100, 25) + + $detailsTextBox = New-Object System.Windows.Forms.TextBox + $detailsTextBox.Multiline = $true + $detailsTextBox.ScrollBars = "Both" + $detailsTextBox.ReadOnly = $true + $detailsTextBox.Text = $ErrorRecord.ToString() + $detailsTextBox.Location = New-Object System.Drawing.Point(20, 160) + $detailsTextBox.Size = New-Object System.Drawing.Size(460, 150) + $detailsTextBox.Visible = $false + + $detailsButton.Add_Click({ + if ($detailsTextBox.Visible) { + $errorForm.Height = 200 + $detailsTextBox.Visible = $false + $detailsButton.Text = "자세히(&D) >>" + } + else { + $errorForm.Height = 360 + $detailsTextBox.Visible = $true + $detailsButton.Text = "<< 간단히(&L)" + } + }) + + $errorForm.Controls.AddRange(@($iconLabel, $msgLabel, $okButton, $detailsButton, $detailsTextBox)) | Out-Null + $errorForm.AcceptButton = $okButton + $errorForm.ShowDialog($script:mainForm) | Out-Null + $errorForm.Dispose() +} + +# --- 로그 기록 함수 --- +# 메시지를 화면(콘솔), UI(RichTextBox), 파일 세 곳에 동시에 기록 +function Write-Log { + Param( + [string]$Message, + [ValidateSet("INFO", "WARNING", "ERROR", "SUCCESS")] + [string]$Level = "INFO" + ) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logEntry = "$timestamp [$Level]: $Message" + + # 1. 콘솔에 출력 + Write-Host $logEntry + + # 2. UI의 RichTextBox에 출력 + if ($script:richTextBoxLog -and $script:richTextBoxLog.IsHandleCreated -and -not $script:richTextBoxLog.IsDisposed) { + $script:richTextBoxLog.AppendText("$logEntry`r`n") + $script:richTextBoxLog.ScrollToCaret() + [System.Windows.Forms.Application]::DoEvents() + } + + # 3. 로그 파일에 기록 + try { + Add-Content -Path $script:logFilePath -Value $logEntry -Encoding UTF8 + } + catch { + Write-Warning "로그 파일($script:logFilePath) 쓰기 실패: $($_.Exception.Message)" + } +} + +# --- 동기식 작업 실행 함수 --- +# 시간이 걸리는 작업을 실행하는 동안 UI가 멈추지 않도록 처리하고, 작업 상태를 표시 +function Invoke-Synchronous { + Param( + [Parameter(Mandatory = $true)] + [ScriptBlock]$ScriptBlock, + [System.Object]$TriggerControl, + [string]$StatusMessage = "작업 중...", + [switch]$RequiresAzureAD + ) + # Azure AD 연결이 필요한 작업인지 확인 + if ($RequiresAzureAD -and -not (Get-MgContext)) { + Write-Log "Azure AD 연결이 필요합니다. 연결을 시도합니다." -Level "WARNING" + if (-not (Connect-AzureAD-WithInteraction)) { + Write-Log "Azure AD 연결이 취소되었거나 실패하여 작업을 중단합니다." -Level "ERROR" + return $null + } + } + + # 작업 시작 전 UI 상태 설정 + if ($TriggerControl) { + $TriggerControl.Enabled = $false + } + $originalCursor = $script:mainForm.Cursor + $script:mainForm.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $script:statusLabelJob.Text = $StatusMessage + Write-Log $StatusMessage + [System.Windows.Forms.Application]::DoEvents() + + try { + # 전달받은 스크립트 블록 실행 + return (& $ScriptBlock) + } + catch { + # 오류 발생 시 로그 기록 및 상세 오류 대화상자 표시 + Write-Log "작업 실패: $($_.Exception.Message)" -Level "ERROR" + Show-DetailedErrorDialog -ErrorRecord $_ + return $null + } + finally { + # 작업 완료 후 UI 상태 복원 + if ($TriggerControl -and $TriggerControl.IsHandleCreated) { + $TriggerControl.Enabled = $true + } + if ($script:mainForm.IsHandleCreated) { + $script:mainForm.Cursor = $originalCursor + } + $script:statusLabelJob.Text = "" + } +} + +# --- SamAccountName 유효성 검사 함수 --- +# 계정명의 유효성(형식, 길이)과 중복 여부(On-Prem AD, Azure AD)를 검사 +function Test-SamAccountName { + param( + [string]$AccountName, + [switch]$CheckAzureAD + ) + if ([string]::IsNullOrWhiteSpace($AccountName)) { + return @{ IsValid = $false; Reason = "계정명은 비워둘 수 없습니다." } + } + if ($AccountName -notmatch '^[a-z0-9]+$') { + return @{ IsValid = $false; Reason = "계정명은 소문자 영문과 숫자만 사용할 수 있습니다." } + } + if ($AccountName.Length -lt 3) { + return @{ IsValid = $false; Reason = "계정명은 3자 이상이어야 합니다." } + } + if ($AccountName.Length -gt 20) { + return @{ IsValid = $false; Reason = "계정명은 20자를 초과할 수 없습니다." } + } + + # 1. On-Prem AD 중복 확인 + try { + if (Get-ADUser -Filter "SamAccountName -eq '$AccountName'" -Server $script:Configuration.OnPremDomainController) { + return @{ IsValid = $false; Reason = "이미 사용 중인 계정명입니다 (On-Prem AD)." } + } + } + catch { + # DC 연결 실패 등 예외 발생 시, 검사를 통과시키되 경고성 메시지를 반환 + return @{ IsValid = $true; Reason = "On-Prem AD 유효성 검사 중 오류 발생 (DC 확인 불가)" } + } + + # 2. Azure AD 중복 확인 (스위치가 제공된 경우) + if ($CheckAzureAD) { + if (-not (Get-MgContext)) { + return @{ IsValid = $true; Reason = "Azure AD 연결이 안 되어 UPN 중복 확인을 건너뜁니다." } + } + $upn = "$($AccountName)@$($script:Configuration.UPNSuffix)" + try { + $azureUser = Get-MgUser -Filter "userPrincipalName eq '$upn'" -ErrorAction Stop + if ($azureUser) { + return @{ IsValid = $false; Reason = "Azure AD에 동일한 UPN의 계정이 이미 존재합니다. 하드 매칭을 사용하세요." } + } + } + catch { + return @{ IsValid = $true; Reason = "Azure AD UPN 확인 중 오류 발생." } + } + } + + return @{ IsValid = $true; Reason = "사용 가능한 계정명입니다." } +} + +# --- 상태 표시줄 아이콘 생성 함수 --- +# 상태 표시줄에 사용할 동그란 색상 아이콘을 생성 +function New-StatusIcon { + Param([System.Drawing.Color]$Color) + $bmp = New-Object System.Drawing.Bitmap(16, 16) + $graphics = [System.Drawing.Graphics]::FromImage($bmp) + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $brush = New-Object System.Drawing.SolidBrush($Color) + $graphics.FillEllipse($brush, 2, 2, 12, 12) + $graphics.Dispose() + $brush.Dispose() + return $bmp +} + +# --- 설정 대화상자 표시 함수 --- +# 프로그램 시작 시 사용자에게 주요 설정 값을 확인하고 수정할 수 있는 대화상자를 표시 +function Show-ConfigurationDialog { + $form = New-Object System.Windows.Forms.Form + $form.Text = "스크립트 설정 확인" + $form.Size = New-Object System.Drawing.Size(550, 250) + $form.StartPosition = "CenterParent" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.MinimizeBox = $false + + $controls = @{} + $yPos = 20 + $labels = @{ + OnPremDomainController = "온프레미스 DC:" + AADConnectServerName = "AAD Connect 서버:" + AzureTenantId = "Azure AD 테넌트 ID:" + UPNSuffix = "UPN 접미사:" + SynchronizedOUs = "동기화 OU (세미콜론; 구분):" + } + + foreach ($key in $labels.Keys) { + $lbl = New-Object System.Windows.Forms.Label + $lbl.Text = $labels[$key] + $lbl.Location = New-Object System.Drawing.Point(20, $yPos) + $lbl.Size = New-Object System.Drawing.Size(200, 20) + $form.Controls.Add($lbl) | Out-Null + + $txt = New-Object System.Windows.Forms.TextBox + $txt.Location = New-Object System.Drawing.Point(230, $yPos) + $txt.Size = New-Object System.Drawing.Size(280, 20) + if ($script:Configuration -and $script:Configuration.PSObject.Properties.Name -contains $key) { + $txt.Text = $script:Configuration.$key + } + $form.Controls.Add($txt) | Out-Null + $controls[$key] = $txt + $yPos += 30 + } + + $ok = New-Object System.Windows.Forms.Button + $ok.Text = "시작" + $ok.DialogResult = "OK" + $ok.Location = New-Object System.Drawing.Point(170, ($yPos + 10)) + $ok.Size = New-Object System.Drawing.Size(90, 30) + $form.AcceptButton = $ok + $form.Controls.Add($ok) | Out-Null + + $cancel = New-Object System.Windows.Forms.Button + $cancel.Text = "취소" + $cancel.DialogResult = "Cancel" + $cancel.Location = New-Object System.Drawing.Point(270, ($yPos + 10)) + $cancel.Size = New-Object System.Drawing.Size(90, 30) + $form.CancelButton = $cancel + $form.Controls.Add($cancel) | Out-Null + + $res = $form.ShowDialog($script:mainForm) + + if ($res -eq [System.Windows.Forms.DialogResult]::OK) { + foreach ($key in $labels.Keys) { + # AAD Connect 서버 이름은 필수가 아님 + if ([string]::IsNullOrWhiteSpace($controls[$key].Text) -and $key -ne 'AADConnectServerName') { + [System.Windows.Forms.MessageBox]::Show("'$($labels[$key])' 값은 비워둘 수 없습니다.", "입력 오류", "OK", "Warning") | Out-Null + return $false + } + $script:Configuration.$key = $controls[$key].Text.Trim() + } + try { + $script:Configuration | ConvertTo-Json -Depth 5 | Set-Content -Path $script:configFilePath -Encoding UTF8 + Write-Log "설정 정보 저장 완료." + } + catch { + Show-DetailedErrorDialog -ErrorRecord $_ -Message "설정 파일 저장 오류" + } + return $true + } + return $false +} + +# --- 스크립트 환경 초기화 함수 --- +# On-Premise AD에 연결하여 도메인 정보를 가져오고, 설정된 동기화 OU가 유효한지 확인 +function Initialize-ScriptEnvironment { + Write-Log "스크립트 환경 초기화 시작." + try { + $script:CurrentADDomainInfo = Get-ADDomain -Server $script:Configuration.OnPremDomainController -ErrorAction Stop + if ($script:CurrentADDomainInfo) { + $script:CurrentADDomainDN = $script:CurrentADDomainInfo.DistinguishedName + Write-Log "온프레미스 AD 도메인 정보 로드 성공: $($script:CurrentADDomainInfo.Name)" + $script:statusLabelOnPrem.Image = $script:iconGreen + $script:statusLabelOnPrem.Text = "On-Prem DC: $($script:Configuration.OnPremDomainController)" + + # 설정에 있는 동기화 OU 목록을 배열로 변환 + $ouNames = $script:Configuration.SynchronizedOUs -split ';' | ForEach-Object { $_.Trim() } + $script:SynchronizedOURoots = @() + + # 각 OU 이름에 대해 AD에서 실제 DistinguishedName을 찾아 저장 + foreach ($ouName in $ouNames) { + try { + $ouObject = Get-ADOrganizationalUnit -Filter "Name -eq '$ouName'" -SearchBase $script:CurrentADDomainDN -SearchScope Subtree -Server $script:Configuration.OnPremDomainController -ErrorAction Stop + if ($ouObject) { + $ouObject | ForEach-Object { + $script:SynchronizedOURoots += $_.DistinguishedName + Write-Log "동기화 OU 루트 추가: $($_.DistinguishedName)" + } + } + else { + Write-Log "설정에 명시된 OU '$ouName'을(를) AD에서 찾을 수 없습니다." -Level WARNING + } + } + catch { + Write-Log "설정에 명시된 OU '$ouName'을(를) 찾는 중 오류 발생: $($_.Exception.Message)" -Level WARNING + } + } + return $true + } + else { + throw "Get-ADDomain cmdlet이 도메인 정보를 반환하지 않았습니다." + } + } + catch { + $msg = "치명적 오류: 온프레미스 AD 도메인 정보를 가져올 수 없습니다." + Write-Log $msg -Level "ERROR" + $script:statusLabelOnPrem.Image = $script:iconRed + $script:statusLabelOnPrem.Text = "On-Prem DC: 연결 오류" + return $false + } +} + +# --- Azure AD 자동 연결 함수 --- +# 캐시된 자격 증명을 사용하여 사용자 개입 없이 Azure AD(Microsoft Graph)에 연결 시도 +function Connect-AzureAD-Silently { + Write-Log "Azure AD 자동 연결을 시도합니다..." + $context = try { + Connect-MgGraph -TenantId $script:Configuration.AzureTenantId + Get-MgContext + } + catch { + $null + } + + if ($context) { + Write-Log "Azure AD 자동 연결 성공." + $script:statusLabelAzure.Image = $script:iconGreen + $script:statusLabelAzure.Text = "Azure AD: 연결됨 ($($context.Account))" + Update-LicenseList + } + else { + Write-Log "캐시된 토큰이 없거나 만료되었습니다. 수동 연결이 필요합니다." -Level "WARNING" + $script:statusLabelAzure.Image = $script:iconRed + $script:statusLabelAzure.Text = "Azure AD: 수동 연결 필요" + } +} + +# --- Azure AD 수동 연결 함수 --- +# 사용자에게 로그인 창을 표시하여 Azure AD(Microsoft Graph)에 대화형으로 연결(2단계 인증 고려) +function Connect-AzureAD-WithInteraction { + $context = Invoke-Synchronous -TriggerControl $script:connectAzureADMenu -StatusMessage "Azure AD에 연결하는 중..." -ScriptBlock { + $requiredScopes = @("User.Read.All", "User.ReadWrite.All", "Directory.Read.All", "Organization.Read.All") + Connect-MgGraph -TenantId $script:Configuration.AzureTenantId -Scopes $requiredScopes + return Get-MgContext + } + + if ($context) { + Write-Log "Azure AD 연결 성공." + $script:statusLabelAzure.Image = $script:iconGreen + $script:statusLabelAzure.Text = "Azure AD: 연결됨 ($($context.Account))" + Update-LicenseList + return $true + } + else { + Write-Log "Azure AD 연결 실패 또는 취소됨." -Level "ERROR" + $script:statusLabelAzure.Image = $script:iconRed + $script:statusLabelAzure.Text = "Azure AD: 연결 오류" + return $false + } +} + +# --- M365 라이선스 목록 갱신 함수 --- +# Azure AD에서 구독 중인 SKU 정보를 가져와 '계정 생성' 탭의 라이선스 목록 업데이트 +function Update-LicenseList { + $licenses = Invoke-Synchronous -TriggerControl $null -StatusMessage "라이선스 목록 조회 중..." -ScriptBlock { + Get-MgSubscribedSku -All | Where-Object { $_.CapabilityStatus -eq "Enabled" } + } -RequiresAzureAD + + if ($licenses) { + $script:add_listBoxLicenses.Items.Clear() + + # 라이선스를 할당하지 않는 옵션을 기본으로 추가 + $noLicense = [PSCustomObject]@{ + DisplayName = "(라이선스 할당 안함)" + SkuId = $null + SkuObject = $null + } + $script:add_listBoxLicenses.Items.Add($noLicense) | Out-Null + + # 사용 가능한 라이선스를 목록에 추가 (남은 수량 표시) + $licenses | Where-Object { -not [string]::IsNullOrWhiteSpace($_.SkuPartNumber) } | Sort-Object SkuPartNumber | ForEach-Object { + $skuItem = $_ + $availableUnits = ($skuItem.PrepaidUnits.Enabled - $skuItem.ConsumedUnits) + $licenseDisplayName = "$($skuItem.SkuPartNumber) (남음: $availableUnits)" + $licenseObject = [PSCustomObject]@{ + DisplayName = $licenseDisplayName + SkuId = $skuItem.SkuId + SkuObject = $skuItem + } + $script:add_listBoxLicenses.Items.Add($licenseObject) | Out-Null + } + $script:add_listBoxLicenses.DisplayMember = "DisplayName" + Write-Log "[계정 생성] 라이선스 목록 갱신 완료." + } +} + +# --- AAD Connect 동기화 실행 함수 --- +# 로컬 또는 원격 AAD Connect 서버에서 동기화 주기를 시작하는 명령 실행 +function Invoke-AadConnectSync { + Param ([string]$PolicyType = "Delta") + + $syncSuccess = Invoke-Synchronous -TriggerControl $null -StatusMessage "AAD Connect 동기화 실행 중..." -ScriptBlock { + $server = $script:Configuration.AADConnectServerName + # 서버 이름이 없으면 로컬에서 실행, 있으면 원격으로 실행 + if ([string]::IsNullOrWhiteSpace($server)) { + Import-Module ADSync -ErrorAction SilentlyContinue + Start-ADSyncSyncCycle -PolicyType $PolicyType + } + else { + Invoke-Command -ComputerName $server -ScriptBlock { + param($p) + Import-Module ADSync -ErrorAction SilentlyContinue + Start-ADSyncSyncCycle -PolicyType $p + } -ArgumentList $PolicyType -ErrorAction Stop + } + return $true # 성공 시 true 반환 + } + + if ($syncSuccess) { + Write-Log "AAD Connect 동기화 명령 전송 완료. 클라우드 적용까지 시간이 소요될 수 있습니다." -Level SUCCESS + } + else { + Write-Log "AAD Connect 동기화 명령 실행에 실패했습니다." -Level ERROR + } +} + +# --- OU TreeView 빌드 함수 (재귀) --- +# AD의 OU 구조를 바탕으로 UI의 TreeView 컨트롤을 재귀적 구성 +function Build-OU-TreeView { + param ( + [string]$ParentDN, + [System.Windows.Forms.TreeNode]$ParentUiNode, + [hashtable]$OUHierarchy + ) + if (-not $OUHierarchy.ContainsKey($ParentDN)) { + return + } + foreach ($ouObject in $OUHierarchy[$ParentDN] | Sort-Object Name) { + $uiNode = New-Object System.Windows.Forms.TreeNode($ouObject.Name) + $uiNode.Tag = $ouObject.DistinguishedName + $ParentUiNode.Nodes.Add($uiNode) | Out-Null + # 자식 OU에 대해 재귀 호출 + Build-OU-TreeView -ParentDN $ouObject.DistinguishedName -ParentUiNode $uiNode -OUHierarchy $OUHierarchy + } +} + +# --- '계정 생성' 탭 초기화 함수 --- +# 계정 생성 탭의 모든 입력 필드를 초기 상태로 되돌리기 +function Reset-AddUserTab { + $script:add_textBoxLastNameKr.Text = "" + $script:add_textBoxFirstNameKr.Text = "" + $script:add_textBoxAccountNameEn.Text = "" + $script:add_textBoxSelectedOU.Text = "" + $script:add_checkedListBoxServicePlans.Items.Clear() + $script:add_labelOUSyncStatus.Text = "OU를 선택하면 동기화 상태를 안내합니다." + $script:add_labelOUSyncStatus.ForeColor = [System.Drawing.Color]::Black + $script:add_pictureBoxAccountValidation.Visible = $false + if ($script:add_treeViewOUs.Nodes.Count -gt 0 -and $script:add_treeViewOUs.SelectedNode) { + $script:add_treeViewOUs.SelectedNode = $null + } + Update-LicenseList +} + +# --- '계정 삭제' 탭 초기화 함수 --- +# 계정 삭제 탭의 모든 입력 필드와 목록을 초기 상태로 되돌리기 +function Reset-DeleteUserTab { + $script:del_textBoxSearchUser.Text = "" + $script:del_listBoxFoundUsers.Items.Clear() + $script:del_textBoxFoundUserDisplay.Text = "표시 이름:" + $script:del_textBoxFoundUserUPN.Text = "UPN:" + $script:del_deleteQueue.Clear() + $script:del_listBoxDeleteQueue.Items.Clear() + $script:del_buttonExecuteDelete.Enabled = $false + $script:del_buttonAddToDeleteList.Enabled = $false +} + +# --- '하드 매칭' 탭 초기화 함수 --- +# 하드 매칭 탭의 모든 입력 필드와 상태 표시를 초기 상태로 되돌리기 +function Reset-HardMatchTab { + $script:hm_textBoxSearchAzure.Text = "" + $script:hm_listBoxFoundAzure.Items.Clear() + $script:hm_textBoxAzureUser.Text = "" + $script:hm_textBoxImmutableId.Text = "" + $script:hm_labelGuidMatchStatus.Visible = $false + $script:hm_textBoxOnPremUser.Text = "" + $script:hm_textBoxObjectGuid.Text = "" + $script:hm_textBoxConvertedImmutableId.Text = "" + if ($script:hm_textBoxOnPremUser.Parent) { + $script:hm_textBoxOnPremUser.Parent.Visible = $false + } + $script:hm_textBoxFirstNameKr.Text = "" + $script:hm_textBoxLastNameKr.Text = "" + $script:hm_textBoxSelectedOU.Text = "" + if ($script:hm_textBoxSelectedOU) { + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::White + } + if ($script:hm_treeViewOUs) { + $script:hm_treeViewOUs.Nodes.Clear() + } + if ($script:hm_groupCreate) { + $script:hm_groupCreate.Visible = $false + } + $script:hm_buttonExecuteAction.Enabled = $false + $script:hm_buttonExecuteAction.Text = "작업 실행" +} + +#endregion + +Write-Host "Common.ps1 로드 완료." -ForegroundColor Cyan \ No newline at end of file diff --git a/Scripts/UI-Tab-AddUser.ps1 b/Scripts/UI-Tab-AddUser.ps1 new file mode 100644 index 0000000..716edb3 --- /dev/null +++ b/Scripts/UI-Tab-AddUser.ps1 @@ -0,0 +1,490 @@ +# ================================================================================ +# 파일: Scripts/UI-Tab-AddUser.ps1 +# 역할: '계정 생성' 탭 UI 및 기능 구현 +# +# 작성자: 양범진 +# 버전: 1.13 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-12 +# +# 설명: +# - '계정 생성' 탭의 UI 컨트롤들을 생성하고 배치 +# - 사용자 정보 입력, OU 선택, 라이선스 할당 등 계정 생성과 관련된 모든 로직 처리 +# - 코드 가독성 및 유지보수 편의성을 위해 전체적으로 포매팅 및 주석 재정리 +# ================================================================================ + +#region '계정 생성' 탭 UI 초기화 함수 + +# --- '계정 생성' 탭의 모든 UI 컨트롤을 생성하고 배치하는 메인 함수 --- +function Initialize-AddUserTab { + Param([System.Windows.Forms.TabPage]$parentTab) + + # 메인 레이아웃: 전체 탭을 2열 3행으로 구성 + $addTabMainLayout = New-Object System.Windows.Forms.TableLayoutPanel + $addTabMainLayout.Dock = "Fill" + $addTabMainLayout.Padding = [System.Windows.Forms.Padding](10) + $addTabMainLayout.ColumnCount = 2 + $addTabMainLayout.RowCount = 3 + $addTabMainLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $addTabMainLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $addTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 80))) | Out-Null + $addTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $addTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 60))) | Out-Null + $parentTab.Controls.Add($addTabMainLayout) | Out-Null + + # 상단: 작업 모드 선택 (AD/Azure AD 동기화 vs On-Premise AD만 생성) + $groupMode = New-Object System.Windows.Forms.GroupBox + $groupMode.Text = "작업 모드 선택" + $groupMode.Dock = "Fill" + $addTabMainLayout.Controls.Add($groupMode, 0, 0) | Out-Null + $addTabMainLayout.SetColumnSpan($groupMode, 2) + + $script:add_radioSync = New-Object System.Windows.Forms.RadioButton + $script:add_radioSync.Text = "AD/Azure AD 동기화 및 라이선스 할당" + $script:add_radioSync.Location = New-Object System.Drawing.Point(25, 30) + $script:add_radioSync.AutoSize = $true + $script:add_radioSync.Checked = $true + $groupMode.Controls.Add($script:add_radioSync) | Out-Null + + $script:add_radioOnPremOnly = New-Object System.Windows.Forms.RadioButton + $script:add_radioOnPremOnly.Text = "온프레미스 AD에만 생성" + $script:add_radioOnPremOnly.Location = New-Object System.Drawing.Point(450, 30) + $script:add_radioOnPremOnly.AutoSize = $true + $groupMode.Controls.Add($script:add_radioOnPremOnly) | Out-Null + + # 왼쪽 패널: 사용자 정보 입력 및 OU 선택 영역 + $leftPanelLayout = New-Object System.Windows.Forms.TableLayoutPanel + $leftPanelLayout.Dock = "Fill" + $leftPanelLayout.RowCount = 2 + $leftPanelLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 180))) | Out-Null + $leftPanelLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $addTabMainLayout.Controls.Add($leftPanelLayout, 0, 1) | Out-Null + + # 왼쪽 패널 (상단): 사용자 정보 입력 그룹박스 + $groupUserInfo = New-Object System.Windows.Forms.GroupBox + $groupUserInfo.Text = "1. 사용자 정보" + $groupUserInfo.Dock = "Fill" + $leftPanelLayout.Controls.Add($groupUserInfo, 0, 0) | Out-Null + + $userInfoLayout = New-Object System.Windows.Forms.TableLayoutPanel + $userInfoLayout.Dock = "Fill" + $userInfoLayout.Padding = [System.Windows.Forms.Padding](10) + $userInfoLayout.ColumnCount = 2 + $userInfoLayout.RowCount = 4 + $userInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 110))) | Out-Null + $userInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $rowHeight = 35 + $userInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $userInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $userInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $userInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $groupUserInfo.Controls.Add($userInfoLayout) | Out-Null + + # 사용자 정보 입력 컨트롤들 (한글 성/이름, 영문 계정명, 비밀번호) + $labelLastNameKr = New-Object System.Windows.Forms.Label + $labelLastNameKr.Text = "한글 성:" + $labelLastNameKr.Dock = "Fill" + $labelLastNameKr.TextAlign = "MiddleLeft" + + $script:add_textBoxLastNameKr = New-Object System.Windows.Forms.TextBox + $script:add_textBoxLastNameKr.Dock = "Fill" + + $labelFirstNameKr = New-Object System.Windows.Forms.Label + $labelFirstNameKr.Text = "한글 이름:" + $labelFirstNameKr.Dock = "Fill" + $labelFirstNameKr.TextAlign = "MiddleLeft" + + $script:add_textBoxFirstNameKr = New-Object System.Windows.Forms.TextBox + $script:add_textBoxFirstNameKr.Dock = "Fill" + + $labelAccountNameEn = New-Object System.Windows.Forms.Label + $labelAccountNameEn.Text = "영문 계정명:" + $labelAccountNameEn.Dock = "Fill" + $labelAccountNameEn.TextAlign = "MiddleLeft" + + # 영문 계정명 입력란 + 유효성 검사 아이콘 패널 + $accountNamePanel = New-Object System.Windows.Forms.TableLayoutPanel + $accountNamePanel.Dock = "Fill" + $accountNamePanel.ColumnCount = 2 + $accountNamePanel.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $accountNamePanel.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 22))) | Out-Null + $accountNamePanel.Margin = [System.Windows.Forms.Padding](0) + + $script:add_textBoxAccountNameEn = New-Object System.Windows.Forms.TextBox + $script:add_textBoxAccountNameEn.Dock = "Fill" + + $script:add_pictureBoxAccountValidation = New-Object System.Windows.Forms.PictureBox + $script:add_pictureBoxAccountValidation.Dock = "Fill" + $script:add_pictureBoxAccountValidation.SizeMode = "CenterImage" + $script:add_pictureBoxAccountValidation.Visible = $false + + $script:add_toolTip = New-Object System.Windows.Forms.ToolTip + + $accountNamePanel.Controls.AddRange(@($script:add_textBoxAccountNameEn, $script:add_pictureBoxAccountValidation)) | Out-Null + + $labelPassword = New-Object System.Windows.Forms.Label + $labelPassword.Text = "초기 비밀번호:" + $labelPassword.Dock = "Fill" + $labelPassword.TextAlign = "MiddleLeft" + + $script:add_textBoxPassword = New-Object System.Windows.Forms.TextBox + $script:add_textBoxPassword.Dock = "Fill" + $script:add_textBoxPassword.Text = $script:Configuration.DefaultPassword + + $userInfoLayout.Controls.Add($labelLastNameKr, 0, 0) | Out-Null + $userInfoLayout.Controls.Add($script:add_textBoxLastNameKr, 1, 0) | Out-Null + $userInfoLayout.Controls.Add($labelFirstNameKr, 0, 1) | Out-Null + $userInfoLayout.Controls.Add($script:add_textBoxFirstNameKr, 1, 1) | Out-Null + $userInfoLayout.Controls.Add($labelAccountNameEn, 0, 2) | Out-Null + $userInfoLayout.Controls.Add($accountNamePanel, 1, 2) | Out-Null + $userInfoLayout.Controls.Add($labelPassword, 0, 3) | Out-Null + $userInfoLayout.Controls.Add($script:add_textBoxPassword, 1, 3) | Out-Null + + # 왼쪽 패널 (하단): OU 선택 그룹박스 + $groupOU = New-Object System.Windows.Forms.GroupBox + $groupOU.Text = "2. 대상 OU 선택" + $groupOU.Dock = "Fill" + $leftPanelLayout.Controls.Add($groupOU, 0, 1) | Out-Null + + $ouLayout = New-Object System.Windows.Forms.TableLayoutPanel + $ouLayout.Dock = "Fill" + $ouLayout.Padding = [System.Windows.Forms.Padding](10) + $ouLayout.ColumnCount = 2 + $ouLayout.RowCount = 3 + $ouLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $ouLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 40))) | Out-Null + $ouLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 35))) | Out-Null + $ouLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $ouLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 25))) | Out-Null + $groupOU.Controls.Add($ouLayout) | Out-Null + + # OU 선택 컨트롤들 (선택된 OU 표시, 새로고침 버튼, TreeView, 동기화 상태 라벨) + $script:add_textBoxSelectedOU = New-Object System.Windows.Forms.TextBox + $script:add_textBoxSelectedOU.Dock = "Fill" + $script:add_textBoxSelectedOU.ReadOnly = $true + $script:add_textBoxSelectedOU.Text = "아래에서 OU를 선택하세요." + + $buttonRefreshOUs = New-Object System.Windows.Forms.Button + $buttonRefreshOUs.Text = "🔄" + $buttonRefreshOUs.Dock = "Fill" + $buttonRefreshOUs.Font = New-Object System.Drawing.Font("Segoe UI Emoji", 10) + + $script:add_treeViewOUs = New-Object System.Windows.Forms.TreeView + $script:add_treeViewOUs.Dock = "Fill" + $script:add_treeViewOUs.HideSelection = $false + + $script:add_labelOUSyncStatus = New-Object System.Windows.Forms.Label + $script:add_labelOUSyncStatus.Dock = "Fill" + $script:add_labelOUSyncStatus.TextAlign = "MiddleLeft" + $script:add_labelOUSyncStatus.Text = "OU를 선택하면 동기화 상태를 안내합니다." + + $ouLayout.Controls.Add($script:add_textBoxSelectedOU, 0, 0) | Out-Null + $ouLayout.Controls.Add($buttonRefreshOUs, 1, 0) | Out-Null + $ouLayout.Controls.Add($script:add_treeViewOUs, 0, 1) | Out-Null + $ouLayout.SetColumnSpan($script:add_treeViewOUs, 2) + $ouLayout.Controls.Add($script:add_labelOUSyncStatus, 0, 2) | Out-Null + $ouLayout.SetColumnSpan($script:add_labelOUSyncStatus, 2) + + # 오른쪽 패널: M365 라이선스 및 서비스 플랜 할당 영역 + $script:add_groupLicense = New-Object System.Windows.Forms.GroupBox + $script:add_groupLicense.Text = "3. M365 라이선스 및 서비스 플랜 할당" + $script:add_groupLicense.Dock = "Fill" + $addTabMainLayout.Controls.Add($script:add_groupLicense, 1, 1) | Out-Null + + $licenseLayout = New-Object System.Windows.Forms.TableLayoutPanel + $licenseLayout.Dock = "Fill" + $licenseLayout.Padding = [System.Windows.Forms.Padding](10) + $licenseLayout.RowCount = 2 + $licenseLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $licenseLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $script:add_groupLicense.Controls.Add($licenseLayout) | Out-Null + + # 라이선스 목록 및 서비스 플랜 목록 컨트롤 + $script:add_listBoxLicenses = New-Object System.Windows.Forms.ListBox + $script:add_listBoxLicenses.Dock = "Fill" + + $script:add_checkedListBoxServicePlans = New-Object System.Windows.Forms.CheckedListBox + $script:add_checkedListBoxServicePlans.Dock = "Fill" + $script:add_checkedListBoxServicePlans.CheckOnClick = $true + + $licenseLayout.Controls.Add($script:add_listBoxLicenses, 0, 0) | Out-Null + $licenseLayout.Controls.Add($script:add_checkedListBoxServicePlans, 0, 1) | Out-Null + + # 하단: 계정 생성 실행 버튼 + $script:add_buttonCreate = New-Object System.Windows.Forms.Button + $script:add_buttonCreate.Text = "계정 생성 실행(&R)" + $script:add_buttonCreate.Dock = "Fill" + $script:add_buttonCreate.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) + $addTabMainLayout.Controls.Add($script:add_buttonCreate, 0, 2) | Out-Null + $addTabMainLayout.SetColumnSpan($script:add_buttonCreate, 2) + + # 이벤트 핸들러 연결 + $script:add_radioSync.add_CheckedChanged($add_ModeChangedHandler) + $script:add_radioOnPremOnly.add_CheckedChanged($add_ModeChangedHandler) + $script:add_textBoxAccountNameEn.Add_TextChanged($add_AccountNameEn_TextChanged) + $script:add_textBoxAccountNameEn.Add_Leave($add_AccountNameEn_Leave) + $buttonRefreshOUs.Add_Click({ Update-OU-TreeView -TriggerControl $buttonRefreshOUs }) + $script:add_treeViewOUs.Add_AfterSelect($add_treeViewOUs_AfterSelect) + $script:add_listBoxLicenses.Add_SelectedIndexChanged($add_listBoxLicenses_SelectedIndexChanged) + $script:add_buttonCreate.Add_Click($add_buttonCreate_Click) +} +#endregion + +#region '계정 생성' 탭 이벤트 핸들러 및 함수 + +# --- OU TreeView를 로드 및 갱신하는 함수 --- +function Update-OU-TreeView { + Param($TriggerControl) + + $allOUs = Invoke-Synchronous -TriggerControl $TriggerControl -StatusMessage "[계정 생성] OU 목록을 조회합니다..." -ScriptBlock { + Get-ADOrganizationalUnit -Filter * -SearchBase $script:CurrentADDomainDN -SearchScope Subtree -Server $script:Configuration.OnPremDomainController -ErrorAction SilentlyContinue + } + + if ($allOUs) { + # OU 목록을 부모-자식 관계의 해시테이블로 재구성 + $ouHierarchy = @{} + foreach ($ou in $allOUs) { + $parentDN = $ou.DistinguishedName -replace '^OU=[^,]+,', '' + if (-not $ouHierarchy.ContainsKey($parentDN)) { + $ouHierarchy[$parentDN] = [System.Collections.ArrayList]::new() + } + $ouHierarchy[$parentDN].Add($ou) | Out-Null + } + + # TreeView 업데이트 시작 (UI 깜빡임 방지) + $script:add_treeViewOUs.BeginUpdate() + $script:add_treeViewOUs.Nodes.Clear() + + # 최상위 루트 노드 추가 + $rootNode = New-Object System.Windows.Forms.TreeNode($script:CurrentADDomainInfo.DnsRoot) + $rootNode.Tag = $script:CurrentADDomainDN + $script:add_treeViewOUs.Nodes.Add($rootNode) | Out-Null + + # 재귀 함수를 통해 OU 계층 구조 빌드 + Build-OU-TreeView -ParentDN $script:CurrentADDomainDN -ParentUiNode $rootNode -OUHierarchy $ouHierarchy + + $rootNode.Expand() + + # 로드 완료 후 선택된 노드 해제 + if ($script:add_treeViewOUs.SelectedNode) { + $script:add_treeViewOUs.SelectedNode = $null + } + $script:add_treeViewOUs.EndUpdate() + Write-Log "[계정 생성] OU 목록 로드/갱신 완료." + } +} + +# --- 작업 모드 변경 이벤트 핸들러 --- +# 라디오 버튼 선택에 따라 라이선스 할당 영역의 활성화 여부와 실행 버튼 텍스트 변경 +$add_ModeChangedHandler = { + param($src, $e) + if (-not $src.Checked) { return } + + $isSyncMode = $script:add_radioSync.Checked + $script:add_groupLicense.Enabled = $isSyncMode + + if ($isSyncMode) { + $script:add_buttonCreate.Text = "계정 생성 및 할당 실행(&R)" + } + else { + $script:add_buttonCreate.Text = "온프레미스 계정 생성 실행(&R)" + } +} + +# --- 영문 계정명 텍스트 변경 이벤트 핸들러 --- +# 사용자가 계정명을 입력할 때 유효성 검사 아이콘을 '확인 중' 상태로 표시 +$add_AccountNameEn_TextChanged = { + $accountName = $script:add_textBoxAccountNameEn.Text + if ([string]::IsNullOrWhiteSpace($accountName)) { + $script:add_pictureBoxAccountValidation.Visible = $false + return + } + $script:add_pictureBoxAccountValidation.Image = $script:iconGreen + $script:add_toolTip.SetToolTip($script:add_pictureBoxAccountValidation, "확인 중...") + $script:add_pictureBoxAccountValidation.Visible = $true +} + +# --- 영문 계정명 입력란 포커스 잃음 이벤트 핸들러 --- +# 입력이 완료되면 계정명의 유효성과 중복 여부를 검사하고 결과를 아이콘과 툴팁으로 표시 +$add_AccountNameEn_Leave = { + $accountName = $script:add_textBoxAccountNameEn.Text + if ([string]::IsNullOrWhiteSpace($accountName)) { + $script:add_pictureBoxAccountValidation.Visible = $false + return + } + + # Azure AD 동기화 모드일 때만 Azure AD 중복 확인을 수행 + $isSyncMode = $script:add_radioSync.Checked + $validationResult = Test-SamAccountName -AccountName $accountName -CheckAzureAD:$isSyncMode + + if ($validationResult.IsValid) { + $script:add_pictureBoxAccountValidation.Image = $script:iconGreen + } + else { + $script:add_pictureBoxAccountValidation.Image = $script:iconRed + } + $script:add_toolTip.SetToolTip($script:add_pictureBoxAccountValidation, $validationResult.Reason) + $script:add_pictureBoxAccountValidation.Visible = $true +} + +# --- OU TreeView 노드 선택 이벤트 핸들러 --- +# OU를 선택하면 해당 OU의 DN을 텍스트박스에 표시하고, 동기화 대상 여부를 확인하여 상태 라벨에 표시 +$add_treeViewOUs_AfterSelect = { + param($source, $e) + if ($e.Node -ne $null -and $e.Node.Tag) { + $selectedOUDN = $e.Node.Tag.ToString() + # 루트 도메인은 선택 불가 처리 + if ($selectedOUDN -eq $script:CurrentADDomainDN) { + $script:add_textBoxSelectedOU.Text = "OU를 선택해야 합니다." + $script:add_labelOUSyncStatus.Text = "루트 도메인은 선택할 수 없습니다." + $script:add_labelOUSyncStatus.ForeColor = [System.Drawing.Color]::Red + return + } + + $script:add_textBoxSelectedOU.Text = $selectedOUDN + $isSynchronized = $false + # 설정된 동기화 OU 목록과 비교 + foreach ($syncRootOU in $script:SynchronizedOURoots) { + if ($selectedOUDN -eq $syncRootOU -or $selectedOUDN.EndsWith(",$syncRootOU", "OrdinalIgnoreCase")) { + $isSynchronized = $true + break + } + } + + if ($isSynchronized) { + $script:add_labelOUSyncStatus.Text = "선택된 OU는 동기화 대상입니다." + $script:add_labelOUSyncStatus.ForeColor = [System.Drawing.Color]::Green + } + else { + $script:add_labelOUSyncStatus.Text = "주의: 선택된 OU는 동기화 대상이 아닐 수 있습니다!" + $script:add_labelOUSyncStatus.ForeColor = [System.Drawing.Color]::Red + } + } +} + +# --- 라이선스 목록 선택 이벤트 핸들러 --- +# 라이선스를 선택하면 해당 라이선스에 포함된 서비스 플랜 목록을 가져와 아래쪽 체크리스트에 표시 +$add_listBoxLicenses_SelectedIndexChanged = { + $script:add_checkedListBoxServicePlans.Items.Clear() + $selectedItem = $script:add_listBoxLicenses.SelectedItem + + # "(라이선스 할당 안함)"을 선택했거나 선택된 항목이 없으면 함수 종료 + if ($null -eq $selectedItem -or $null -eq $selectedItem.SkuId) { + return + } + + Write-Log "'$($selectedItem.DisplayName)' 라이선스의 서비스 플랜을 조회합니다." + $allPlans = $selectedItem.SkuObject.ServicePlans | Sort-Object ServicePlanName + foreach ($plan in $allPlans) { + # 모든 플랜을 기본적으로 체크된 상태로 추가 + $script:add_checkedListBoxServicePlans.Items.Add($plan, $true) | Out-Null + } + $script:add_checkedListBoxServicePlans.DisplayMember = "ServicePlanName" +} + +# --- '계정 생성 실행' 버튼 클릭 이벤트 핸들러 --- +# 입력된 정보를 바탕으로 실제 계정 생성 작업을 수행하는 메인 로직 +$add_buttonCreate_Click = { + # 1. 필수 값 확인 + $lastNameKr = $script:add_textBoxLastNameKr.Text.Trim() + $firstNameKr = $script:add_textBoxFirstNameKr.Text.Trim() + $accountNameEn = $script:add_textBoxAccountNameEn.Text.Trim() + $targetOUDN = $script:add_textBoxSelectedOU.Text.Trim() + $passwordInput = $script:add_textBoxPassword.Text + $isSyncMode = $script:add_radioSync.Checked + + if (-not ($lastNameKr -and $firstNameKr -and $accountNameEn -and $passwordInput)) { + [System.Windows.Forms.MessageBox]::Show("사용자 정보(한글 성/이름, 영문 계정명)와 비밀번호는 필수 입력 항목입니다.", "입력 오류", "OK", "Warning") | Out-Null + return + } + if ($targetOUDN -eq "아래에서 OU를 선택하세요." -or $targetOUDN -eq "OU를 선택해야 합니다." -or [string]::IsNullOrWhiteSpace($targetOUDN)) { + [System.Windows.Forms.MessageBox]::Show("대상 OU를 선택해야 합니다.", "입력 오류", "OK", "Warning") | Out-Null + return + } + + # 생성 직전 최종 유효성 검사 (Azure AD 중복 확인 포함) + $validationResult = Test-SamAccountName -AccountName $accountNameEn -CheckAzureAD:$isSyncMode + if (-not $validationResult.IsValid) { + $message = "계정명 유효성 검사를 통과하지 못했습니다.`n사유: $($validationResult.Reason)" + [System.Windows.Forms.MessageBox]::Show($message, "입력 오류", "OK", "Warning") | Out-Null + return + } + + # 2. 메인 작업 실행 (Invoke-Synchronous 래퍼 함수 사용) + $success = Invoke-Synchronous -TriggerControl $this -StatusMessage "계정 생성 작업을 실행합니다..." -ScriptBlock { + # 2.1. On-Premise AD 사용자 생성 + $userPrincipalName = "$accountNameEn@$($script:Configuration.UPNSuffix)" + $commonName = "$lastNameKr$firstNameKr [$accountNameEn]" + Write-Log "온프레미스 AD 사용자 생성을 시작합니다: $accountNameEn" + + New-ADUser -SamAccountName $accountNameEn ` + -UserPrincipalName $userPrincipalName ` + -Name $commonName ` + -DisplayName $commonName ` + -GivenName $firstNameKr ` + -Surname $lastNameKr ` + -Path $targetOUDN ` + -AccountPassword (ConvertTo-SecureString $passwordInput -AsPlainText -Force) ` + -Enabled $true ` + -ChangePasswordAtLogon $false ` + -EmailAddress $userPrincipalName ` + -Server $script:Configuration.OnPremDomainController ` + -PassThru ` + -ErrorAction Stop + + Write-Log "온프레미스 AD 사용자 '$accountNameEn' 생성 성공." + + if (-not $isSyncMode) { return $true } # On-Premise 전용 모드일 경우 여기서 작업 종료 + + # 2.2. 동기화 실행 및 Azure AD에서 사용자 확인 + Invoke-AadConnectSync -PolicyType Delta + Write-Log "Azure AD에서 사용자 '$userPrincipalName' 확인 시도 (최대 120초 대기)..." + $azureAdUser = $null + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt 120) { + Write-Log "Azure AD에서 사용자 확인 중... ($([math]::Round($stopwatch.Elapsed.TotalSeconds))초 경과)" + $azureAdUser = Get-MgUser -Filter "userPrincipalName eq '$userPrincipalName'" -ErrorAction SilentlyContinue + if ($azureAdUser) { break } + Start-Sleep -Seconds 10 + } + $stopwatch.Stop() + if (-not $azureAdUser) { throw "제한 시간(120초) 내에 Azure AD에서 사용자를 찾지 못했습니다." } + + $elapsedSeconds = [math]::Round($stopwatch.Elapsed.TotalSeconds) + Write-Log "사용자 '$userPrincipalName'을(를) Azure AD에서 찾았습니다! (소요 시간: ${elapsedSeconds}초)" -Level SUCCESS + + # 2.3. 사용 위치(Usage Location) 및 라이선스 할당 + Write-Log "사용 위치 설정 중: $($script:Configuration.DefaultUsageLocation)" + Update-MgUser -UserId $azureAdUser.UserPrincipalName -UsageLocation $script:Configuration.DefaultUsageLocation -ErrorAction Stop + + $selectedLicenseItem = $script:add_listBoxLicenses.SelectedItem + if ($selectedLicenseItem -and $selectedLicenseItem.SkuId) { + Write-Log "라이선스 할당 중: $($selectedLicenseItem.DisplayName)" + $disabledPlans = [System.Collections.Generic.List[string]]::new() + # 체크 해제된 서비스 플랜 목록을 구성 + for ($i = 0; $i -lt $script:add_checkedListBoxServicePlans.Items.Count; $i++) { + if (-not $script:add_checkedListBoxServicePlans.GetItemChecked($i)) { + $servicePlanId = $script:add_checkedListBoxServicePlans.Items[$i].ServicePlanId + $disabledPlans.Add($servicePlanId) + } + } + # 라이선스 할당 API 호출 + $licenseBody = @{ + AddLicenses = @(@{SkuId = $selectedLicenseItem.SkuId; DisabledPlans = $disabledPlans}) + RemoveLicenses = @() + } + Set-MgUserLicense -UserId $azureAdUser.UserPrincipalName -BodyParameter $licenseBody -ErrorAction Stop + Write-Log "라이선스 및 서비스 플랜 할당 완료." + } + return $true + } -RequiresAzureAD:$isSyncMode + + # 3. 최종 결과 처리 + if ($success) { + [System.Windows.Forms.MessageBox]::Show("모든 작업이 성공적으로 완료되었습니다.", "작업 완료", "OK", "Information") | Out-Null + Reset-AddUserTab + } +} +#endregion + +Write-Host "UI-Tab-AddUser.ps1 로드 완료." -ForegroundColor Cyan \ No newline at end of file diff --git a/Scripts/UI-Tab-BatchTask.ps1 b/Scripts/UI-Tab-BatchTask.ps1 new file mode 100644 index 0000000..ff8ffec --- /dev/null +++ b/Scripts/UI-Tab-BatchTask.ps1 @@ -0,0 +1,619 @@ +# ================================================================================ +# 파일: Scripts/UI-Tab-BatchTask.ps1 +# 역할: '일괄 작업 (CSV)' 탭 UI 및 기능 구현 (리팩토링 최종 완전판) +# +# 작성자: 양범진 +# 버전: 1.13 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-12 +# +# 설명: +# - 가독성 향상을 위해 코드 포매팅, 주석 추가, 불필요한 콘솔 출력 제거. +# - 누락된 함수 정의를 복원하여 스크립트 실행 오류 최종 해결. +# - 모든 기능이 포함된 최종 안정화 버전. +# ================================================================================ + +#region '일괄 작업' 탭 UI 초기화 및 이벤트 핸들러 + +# -------------------------------------------------------------------------------- +# 헬퍼 함수 정의 +# -------------------------------------------------------------------------------- + +# 선택된 작업 유형에 따라 안내 메시지를 업데이트하는 함수 +function Update-BatchGuide { + param($taskType) + + # 작업 유형에 따라 다른 안내 텍스트를 설정 + $guideText = switch ($taskType) { + "계정 생성" { + @" +** CSV로 계정 생성하기 ** + +'템플릿 다운로드'로 받은 CSV 파일을 아래 형식에 맞게 작성하세요. + +- **LastNameKr**: 사용자의 한글 성 (예: 홍) +- **FirstNameKr**: 사용자의 한글 이름 (예: 길동) +- **AccountNameEn**: 로그인 ID (SamAccountName). 아래 규칙을 따라야 합니다. + - 소문자 영문과 숫자만 사용 가능 + - 3자 이상, 20자 이하 +- **Password**: 사용자의 초기 비밀번호. +- **OU_Name**: 사용자가 생성될 조직 단위(OU)의 경로. + - 최상위 OU: `Dev` + - 하위 OU: `상위OU/하위OU` (예: `Dev/Test001`) +"@ + } + "계정 삭제" { + @" +** CSV로 계정 삭제하기 ** + +'템플릿 다운로드'로 받은 CSV 파일을 아래 형식에 맞게 작성하세요. + +- **SamAccountName**: 삭제할 사용자의 로그인 ID. +"@ + } + } + # 안내 레이블의 텍스트를 업데이트 + $script:batch_labelGuide.Text = $guideText.Trim() +} + +# 'Dev/Test'와 같은 OU 경로를 'OU=Test,OU=Dev,DC=...' 형식의 DN(Distinguished Name)으로 변환하는 함수 +function Convert-OUPathToDN { + param([string]$OUPath) + if (-not $OUPath) { + return $null + } + # '/'를 기준으로 경로를 분리하고, 빈 항목은 제거 + $ouParts = $OUPath.Split('/') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + # AD DN 형식에 맞게 순서를 뒤집음 (하위 OU가 먼저 오도록) + [array]::Reverse($ouParts) + # 각 부분을 'OU=이름' 형식으로 변환 + $ouDNs = $ouParts | ForEach-Object { "OU=$_" } + # 쉼표로 연결하고 기본 도메인 DN을 추가하여 최종 DN 완성 + return ($ouDNs -join ",") + ",$($script:CurrentADDomainDN)" +} + +# -------------------------------------------------------------------------------- +# 이벤트 핸들러 정의 (스크립트 블록) +# -------------------------------------------------------------------------------- + +# '작업 유형' 콤보박스 선택이 변경될 때 호출 +$batch_TaskType_Changed = { + # UI 컨트롤 상태 초기화 + $script:batch_dataGridView.DataSource = $null + $script:batch_textBoxCsvPath.Text = "" + $script:batch_buttonValidate.Enabled = $false + $script:batch_buttonExecute.Enabled = $false + $script:batch_progressBar.Value = 0 + $script:batch_dataGridView.Visible = $false + $script:batch_labelGuide.Visible = $true + + # 새 작업 유형에 맞는 안내 가이드 표시 + Update-BatchGuide -taskType $script:batch_comboBoxTaskType.SelectedItem.ToString() +} + +# '템플릿 다운로드' 버튼 클릭 시 호출 +$batch_buttonDownloadTemplate_Click = { + $taskType = $script:batch_comboBoxTaskType.SelectedItem.ToString() + $headers = "" + $sampleData = "" + + # 작업 유형에 따라 CSV 헤더와 샘플 데이터 설정 + switch ($taskType) { + "계정 생성" { + $headers = "LastNameKr,FirstNameKr,AccountNameEn,Password,OU_Name" + $sampleData = "김,가네,mrkim,Password123!,Dev/Test001" + } + "계정 삭제" { + $headers = "SamAccountName" + $sampleData = "testuser" + } + default { + return # 지원하지 않는 작업 유형이면 종료 + } + } + + # 파일 저장 대화상자 생성 + $sfd = New-Object System.Windows.Forms.SaveFileDialog + $sfd.Filter = "CSV 파일 (*.csv)|*.csv" + $sfd.FileName = "$($taskType)_template.csv" + + if ($sfd.ShowDialog() -eq "OK") { + try { + # 헤더와 샘플 데이터를 포함한 CSV 콘텐츠 생성 + $csvContent = @($headers, $sampleData) + # UTF8 인코딩으로 파일 저장 + Set-Content -Path $sfd.FileName -Value $csvContent -Encoding UTF8 + [System.Windows.Forms.MessageBox]::Show("샘플 데이터가 포함된 템플릿 파일이 저장되었습니다.`n$($sfd.FileName)", "저장 완료", "OK", "Information") + } + catch { + # 파일 저장 중 오류 발생 시 상세 오류 대화상자 표시 + Show-DetailedErrorDialog -ErrorRecord $_ + } + } +} + +# '파일 찾아보기' 버튼 클릭 시 호출 +$batch_buttonBrowseCsv_Click = { + # 파일 열기 대화상자 생성 + $ofd = New-Object System.Windows.Forms.OpenFileDialog + $ofd.Filter = "CSV 파일 (*.csv)|*.csv" + + if ($ofd.ShowDialog() -eq "OK") { + try { + # CSV 파일의 첫 줄(헤더)을 읽어옴 + $headersInFile = (Get-Content -Path $ofd.FileName -TotalCount 1).Split(',') | ForEach-Object { $_.Trim('"') } + $taskType = $script:batch_comboBoxTaskType.SelectedItem.ToString() + + # 현재 선택된 작업에 필요한 헤더 정의 + $requiredHeaders = if ($taskType -eq "계정 생성") { @("LastNameKr", "FirstNameKr", "AccountNameEn", "Password", "OU_Name") } else { @("SamAccountName") } + + # 파일의 헤더와 필요한 헤더가 일치하는지 비교 + if (($headersInFile | Compare-Object -ReferenceObject $requiredHeaders -SyncWindow 0).Length -ne 0) { + [System.Windows.Forms.MessageBox]::Show("CSV 파일의 헤더가 선택된 작업 유형과 일치하지 않습니다.`n템플릿을 다운로드하여 형식을 확인해주세요.", "CSV 형식 오류", "OK", "Warning") + return + } + + # 파일 경로 텍스트박스 업데이트 및 CSV 파일 가져오기 + $script:batch_textBoxCsvPath.Text = $ofd.FileName + $csvData = Import-Csv -Path $ofd.FileName -Encoding UTF8 + + if ($csvData.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show("CSV 파일에 데이터가 없습니다.", "알림", "OK", "Information") + return + } + + # CSV 데이터를 표시할 DataTable 객체 생성 + $dt = New-Object System.Data.DataTable + $csvData[0].PSObject.Properties.Name | ForEach-Object { $dt.Columns.Add($_) } | Out-Null + $dt.Columns.Add("결과") | Out-Null # 결과 표시를 위한 열 추가 + + # CSV의 각 행을 DataTable에 추가 + foreach ($row in $csvData) { + $dr = $dt.NewRow() + foreach ($prop in $row.PSObject.Properties) { + $dr[$prop.Name] = $prop.Value + } + $dt.Rows.Add($dr) + } + + # 데이터 그리드뷰에 DataTable 바인딩 및 UI 상태 업데이트 + $script:batch_dataGridView.DataSource = $dt + $script:batch_dataGridView.Columns["결과"].ReadOnly = $true + $script:batch_dataGridView.Columns["결과"].DefaultCellStyle.BackColor = [System.Drawing.Color]::Gainsboro + + Write-Log "$($csvData.Count)개의 항목을 CSV 파일에서 로드했습니다." + $script:batch_buttonValidate.Enabled = $true + $script:batch_buttonExecute.Enabled = $false + $script:batch_progressBar.Value = 0 + $script:batch_dataGridView.Visible = $true + $script:batch_labelGuide.Visible = $false + } + catch { + # 파일 읽기 실패 등 예외 발생 시 상세 오류 대화상자 표시 + Show-DetailedErrorDialog -ErrorRecord $_ + $script:batch_buttonValidate.Enabled = $false + } + } +} + +# 데이터 그리드뷰의 셀 값이 변경될 때 호출 +$batch_dataGridView_CellValueChanged = { + param($src, $e) + $rowIndex = $e.RowIndex + $colIndex = $e.ColumnIndex + + if ($rowIndex -ge 0 -and $colIndex -ge 0) { + $dataGridView = $src + # '결과' 열은 시스템이 업데이트하므로 사용자의 수정에 반응하지 않음 + if ($dataGridView.Columns[$colIndex].Name -eq "결과") { + return + } + + # 사용자가 데이터를 수정했음을 시각적으로 표시 + $row = $dataGridView.Rows[$rowIndex] + $resultCell = $row.Cells["결과"] + if ($resultCell.Value -notlike "수정됨*") { + $resultCell.Value = "수정됨 (검사 필요)" + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightYellow + $script:batch_buttonExecute.Enabled = $false # 수정 후에는 다시 유효성 검사가 필요함 + } + } +} + +# 데이터 그리드뷰에 새 행을 추가할 때 호출 (컨텍스트 메뉴) +$batch_buttonAddRow_Click = { + param($src, $e) + $dataTable = $script:batch_dataGridView.DataSource + + # 그리드에 데이터가 아직 없는 경우, DataTable을 새로 생성 + if ($null -eq $dataTable) { + $taskType = $script:batch_comboBoxTaskType.SelectedItem.ToString() + $headers = if ($taskType -eq "계정 생성") { @("LastNameKr", "FirstNameKr", "AccountNameEn", "Password", "OU_Name") } else { @("SamAccountName") } + + $dataTable = New-Object System.Data.DataTable + $headers | ForEach-Object { $dataTable.Columns.Add($_) } | Out-Null + $dataTable.Columns.Add("결과") | Out-Null + + $script:batch_dataGridView.DataSource = $dataTable + $script:batch_dataGridView.Columns["결과"].ReadOnly = $true + $script:batch_dataGridView.Columns["결과"].DefaultCellStyle.BackColor = [System.Drawing.Color]::Gainsboro + $script:batch_dataGridView.Visible = $true + $script:batch_labelGuide.Visible = $false + $script:batch_buttonValidate.Enabled = $true + } + + # 새 행을 추가하고 초기 상태 설정 + $newRow = $dataTable.NewRow() + $newRow["결과"] = "신규 (검사 필요)" + $dataTable.Rows.Add($newRow) + + # 새로 추가된 행으로 스크롤 이동 + $script:batch_dataGridView.FirstDisplayedScrollingRowIndex = $script:batch_dataGridView.Rows.Count - 1 +} + +# 데이터 그리드뷰에서 선택된 행을 삭제할 때 호출 (컨텍스트 메뉴) +$batch_buttonDeleteRow_Click = { + param($src, $e) + $selectedRows = $script:batch_dataGridView.SelectedRows + if ($selectedRows.Count -eq 0) { + return + } + # 여러 행 삭제 시 인덱스 문제를 피하기 위해 역순으로 정렬 후 삭제 + foreach ($row in $selectedRows | Sort-Object Index -Descending) { + if (-not $row.IsNewRow) { + $script:batch_dataGridView.Rows.Remove($row) + } + } +} + +# '유효성 검사' 버튼 클릭 시 호출 +$batch_buttonValidate_Click = { + $dataTable = $script:batch_dataGridView.DataSource + if ($null -eq $dataTable -or $dataTable.Rows.Count -eq 0) { + return + } + + $taskType = $script:batch_comboBoxTaskType.SelectedItem.ToString() + $script:batch_buttonExecute.Enabled = $false + + # 동기식으로 유효성 검사 스크립트 블록을 실행하고, 오류가 발생한 행의 수를 반환받음 + $invalidRowsCount = Invoke-Synchronous -TriggerControl $this -StatusMessage "사전 유효성 검사를 수행합니다..." -RequiresAzureAD -ScriptBlock { + $invalidCount = 0 + $totalCount = $dataTable.Rows.Count + $processedCount = 0 + + foreach ($dataRow in $dataTable.Rows) { + $processedCount++ + $script:batch_progressBar.Value = ($processedCount / $totalCount) * 100 + + try { + if ($taskType -eq "계정 생성") { + # 필수 값, OU 경로, 계정명 중복 여부 등 검사 + if (-not ($dataRow.LastNameKr -and $dataRow.FirstNameKr -and $dataRow.AccountNameEn -and $dataRow.OU_Name -and $dataRow.Password)) { throw "필수 열이 비어있습니다." } + $ouDN = Convert-OUPathToDN -OUPath $dataRow.OU_Name + if (-not (Get-ADOrganizationalUnit -Identity $ouDN -Server $script:Configuration.OnPremDomainController -ErrorAction SilentlyContinue)) { throw "OU 경로 '$($dataRow.OU_Name)'를 찾을 수 없습니다." } + $validationResult = Test-SamAccountName -AccountName $dataRow.AccountNameEn -CheckAzureAD + if (-not $validationResult.IsValid) { throw $validationResult.Reason } + } + elseif ($taskType -eq "계정 삭제") { + # 필수 값, 계정 존재 여부 검사 + if (-not $dataRow.SamAccountName) { throw "필수 열(SamAccountName)이 비어있습니다." } + if (-not (Get-ADUser -Filter "SamAccountName -eq '$($dataRow.SamAccountName)'" -Server $script:Configuration.OnPremDomainController)) { throw "존재하지 않는 계정입니다." } + } + # 유효성 검사 통과 시 결과 업데이트 + $dataRow["결과"] = "✅ 유효" + } + catch { + # 오류 발생 시 결과에 오류 메시지 기록 + $errorMessage = $_.Exception.Message.Trim() + $dataRow["결과"] = "❌ 오류: $errorMessage" + $invalidCount++ + } + } + # 스크립트 블록의 결과로 오류 개수 반환 + return $invalidCount + } + + # 그리드의 각 행을 결과에 따라 색상으로 구분 + foreach ($row in $script:batch_dataGridView.Rows) { + $resultCell = $row.Cells["결과"] + if ($resultCell.Value -like "❌*") { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightPink + } + else { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::White + } + } + + if ($invalidRowsCount -gt 0) { + $msg = "$($invalidRowsCount)개의 유효하지 않은 항목이 발견되었습니다. 그리드에서 오류 내용을 확인하고 CSV 파일을 수정한 후 다시 시도하세요." + [System.Windows.Forms.MessageBox]::Show($msg, "유효성 검사 실패", "OK", "Warning") + } + elseif ($invalidRowsCount -eq 0) { # 0일 경우에만 성공으로 간주 + $script:batch_buttonExecute.Enabled = $true + [System.Windows.Forms.MessageBox]::Show("모든 항목이 유효합니다. '일괄 작업 실행' 버튼을 눌러 작업을 시작하세요.", "검사 완료", "OK", "Information") + } +} + +# '일괄 작업 실행' 버튼 클릭 시 호출 +$batch_buttonExecute_Click = { + $dataTable = $script:batch_dataGridView.DataSource + if ($null -eq $dataTable -or $dataTable.Rows.Count -eq 0) { + return + } + + # 유효성 검사를 먼저 수행했는지 확인 + if ([string]::IsNullOrWhiteSpace($dataTable.Rows[0]["결과"])) { + [System.Windows.Forms.MessageBox]::Show("먼저 '유효성 검사'를 실행하여 데이터의 유효성을 확인해야 합니다.", "검사 필요", "OK", "Warning") + return + } + # 오류가 있는 항목이 있는지 확인 + $invalidRows = $dataTable.Select("[결과] LIKE '❌*'") + if ($invalidRows.Count -gt 0) { + [System.Windows.Forms.MessageBox]::Show("오류 항목이 존재합니다. 유효성 검사를 다시 수행하거나 CSV 파일을 수정해주세요.", "오류 발견", "OK", "Error") + return + } + # 실행할 유효한 항목이 있는지 확인 + $validRows = $dataTable.Select("[결과] = '✅ 유효'") + if ($validRows.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show("실행할 유효한 항목이 없습니다.", "작업 불가", "OK", "Information") + return + } + + # 최종 실행 확인 + $taskType = $script:batch_comboBoxTaskType.SelectedItem.ToString() + $confirmMsg = "유효한 $($validRows.Length)개 항목에 대해 '$taskType' 일괄 작업을 시작하시겠습니까?" + if ([System.Windows.Forms.MessageBox]::Show($confirmMsg, "최종 확인", "YesNo", "Question") -ne "Yes") { + return + } + + # 동기식으로 일괄 작업 실행 + Invoke-Synchronous -TriggerControl $this -StatusMessage "일괄 작업을 실행합니다..." -ScriptBlock { + $successfulSyncCount = 0 + foreach ($dataRow in $validRows) { + try { + if ($taskType -eq "계정 생성") { + # AD 사용자 생성을 위한 파라미터 설정 + $ouDN = Convert-OUPathToDN -OUPath $dataRow.OU_Name + $userPrincipalName = "$($dataRow.AccountNameEn)@$($script:Configuration.UPNSuffix)" + $params = @{ + Name = "$($dataRow.LastNameKr)$($dataRow.FirstNameKr) [$($dataRow.AccountNameEn)]" + DisplayName = "$($dataRow.LastNameKr)$($dataRow.FirstNameKr) [$($dataRow.AccountNameEn)]" + SamAccountName = $dataRow.AccountNameEn + UserPrincipalName = $userPrincipalName + EmailAddress = $userPrincipalName + Path = $ouDN + GivenName = $dataRow.FirstNameKr + Surname = $dataRow.LastNameKr + AccountPassword = (ConvertTo-SecureString ($dataRow.Password) -AsPlainText -Force) + Enabled = $true + ChangePasswordAtLogon = $false + Server = $script:Configuration.OnPremDomainController + } + New-ADUser @params -ErrorAction Stop + } + elseif ($taskType -eq "계정 삭제") { + Remove-ADUser -Identity $dataRow.SamAccountName.Trim() -Confirm:$false -Server $script:Configuration.OnPremDomainController -ErrorAction Stop + } + + # 작업 성공 시 결과 업데이트 + $dataRow["결과"] = "✅ 처리 완료" + $successfulSyncCount++ + } + catch { + # 작업 실패 시 결과에 오류 메시지 기록 + $errorMessage = $_.Exception.Message.Trim() + $dataRow["결과"] = "❌ 실행 실패: $errorMessage" + } + } + # 성공적으로 처리된 건이 하나라도 있으면 AAD Connect Delta 동기화 실행 + if ($successfulSyncCount -gt 0) { + Write-Log "$successfulSyncCount 건의 성공적인 작업에 대해 AAD Connect 동기화(Delta)를 시작합니다..." + Invoke-AadConnectSync -PolicyType Delta + } + } + + # 그리드의 각 행을 최종 결과에 따라 색상으로 구분 + foreach ($row in $script:batch_dataGridView.Rows) { + if ($row.Cells["결과"].Value -eq "✅ 처리 완료") { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::PaleGreen + } + elseif ($row.Cells["결과"].Value -like "❌ 실행 실패*") { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::OrangeRed + } + } + + [System.Windows.Forms.MessageBox]::Show("일괄 작업이 완료되었습니다. 최종 결과는 그리드를 확인하세요.`n새로운 작업을 하시려면 다시 CSV 파일을 로드해주세요.", "작업 완료", "OK", "Information") + + # 작업 완료 후 UI 상태 초기화 + $script:batch_textBoxCsvPath.Text = "" + $script:batch_progressBar.Value = 0 + $script:batch_buttonExecute.Enabled = $false + $script:batch_buttonValidate.Enabled = $false +} + +# -------------------------------------------------------------------------------- +# 이벤트 등록 함수 +# -------------------------------------------------------------------------------- + +function Register-BatchTaskEvents { + # 각 컨트롤에 정의된 이벤트 핸들러(스크립트 블록)를 연결 + $script:batch_comboBoxTaskType.add_SelectedIndexChanged($batch_TaskType_Changed) + $script:batch_buttonDownloadTemplate.add_Click($batch_buttonDownloadTemplate_Click) + $script:batch_buttonBrowseCsv.add_Click($batch_buttonBrowseCsv_Click) + $script:batch_buttonValidate.add_Click($batch_buttonValidate_Click) + $script:batch_buttonExecute.add_Click($batch_buttonExecute_Click) + $script:batch_dataGridView.add_CellValueChanged($batch_dataGridView_CellValueChanged) + + # 데이터 그리드뷰의 우클릭 컨텍스트 메뉴 설정 + $contextMenu = New-Object System.Windows.Forms.ContextMenuStrip + $menuItemAdd = New-Object System.Windows.Forms.ToolStripMenuItem("새 행 추가") + $menuItemDelete = New-Object System.Windows.Forms.ToolStripMenuItem("선택 행 삭제") + + $menuItemAdd.Add_Click($batch_buttonAddRow_Click) + $menuItemDelete.Add_Click($batch_buttonDeleteRow_Click) + + $contextMenu.Items.AddRange(@($menuItemAdd, $menuItemDelete)) | Out-Null + + # 컨텍스트 메뉴가 열릴 때, 행이 선택된 경우에만 '삭제' 메뉴를 활성화 + $contextMenu.Add_Opening({ + param($src, $e) + $src.Items[1].Enabled = ($script:batch_dataGridView.SelectedRows.Count -gt 0) + }) + + $script:batch_dataGridView.ContextMenuStrip = $contextMenu +} + +# -------------------------------------------------------------------------------- +# 탭 UI 초기화 함수 +# -------------------------------------------------------------------------------- + +function Initialize-BatchTaskTab { + Param( + [System.Windows.Forms.TabPage]$parentTab + ) + + # 메인 레이아웃 (TableLayoutPanel) 설정 + $layout = New-Object System.Windows.Forms.TableLayoutPanel + $layout.Dock = "Fill" + $layout.Padding = [System.Windows.Forms.Padding](10) + $layout.ColumnCount = 4 + $layout.RowCount = 4 + $layout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 100))) | Out-Null + $layout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $layout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 150))) | Out-Null + $layout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 160))) | Out-Null + $layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 40))) | Out-Null + $layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 40))) | Out-Null + $layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 50))) | Out-Null + $parentTab.Controls.Add($layout) | Out-Null + + # UI 컨트롤 생성 + # 0행: 작업 유형 선택 영역 + $labelTaskType = New-Object System.Windows.Forms.Label + $script:batch_comboBoxTaskType = New-Object System.Windows.Forms.ComboBox + $script:batch_buttonDownloadTemplate = New-Object System.Windows.Forms.Button + + # 1행: CSV 파일 선택 영역 + $labelCsvPath = New-Object System.Windows.Forms.Label + $script:batch_textBoxCsvPath = New-Object System.Windows.Forms.TextBox + $script:batch_buttonBrowseCsv = New-Object System.Windows.Forms.Button + + # 2행: 데이터 표시 영역 (그리드뷰, 안내문) + $gridPanel = New-Object System.Windows.Forms.Panel + $script:batch_dataGridView = New-Object System.Windows.Forms.DataGridView + $script:batch_labelGuide = New-Object System.Windows.Forms.Label + + # 3행: 진행률 및 실행 버튼 영역 + $script:batch_progressBar = New-Object System.Windows.Forms.ProgressBar + $execButtonLayout = New-Object System.Windows.Forms.TableLayoutPanel + $script:batch_buttonValidate = New-Object System.Windows.Forms.Button + $script:batch_buttonExecute = New-Object System.Windows.Forms.Button + + # UI 컨트롤 속성 설정 + # - 작업 유형 레이블 + $labelTaskType.Text = "작업 유형:" + $labelTaskType.Dock = "Fill" + $labelTaskType.TextAlign = "MiddleLeft" + + # - 작업 유형 콤보박스 + $script:batch_comboBoxTaskType.Dock = "Fill" + $script:batch_comboBoxTaskType.DropDownStyle = "DropDownList" + $script:batch_comboBoxTaskType.Items.AddRange(@("계정 생성", "계정 삭제")) | Out-Null + $script:batch_comboBoxTaskType.SelectedIndex = 0 + + # - 템플릿 다운로드 버튼 + $script:batch_buttonDownloadTemplate.Text = "템플릿 다운로드(&D)" + $script:batch_buttonDownloadTemplate.Dock = "Fill" + + # - CSV 파일 경로 레이블 + $labelCsvPath.Text = "CSV 파일 경로:" + $labelCsvPath.Dock = "Fill" + $labelCsvPath.TextAlign = "MiddleLeft" + + # - CSV 파일 경로 텍스트박스 + $script:batch_textBoxCsvPath.Dock = "Fill" + $script:batch_textBoxCsvPath.ReadOnly = $true + + # - 파일 찾아보기 버튼 + $script:batch_buttonBrowseCsv.Text = "파일 찾아보기(&B)..." + $script:batch_buttonBrowseCsv.Dock = "Fill" + + # - 그리드뷰를 담을 패널 + $gridPanel.Dock = "Fill" + + # - 데이터 그리드뷰 + $script:batch_dataGridView.Dock = "Fill" + $script:batch_dataGridView.ReadOnly = $false + $script:batch_dataGridView.AllowUserToAddRows = $false + $script:batch_dataGridView.AllowUserToDeleteRows = $false + $script:batch_dataGridView.AutoSizeColumnsMode = "Fill" + $script:batch_dataGridView.Visible = $false + $script:batch_dataGridView.SelectionMode = "FullRowSelect" + + # - 안내 가이드 레이블 + $script:batch_labelGuide.Dock = "Fill" + $script:batch_labelGuide.TextAlign = "MiddleLeft" + $script:batch_labelGuide.Padding = New-Object System.Windows.Forms.Padding(20, 0, 0, 0) + $script:batch_labelGuide.Font = New-Object System.Drawing.Font("Malgun Gothic", 9.5) + $script:batch_labelGuide.ForeColor = [System.Drawing.Color]::DimGray + + # - 진행률 표시줄 + $script:batch_progressBar.Dock = "Fill" + $script:batch_progressBar.Maximum = 100 + $script:batch_progressBar.Value = 0 + + # - 실행 버튼들을 담을 레이아웃 + $execButtonLayout.Dock = "Fill" + $execButtonLayout.ColumnCount = 2 + $execButtonLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $execButtonLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + + # - 유효성 검사 버튼 + $script:batch_buttonValidate.Text = "유효성 검사(&V)" + $script:batch_buttonValidate.Dock = "Fill" + $script:batch_buttonValidate.Enabled = $false + + # - 일괄 작업 실행 버튼 + $script:batch_buttonExecute.Text = "일괄 작업 실행(&X)" + $script:batch_buttonExecute.Dock = "Fill" + $script:batch_buttonExecute.Enabled = $false + $script:batch_buttonExecute.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold) + + # 컨트롤들을 메인 레이아웃에 배치 + # 0행: 작업 유형 선택 + $layout.Controls.Add($labelTaskType, 0, 0) | Out-Null + $layout.Controls.Add($script:batch_comboBoxTaskType, 1, 0) | Out-Null + $layout.SetColumnSpan($script:batch_comboBoxTaskType, 2) + $layout.Controls.Add($script:batch_buttonDownloadTemplate, 3, 0) | Out-Null + + # 1행: CSV 파일 경로 + $layout.Controls.Add($labelCsvPath, 0, 1) | Out-Null + $layout.Controls.Add($script:batch_textBoxCsvPath, 1, 1) | Out-Null + $layout.SetColumnSpan($script:batch_textBoxCsvPath, 2) + $layout.Controls.Add($script:batch_buttonBrowseCsv, 3, 1) | Out-Null + + # 2행: 데이터 그리드 및 안내문 + $gridPanel.Controls.Add($script:batch_dataGridView) | Out-Null + $gridPanel.Controls.Add($script:batch_labelGuide) | Out-Null + $layout.Controls.Add($gridPanel, 0, 2) | Out-Null + $layout.SetColumnSpan($gridPanel, 4) + + # 3행: 진행률 및 실행 버튼 + $execButtonLayout.Controls.Add($script:batch_buttonValidate, 0, 0) | Out-Null + $execButtonLayout.Controls.Add($script:batch_buttonExecute, 1, 0) | Out-Null + $layout.Controls.Add($script:batch_progressBar, 0, 3) | Out-Null + $layout.SetColumnSpan($script:batch_progressBar, 2) + $layout.Controls.Add($execButtonLayout, 2, 3) | Out-Null + $layout.SetColumnSpan($execButtonLayout, 2) + + # 이벤트 핸들러 등록 + Register-BatchTaskEvents + + # 탭 초기화 시 '작업 유형 변경' 이벤트를 수동으로 호출하여 UI 초기 상태를 설정 + $batch_TaskType_Changed.Invoke($null, $null) +} +#endregion + +Write-Host "UI-Tab-BatchTask.ps1 로드 완료." -ForegroundColor Cyan \ No newline at end of file diff --git a/Scripts/UI-Tab-DeleteUser.ps1 b/Scripts/UI-Tab-DeleteUser.ps1 new file mode 100644 index 0000000..8b1ae97 --- /dev/null +++ b/Scripts/UI-Tab-DeleteUser.ps1 @@ -0,0 +1,375 @@ +# ================================================================================ +# 파일: Scripts/UI-Tab-DeleteUser.ps1 +# 역할: '계정 삭제' 탭 UI 및 기능 구현 +# +# 작성자: 양범진 +# 버전: 1.13 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-12 +# +# 설명: 안정화된 동기 실행 방식에 맞춰 코드 수정 및 가독성 개선, PSScriptAnalyzer 경고 수정 +# ================================================================================ + +#region '계정 삭제' 탭 UI 초기화 함수 +function Initialize-DeleteUserTab { + Param( + [System.Windows.Forms.TabPage]$parentTab + ) + + # ========================================================== + # 탭 전체 레이아웃 설정 + # ========================================================== + $delTabMainLayout = New-Object System.Windows.Forms.TableLayoutPanel + $delTabMainLayout.Dock = "Fill" + $delTabMainLayout.Padding = [System.Windows.Forms.Padding](10) + $delTabMainLayout.ColumnCount = 1 + $delTabMainLayout.RowCount = 3 + $delTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 80))) | Out-Null + $delTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 250))) | Out-Null + $delTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $parentTab.Controls.Add($delTabMainLayout) | Out-Null + + # ========================================================== + # 1. 작업 모드 선택 그룹박스 + # ========================================================== + $groupMode = New-Object System.Windows.Forms.GroupBox + $groupMode.Text = "작업 모드 선택" + $groupMode.Dock = "Fill" + $delTabMainLayout.Controls.Add($groupMode, 0, 0) | Out-Null + + # '온프레미스 AD 삭제 및 Azure AD 동기화' 라디오 버튼 + $script:del_radioSync = New-Object System.Windows.Forms.RadioButton + $script:del_radioSync.Text = "온프레미스 AD 삭제 및 Azure AD 동기화" + $script:del_radioSync.Location = New-Object System.Drawing.Point(25, 30) + $script:del_radioSync.AutoSize = $true + $script:del_radioSync.Checked = $true + + # '온프레미스 AD에서만 삭제' 라디오 버튼 + $script:del_radioOnPremOnly = New-Object System.Windows.Forms.RadioButton + $script:del_radioOnPremOnly.Text = "온프레미스 AD에서만 삭제 (동기화 안함)" + $script:del_radioOnPremOnly.Location = New-Object System.Drawing.Point(450, 30) + $script:del_radioOnPremOnly.AutoSize = $true + + $groupMode.Controls.AddRange(@($script:del_radioSync, $script:del_radioOnPremOnly)) | Out-Null + + # ========================================================== + # 2. 사용자 검색 및 선택 그룹박스 + # ========================================================== + $groupSearch = New-Object System.Windows.Forms.GroupBox + $groupSearch.Text = "1. 삭제할 사용자 검색 및 선택" + $groupSearch.Dock = "Fill" + $delTabMainLayout.Controls.Add($groupSearch, 0, 1) | Out-Null + + # 검색 그룹박스 내부 레이아웃 + $searchLayout = New-Object System.Windows.Forms.TableLayoutPanel + $searchLayout.Dock = "Fill" + $searchLayout.Padding = [System.Windows.Forms.Padding](10) + $searchLayout.ColumnCount = 2 + $searchLayout.RowCount = 4 + $searchLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $searchLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 150))) | Out-Null + $searchLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 35))) | Out-Null + $searchLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $searchLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 35))) | Out-Null + $searchLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 45))) | Out-Null + $groupSearch.Controls.Add($searchLayout) | Out-Null + + # 사용자 검색 텍스트박스 및 검색 버튼 + $script:del_textBoxSearchUser = New-Object System.Windows.Forms.TextBox + $script:del_textBoxSearchUser.Dock = "Fill" + $buttonSearchUserOnPrem = New-Object System.Windows.Forms.Button + $buttonSearchUserOnPrem.Text = "온프레미스 검색(&S)" + $buttonSearchUserOnPrem.Dock = "Fill" + + # 검색된 사용자 목록을 보여주는 리스트박스 (다중 선택 활성화) + $script:del_listBoxFoundUsers = New-Object System.Windows.Forms.ListBox + $script:del_listBoxFoundUsers.Dock = "Fill" + $script:del_listBoxFoundUsers.SelectionMode = "MultiExtended" + + # 선택된 사용자의 상세 정보를 표시하는 레이아웃 + $userInfoLayout = New-Object System.Windows.Forms.TableLayoutPanel + $userInfoLayout.Dock = "Fill" + $userInfoLayout.ColumnCount = 2 + $userInfoLayout.RowCount = 1 + $userInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $userInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + + $script:del_textBoxFoundUserDisplay = New-Object System.Windows.Forms.TextBox + $script:del_textBoxFoundUserDisplay.Dock = "Fill" + $script:del_textBoxFoundUserDisplay.ReadOnly = $true + $script:del_textBoxFoundUserDisplay.Text = "표시 이름:" + $script:del_textBoxFoundUserUPN = New-Object System.Windows.Forms.TextBox + $script:del_textBoxFoundUserUPN.Dock = "Fill" + $script:del_textBoxFoundUserUPN.ReadOnly = $true + $script:del_textBoxFoundUserUPN.Text = "UPN:" + $userInfoLayout.Controls.AddRange(@($script:del_textBoxFoundUserDisplay, $script:del_textBoxFoundUserUPN)) | Out-Null + + # '삭제 목록에 추가' 버튼 + $script:del_buttonAddToDeleteList = New-Object System.Windows.Forms.Button + $script:del_buttonAddToDeleteList.Text = "삭제 목록에 추가(&A) ⬇" + $script:del_buttonAddToDeleteList.Dock = "Fill" + $script:del_buttonAddToDeleteList.Enabled = $false + + # 검색 레이아웃에 컨트롤 추가 + $searchLayout.Controls.Add($script:del_textBoxSearchUser, 0, 0) | Out-Null + $searchLayout.Controls.Add($buttonSearchUserOnPrem, 1, 0) | Out-Null + $searchLayout.Controls.Add($script:del_listBoxFoundUsers, 0, 1) | Out-Null + $searchLayout.SetColumnSpan($script:del_listBoxFoundUsers, 2) + $searchLayout.Controls.Add($userInfoLayout, 0, 2) | Out-Null + $searchLayout.SetColumnSpan($userInfoLayout, 2) + $searchLayout.Controls.Add($script:del_buttonAddToDeleteList, 0, 3) | Out-Null + $searchLayout.SetColumnSpan($script:del_buttonAddToDeleteList, 2) + + # ========================================================== + # 3. 삭제 대기 목록 그룹박스 + # ========================================================== + $groupQueue = New-Object System.Windows.Forms.GroupBox + $groupQueue.Text = "2. 삭제 대기 목록 (우클릭으로 항목 관리)" + $groupQueue.Dock = "Fill" + $delTabMainLayout.Controls.Add($groupQueue, 0, 2) | Out-Null + + # 대기 목록 그룹박스 내부 레이아웃 + $queueLayout = New-Object System.Windows.Forms.TableLayoutPanel + $queueLayout.Dock = "Fill" + $queueLayout.Padding = [System.Windows.Forms.Padding](10) + $queueLayout.ColumnCount = 1 + $queueLayout.RowCount = 2 + $queueLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $queueLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $queueLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 50))) | Out-Null + $groupQueue.Controls.Add($queueLayout) | Out-Null + + # 삭제 대기열 리스트박스 및 컨텍스트 메뉴 + $script:del_listBoxDeleteQueue = New-Object System.Windows.Forms.ListBox + $script:del_listBoxDeleteQueue.Dock = "Fill" + $contextMenu = New-Object System.Windows.Forms.ContextMenuStrip + $menuItemRemove = New-Object System.Windows.Forms.ToolStripMenuItem("선택 항목 제거") + $menuItemRemove.Name = "menuItemRemove" + $menuItemClear = New-Object System.Windows.Forms.ToolStripMenuItem("목록 전체 비우기") + $contextMenu.Items.AddRange(@($menuItemRemove, $menuItemClear)) | Out-Null + $script:del_listBoxDeleteQueue.ContextMenuStrip = $contextMenu + + # '삭제 실행' 버튼 + $script:del_buttonExecuteDelete = New-Object System.Windows.Forms.Button + $script:del_buttonExecuteDelete.Dock = "Fill" + $script:del_buttonExecuteDelete.Enabled = $false + $script:del_buttonExecuteDelete.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) + + # 대기 목록 레이아웃에 컨트롤 추가 + $queueLayout.Controls.Add($script:del_listBoxDeleteQueue, 0, 0) | Out-Null + $queueLayout.Controls.Add($script:del_buttonExecuteDelete, 0, 1) | Out-Null + + # ========================================================== + # 이벤트 핸들러 연결 + # ========================================================== + $script:del_radioSync.add_CheckedChanged($del_ModeChangedHandler) + $script:del_radioOnPremOnly.add_CheckedChanged($del_ModeChangedHandler) + $buttonSearchUserOnPrem.Add_Click($del_buttonSearchUserOnPrem_Click) + $script:del_listBoxFoundUsers.Add_SelectedIndexChanged($del_listBoxFoundUsers_SelectedIndexChanged) + $script:del_listBoxFoundUsers.Add_DoubleClick($del_listBoxFoundUsers_DoubleClick) + $script:del_buttonAddToDeleteList.Add_Click({ Move-SelectedUsersToDeleteQueue }) + $script:del_buttonExecuteDelete.Add_Click($del_buttonExecuteDelete_Click) + $menuItemRemove.Add_Click($del_contextMenuRemove_Click) + $menuItemClear.Add_Click($del_contextMenuClear_Click) + + # 컨텍스트 메뉴가 열릴 때 '선택 항목 제거' 메뉴의 활성화 여부 결정 + $contextMenu.Add_Opening({ + param($sourceItem, $e) + $menuItem = $sourceItem.Items["menuItemRemove"] + if ($menuItem) { + $menuItem.Enabled = ($null -ne $script:del_listBoxDeleteQueue.SelectedItem) + } + }) + + # 초기 모드 설정에 따라 버튼 텍스트 업데이트 + $del_ModeChangedHandler.Invoke($script:del_radioSync, $null) +} +#endregion + +#region '계정 삭제' 탭 이벤트 핸들러 및 함수 + +# 작업 모드 라디오 버튼 변경 시 '삭제 실행' 버튼의 텍스트를 변경하는 핸들러 +$del_ModeChangedHandler = { + param($sourceControl, $e) + # 체크된 라디오 버튼에 대해서만 동작 + if (-not $sourceControl.Checked) { return } + + if ($script:del_radioSync.Checked) { + $script:del_buttonExecuteDelete.Text = "목록 삭제 및 동기화 실행(&E)" + } + else { + $script:del_buttonExecuteDelete.Text = "목록 전체 삭제 실행 (On-Prem Only)(&E)" + } +} + +# '온프레미스 검색' 버튼 클릭 이벤트 핸들러 +$del_buttonSearchUserOnPrem_Click = { + $searchTerm = $script:del_textBoxSearchUser.Text.Trim() + if ([string]::IsNullOrEmpty($searchTerm)) { return } + + # 동기 함수 호출 래퍼를 사용하여 사용자 검색 실행 (UI 응답성 유지) + $users = Invoke-Synchronous -TriggerControl $this -StatusMessage "온프레미스 AD에서 사용자 '$searchTerm' 검색 중..." -ScriptBlock { + # SamAccountName 또는 DisplayName에 검색어가 포함된 사용자를 찾음 + Get-ADUser -Filter "(SamAccountName -like '*$searchTerm*') -or (DisplayName -like '*$searchTerm*')" -Properties DisplayName, UserPrincipalName, SamAccountName -Server $script:Configuration.OnPremDomainController -ErrorAction SilentlyContinue + } + + # UI 업데이트 전 BeginUpdate() 호출로 깜빡임 방지 + $script:del_listBoxFoundUsers.BeginUpdate() + $script:del_listBoxFoundUsers.Items.Clear() | Out-Null + + if ($users) { + # 찾은 사용자들을 이름순으로 정렬하여 리스트박스에 추가 + foreach ($user in @($users) | Sort-Object DisplayName) { + $display = "$($user.DisplayName) (sAM: $($user.SamAccountName))" + $item = [PSCustomObject]@{ + DisplayText = $display + ADObject = $user + } + $script:del_listBoxFoundUsers.Items.Add($item) | Out-Null + } + } + + $script:del_listBoxFoundUsers.DisplayMember = "DisplayText" + $script:del_listBoxFoundUsers.EndUpdate() + + Write-Log "$(@($users).Count)명의 사용자를 찾았습니다." +} + +# 검색된 사용자 리스트박스에서 선택이 변경될 때의 이벤트 핸들러 +$del_listBoxFoundUsers_SelectedIndexChanged = { + # 다중 선택을 지원하므로 SelectedItems 속성 사용 + $selectedItems = $this.SelectedItems + if ($selectedItems.Count -gt 0) { + # 정보 표시는 첫 번째 선택된 항목 기준으로 함 + $firstSelectedItem = $selectedItems[0] + $script:del_selectedADUser = $firstSelectedItem.ADObject + $script:del_textBoxFoundUserDisplay.Text = "표시 이름: $($script:del_selectedADUser.DisplayName)" + $script:del_textBoxFoundUserUPN.Text = "UPN: $($script:del_selectedADUser.UserPrincipalName)" + $script:del_buttonAddToDeleteList.Enabled = $true + } + else { + # 선택된 항목이 없으면 정보 초기화 및 버튼 비활성화 + $script:del_selectedADUser = $null + $script:del_textBoxFoundUserDisplay.Text = "표시 이름:" + $script:del_textBoxFoundUserUPN.Text = "UPN:" + $script:del_buttonAddToDeleteList.Enabled = $false + } +} + +# 검색된 사용자 리스트박스에서 항목을 더블클릭할 때의 이벤트 핸들러 +$del_listBoxFoundUsers_DoubleClick = { + # 더블클릭 시 해당 항목 하나만 삭제 대기 목록으로 이동 + $selectedItem = $script:del_listBoxFoundUsers.SelectedItem + if ($null -eq $selectedItem) { return } + + $userObject = $selectedItem.ADObject + # 이미 대기 목록에 있는지 확인 (고유 식별자인 DistinguishedName 기준) + $isAlreadyInQueue = $script:del_deleteQueue.DistinguishedName -contains $userObject.DistinguishedName + + if (-not $isAlreadyInQueue) { + $script:del_deleteQueue.Add($userObject) | Out-Null + $script:del_listBoxDeleteQueue.Items.Add($selectedItem.DisplayText) | Out-Null + $script:del_buttonExecuteDelete.Enabled = $true + $script:del_listBoxFoundUsers.Items.Remove($selectedItem) | Out-Null + } + else { + [System.Windows.Forms.MessageBox]::Show("해당 사용자는 이미 삭제 대기 목록에 있습니다.", "중복 추가", "OK", "Information") | Out-Null + } +} + +# 검색 리스트에서 선택된 사용자들을 삭제 대기 목록으로 옮기는 함수 +function Move-SelectedUsersToDeleteQueue { + $selectedItems = $script:del_listBoxFoundUsers.SelectedItems + if ($selectedItems.Count -eq 0) { return } + + # 여러 항목 변경 시 UI 깜빡임 방지를 위해 BeginUpdate/EndUpdate 사용 + $script:del_listBoxFoundUsers.BeginUpdate() + $script:del_listBoxDeleteQueue.BeginUpdate() + + # 반복 중 컬렉션에서 항목을 제거하면 문제가 발생하므로, 복사본을 만들어 순회 + $itemsToMove = @($selectedItems) + + foreach ($item in $itemsToMove) { + $userObject = $item.ADObject + $isAlreadyInQueue = $script:del_deleteQueue.DistinguishedName -contains $userObject.DistinguishedName + + if (-not $isAlreadyInQueue) { + $script:del_deleteQueue.Add($userObject) | Out-Null + $script:del_listBoxDeleteQueue.Items.Add($item.DisplayText) | Out-Null + $script:del_listBoxFoundUsers.Items.Remove($item) | Out-Null + } + } + + $script:del_buttonExecuteDelete.Enabled = ($script:del_deleteQueue.Count -gt 0) + + $script:del_listBoxFoundUsers.EndUpdate() + $script:del_listBoxDeleteQueue.EndUpdate() +} + +# 삭제 대기 목록 컨텍스트 메뉴 - '선택 항목 제거' 클릭 이벤트 핸들러 +$del_contextMenuRemove_Click = { + $selectedIndex = $script:del_listBoxDeleteQueue.SelectedIndex + if ($selectedIndex -ne -1) { + # 데이터 목록과 UI 목록에서 모두 제거 + $script:del_deleteQueue.RemoveAt($selectedIndex) + $script:del_listBoxDeleteQueue.Items.RemoveAt($selectedIndex) | Out-Null + $script:del_buttonExecuteDelete.Enabled = ($script:del_deleteQueue.Count -gt 0) + } +} + +# 삭제 대기 목록 컨텍스트 메뉴 - '목록 전체 비우기' 클릭 이벤트 핸들러 +$del_contextMenuClear_Click = { + $script:del_deleteQueue.Clear() + $script:del_listBoxDeleteQueue.Items.Clear() | Out-Null + $script:del_buttonExecuteDelete.Enabled = $false +} + +# '삭제 실행' 버튼 클릭 이벤트 핸들러 +$del_buttonExecuteDelete_Click = { + if ($script:del_deleteQueue.Count -eq 0) { return } + + # 삭제할 사용자 목록을 만들어 사용자에게 최종 확인 + $userList = ($script:del_deleteQueue | ForEach-Object { "- $($_.DisplayName)" }) -join "`n" + $message = "다음 $($script:del_deleteQueue.Count)명의 사용자를 영구적으로 삭제하시겠습니까?`n이 작업은 되돌릴 수 없습니다.`n`n$userList" + $confirmResult = [System.Windows.Forms.MessageBox]::Show($message, "최종 삭제 확인", "YesNo", "Warning") + + if ($confirmResult -ne "Yes") { + Write-Log "사용자가 삭제 작업을 취소했습니다." -Level "WARNING" + return + } + + # 동기 함수 래퍼를 통해 실제 삭제 작업 수행 + $deletedCount = Invoke-Synchronous -TriggerControl $this -StatusMessage "사용자 삭제 작업을 실행합니다..." -ScriptBlock { + $count = 0 + # 반복 중 컬렉션 변경에 안전하도록 ToArray()로 복사본 사용 + foreach ($userToDelete in $script:del_deleteQueue.ToArray()) { + try { + Write-Log "AD 사용자 '$($userToDelete.SamAccountName)' 삭제 시도..." + Remove-ADUser -Identity $userToDelete.DistinguishedName -Confirm:$false -Server $script:Configuration.OnPremDomainController -ErrorAction Stop + Write-Log " -> AD 사용자 '$($userToDelete.SamAccountName)' 삭제 성공." + $count++ + } + catch { + Write-Log " -> AD 사용자 '$($userToDelete.SamAccountName)' 삭제 실패: $($_.Exception.Message)" -Level "ERROR" + } + } + + # '동기화' 모드이고 한 명 이상 성공적으로 삭제된 경우, Azure AD Connect 델타 동기화 실행 + if ($script:del_radioSync.Checked -and $count -gt 0) { + Invoke-AadConnectSync -PolicyType Delta + } + + # 성공적으로 삭제된 사용자 수 반환 + return $count + } + + # 작업 완료 후 결과 메시지 표시 및 탭 초기화 + # 수정: $null을 비교 연산자 왼쪽에 배치 (PSScriptAnalyzer 규칙 준수) + if ($null -ne $deletedCount) { + [System.Windows.Forms.MessageBox]::Show("$deletedCount 명의 사용자에 대한 삭제 작업이 완료되었습니다.", "작업 완료", "OK", "Information") | Out-Null + Reset-DeleteUserTab + } +} +#endregion + +Write-Host "UI-Tab-DeleteUser.ps1 로드 완료." -ForegroundColor Cyan \ No newline at end of file diff --git a/Scripts/UI-Tab-HardMatch.ps1 b/Scripts/UI-Tab-HardMatch.ps1 new file mode 100644 index 0000000..e986ef4 --- /dev/null +++ b/Scripts/UI-Tab-HardMatch.ps1 @@ -0,0 +1,530 @@ +# ================================================================================ +# 파일: Scripts/UI-Tab-HardMatch.ps1 +# 역할: '하드 매칭' 탭 UI 및 기능 구현 (리팩토링 최종 완전판) +# +# 작성자: 양범진 +# 버전: 1.13 +# 생성일자: 2025-06-05 +# 최종 수정일자: 2025-06-12 +# +# 설명: +# - '3. 작업 실행' 영역의 레이아웃을 2열 구조로 변경하여 UI 잘림 문제 해결. +# - 하드 매칭으로 신규 계정 생성 시, SamAccountName 중복 확인 로직 추가. +# ================================================================================ + +#region '하드 매칭' 탭 UI 초기화 함수 +function Initialize-HardMatchTab { + Param( + [System.Windows.Forms.TabPage]$parentTab + ) + + # 탭 페이지의 메인 레이아웃 (3행 1열) + $hmTabMainLayout = New-Object System.Windows.Forms.TableLayoutPanel + $hmTabMainLayout.Dock = "Fill" + $hmTabMainLayout.Padding = [System.Windows.Forms.Padding](10) + $hmTabMainLayout.ColumnCount = 1 + $hmTabMainLayout.RowCount = 3 + $hmTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 160))) | Out-Null + $hmTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 160))) | Out-Null + $hmTabMainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $parentTab.Controls.Add($hmTabMainLayout) | Out-Null + + # 1. Azure AD 사용자 검색 그룹박스 + $groupSearchAzure = New-Object System.Windows.Forms.GroupBox + $groupSearchAzure.Text = "1. Azure AD 사용자 검색 (Cloud-Only 또는 동기화 오류 사용자)" + $groupSearchAzure.Dock = "Fill" + $hmTabMainLayout.Controls.Add($groupSearchAzure, 0, 0) | Out-Null + + $searchAzureLayout = New-Object System.Windows.Forms.TableLayoutPanel + $searchAzureLayout.Dock = "Fill" + $searchAzureLayout.Padding = [System.Windows.Forms.Padding](10) + $searchAzureLayout.ColumnCount = 2 + $searchAzureLayout.RowCount = 2 + $searchAzureLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $searchAzureLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 150))) | Out-Null + $searchAzureLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 35))) | Out-Null + $searchAzureLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $groupSearchAzure.Controls.Add($searchAzureLayout) | Out-Null + + $script:hm_textBoxSearchAzure = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxSearchAzure.Dock = "Fill" + + $buttonSearchAzure = New-Object System.Windows.Forms.Button + $buttonSearchAzure.Text = "Azure AD 검색(&Z)" + $buttonSearchAzure.Dock = "Fill" + + $script:hm_listBoxFoundAzure = New-Object System.Windows.Forms.ListBox + $script:hm_listBoxFoundAzure.Dock = "Fill" + + $searchAzureLayout.Controls.Add($script:hm_textBoxSearchAzure, 0, 0) | Out-Null + $searchAzureLayout.Controls.Add($buttonSearchAzure, 1, 0) | Out-Null + $searchAzureLayout.Controls.Add($script:hm_listBoxFoundAzure, 0, 1) | Out-Null + $searchAzureLayout.SetColumnSpan($script:hm_listBoxFoundAzure, 2) + + # 2. 사용자 정보 확인 그룹박스 + $groupInfo = New-Object System.Windows.Forms.GroupBox + $groupInfo.Text = "2. 사용자 정보 확인" + $groupInfo.Dock = "Fill" + $hmTabMainLayout.Controls.Add($groupInfo, 0, 1) | Out-Null + + $infoLayout = New-Object System.Windows.Forms.TableLayoutPanel + $infoLayout.Dock = "Fill" + $infoLayout.ColumnCount = 2 + $infoLayout.RowCount = 1 + $infoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $infoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $groupInfo.Controls.Add($infoLayout) | Out-Null + + # 2-1. Azure AD 사용자 정보 + $groupAzure = New-Object System.Windows.Forms.GroupBox + $groupAzure.Text = "Azure AD 사용자" + $groupAzure.Dock = "Fill" + $infoLayout.Controls.Add($groupAzure, 0, 0) | Out-Null + + $script:hm_textBoxAzureUser = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxAzureUser.Location = New-Object System.Drawing.Point(15, 25) + $script:hm_textBoxAzureUser.Width = $groupAzure.ClientSize.Width - 30 + $script:hm_textBoxAzureUser.Anchor = 'Top, Left, Right' + $script:hm_textBoxAzureUser.ReadOnly = $true + + $script:hm_textBoxImmutableId = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxImmutableId.Location = New-Object System.Drawing.Point(15, 55) + $script:hm_textBoxImmutableId.Width = $groupAzure.ClientSize.Width - 30 + $script:hm_textBoxImmutableId.Anchor = 'Top, Left, Right' + $script:hm_textBoxImmutableId.ReadOnly = $true + + $script:hm_labelGuidMatchStatus = New-Object System.Windows.Forms.Label + $script:hm_labelGuidMatchStatus.Location = New-Object System.Drawing.Point(15, 85) + $script:hm_labelGuidMatchStatus.Width = $groupAzure.ClientSize.Width - 30 + $script:hm_labelGuidMatchStatus.Anchor = 'Top, Left, Right' + $script:hm_labelGuidMatchStatus.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) + $script:hm_labelGuidMatchStatus.TextAlign = "MiddleLeft" + $script:hm_labelGuidMatchStatus.Visible = $false + + $groupAzure.Controls.AddRange(@( + $script:hm_textBoxAzureUser, + $script:hm_textBoxImmutableId, + $script:hm_labelGuidMatchStatus + )) | Out-Null + + # 2-2. 온프레미스 AD 사용자 정보 + $groupOnPrem = New-Object System.Windows.Forms.GroupBox + $groupOnPrem.Text = "온프레미스 AD 사용자" + $groupOnPrem.Dock = "Fill" + $groupOnPrem.Visible = $false + $infoLayout.Controls.Add($groupOnPrem, 1, 0) | Out-Null + + $script:hm_textBoxOnPremUser = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxOnPremUser.Location = New-Object System.Drawing.Point(15, 25) + $script:hm_textBoxOnPremUser.Width = $groupOnPrem.ClientSize.Width - 30 + $script:hm_textBoxOnPremUser.Anchor = 'Top, Left, Right' + $script:hm_textBoxOnPremUser.ReadOnly = $true + + $script:hm_textBoxObjectGuid = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxObjectGuid.Location = New-Object System.Drawing.Point(15, 55) + $script:hm_textBoxObjectGuid.Width = $groupOnPrem.ClientSize.Width - 30 + $script:hm_textBoxObjectGuid.Anchor = 'Top, Left, Right' + $script:hm_textBoxObjectGuid.ReadOnly = $true + + $script:hm_textBoxConvertedImmutableId = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxConvertedImmutableId.Location = New-Object System.Drawing.Point(15, 85) + $script:hm_textBoxConvertedImmutableId.Width = $groupOnPrem.ClientSize.Width - 30 + $script:hm_textBoxConvertedImmutableId.Anchor = 'Top, Left, Right' + $script:hm_textBoxConvertedImmutableId.ReadOnly = $true + + $groupOnPrem.Controls.AddRange(@( + $script:hm_textBoxOnPremUser, + $script:hm_textBoxObjectGuid, + $script:hm_textBoxConvertedImmutableId + )) | Out-Null + + # 3. 작업 실행 그룹박스 + $groupAction = New-Object System.Windows.Forms.GroupBox + $groupAction.Text = "3. 작업 실행" + $groupAction.Dock = "Fill" + $hmTabMainLayout.Controls.Add($groupAction, 0, 2) | Out-Null + + # '작업 실행' 그룹박스 내부의 외부 레이아웃 (컨텐츠 영역 + 실행 버튼) + $actionOuterLayout = New-Object System.Windows.Forms.TableLayoutPanel + $actionOuterLayout.Dock = "Fill" + $actionOuterLayout.ColumnCount = 1 + $actionOuterLayout.RowCount = 2 + $actionOuterLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $actionOuterLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 50))) | Out-Null + $groupAction.Controls.Add($actionOuterLayout) | Out-Null + + # '계정 생성' 옵션이 표시될 패널 (기본적으로 숨김) + $script:hm_groupCreate = New-Object System.Windows.Forms.Panel + $script:hm_groupCreate.Dock = "Fill" + $script:hm_groupCreate.Visible = $false + $actionOuterLayout.Controls.Add($script:hm_groupCreate, 0, 0) | Out-Null + + # '계정 생성' 패널 내부 레이아웃 (2열) + $createInnerLayout = New-Object System.Windows.Forms.TableLayoutPanel + $createInnerLayout.Dock = "Fill" + $createInnerLayout.ColumnCount = 2 + $createInnerLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $createInnerLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) | Out-Null + $script:hm_groupCreate.Controls.Add($createInnerLayout) | Out-Null + + # 3-1. 신규 온프레미스 계정 정보 입력 영역 + $groupCreateInfo = New-Object System.Windows.Forms.GroupBox + $groupCreateInfo.Text = "신규 온프레미스 계정 정보" + $groupCreateInfo.Dock = "Fill" + $createInnerLayout.Controls.Add($groupCreateInfo, 0, 0) | Out-Null + $createInnerLayout.SetRowSpan($groupCreateInfo, 2) + + $hmCreateInfoLayout = New-Object System.Windows.Forms.TableLayoutPanel + $hmCreateInfoLayout.Dock = "Fill" + $hmCreateInfoLayout.Padding = [System.Windows.Forms.Padding](10) + $hmCreateInfoLayout.ColumnCount = 2 + $hmCreateInfoLayout.RowCount = 3 + $hmCreateInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 100))) | Out-Null + $hmCreateInfoLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $rowHeight = 35 + $hmCreateInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $hmCreateInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $hmCreateInfoLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, $rowHeight))) | Out-Null + $groupCreateInfo.Controls.Add($hmCreateInfoLayout) | Out-Null + + $labelHMLastNameKr = New-Object System.Windows.Forms.Label + $labelHMLastNameKr.Text = "한글 성:" + $labelHMLastNameKr.Dock = "Fill" + $labelHMLastNameKr.TextAlign = "MiddleLeft" + + $script:hm_textBoxLastNameKr = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxLastNameKr.Dock = "Fill" + + $labelHMFirstNameKr = New-Object System.Windows.Forms.Label + $labelHMFirstNameKr.Text = "한글 이름:" + $labelHMFirstNameKr.Dock = "Fill" + $labelHMFirstNameKr.TextAlign = "MiddleLeft" + + $script:hm_textBoxFirstNameKr = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxFirstNameKr.Dock = "Fill" + + $labelHMPassword = New-Object System.Windows.Forms.Label + $labelHMPassword.Text = "초기 암호:" + $labelHMPassword.Dock = "Fill" + $labelHMPassword.TextAlign = "MiddleLeft" + + $script:hm_textBoxPassword = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxPassword.Dock = "Fill" + $script:hm_textBoxPassword.Text = $script:Configuration.DefaultPassword + + $hmCreateInfoLayout.Controls.Add($labelHMLastNameKr, 0, 0) | Out-Null + $hmCreateInfoLayout.Controls.Add($script:hm_textBoxLastNameKr, 1, 0) | Out-Null + $hmCreateInfoLayout.Controls.Add($labelHMFirstNameKr, 0, 1) | Out-Null + $hmCreateInfoLayout.Controls.Add($script:hm_textBoxFirstNameKr, 1, 1) | Out-Null + $hmCreateInfoLayout.Controls.Add($labelHMPassword, 0, 2) | Out-Null + $hmCreateInfoLayout.Controls.Add($script:hm_textBoxPassword, 1, 2) | Out-Null + + # 3-2. 대상 OU 선택 영역 + $groupCreateOU = New-Object System.Windows.Forms.GroupBox + $groupCreateOU.Text = "대상 OU 선택" + $groupCreateOU.Dock = "Fill" + $createInnerLayout.Controls.Add($groupCreateOU, 1, 0) | Out-Null + $createInnerLayout.SetRowSpan($groupCreateOU, 2) + + # OU 그룹박스 내부 레이아웃 (선택된 OU 표시 + TreeView) + $hmCreateOU_Layout = New-Object System.Windows.Forms.TableLayoutPanel + $hmCreateOU_Layout.Dock = "Fill" + $hmCreateOU_Layout.Padding = [System.Windows.Forms.Padding](10) + $hmCreateOU_Layout.ColumnCount = 1 + $hmCreateOU_Layout.RowCount = 2 + $hmCreateOU_Layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 35))) | Out-Null + $hmCreateOU_Layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null + $groupCreateOU.Controls.Add($hmCreateOU_Layout) | Out-Null + + $script:hm_textBoxSelectedOU = New-Object System.Windows.Forms.TextBox + $script:hm_textBoxSelectedOU.Dock = "Fill" + $script:hm_textBoxSelectedOU.ReadOnly = $true + + $script:hm_treeViewOUs = New-Object System.Windows.Forms.TreeView + $script:hm_treeViewOUs.Dock = "Fill" + $script:hm_treeViewOUs.HideSelection = $false + + $hmCreateOU_Layout.Controls.Add($script:hm_textBoxSelectedOU, 0, 0) | Out-Null + $hmCreateOU_Layout.Controls.Add($script:hm_treeViewOUs, 0, 1) | Out-Null + + # 최종 실행 버튼 + $script:hm_buttonExecuteAction = New-Object System.Windows.Forms.Button + $script:hm_buttonExecuteAction.Text = "작업 실행" + $script:hm_buttonExecuteAction.Dock = "Fill" + $script:hm_buttonExecuteAction.Enabled = $false + $script:hm_buttonExecuteAction.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold) + $actionOuterLayout.Controls.Add($script:hm_buttonExecuteAction, 0, 1) | Out-Null + + # 이벤트 핸들러 연결 + $buttonSearchAzure.Add_Click($hm_buttonSearchAzure_Click) + $script:hm_listBoxFoundAzure.Add_SelectedIndexChanged($hm_listBoxFoundAzure_SelectedIndexChanged) + $script:hm_listBoxFoundAzure.Add_DoubleClick({ + if ($script:hm_buttonExecuteAction.Enabled) { + $script:hm_buttonExecuteAction.PerformClick() + } + }) + $script:hm_treeViewOUs.Add_AfterSelect($hm_treeViewOUs_AfterSelect) + $script:hm_buttonExecuteAction.Add_Click($hm_buttonExecuteAction_Click) +} +#endregion + +#region '하드 매칭' 탭 이벤트 핸들러 + +# Azure AD 사용자 검색 버튼 클릭 이벤트 +$hm_buttonSearchAzure_Click = { + $searchTerm = $script:hm_textBoxSearchAzure.Text.Trim() + if (-not $searchTerm) { + return + } + + # 비동기 호출로 Azure AD 사용자 검색 + $users = Invoke-Synchronous -TriggerControl $this -StatusMessage "Azure AD의 모든 사용자를 조회하여 필터링합니다..." -ScriptBlock { + $allAzureUsers = Get-MgUser -All -Property "Id,DisplayName,UserPrincipalName,onPremisesImmutableId,Mail,GivenName,Surname" -ErrorAction Stop + return $allAzureUsers | Where-Object { + $_.DisplayName -like "*$searchTerm*" -or + $_.UserPrincipalName -like "*$searchTerm*" -or + $_.Mail -like "*$searchTerm*" + } + } -RequiresAzureAD + + # 검색 결과를 리스트박스에 추가 + $script:hm_listBoxFoundAzure.BeginUpdate() + $script:hm_listBoxFoundAzure.Items.Clear() + if ($users) { + foreach ($user in @($users) | Sort-Object DisplayName) { + $item = [PSCustomObject]@{ + DisplayText = "$($user.DisplayName) ($($user.UserPrincipalName))" + AzureObject = $user + } + $script:hm_listBoxFoundAzure.Items.Add($item) | Out-Null + } + } + $script:hm_listBoxFoundAzure.DisplayMember = "DisplayText" + $script:hm_listBoxFoundAzure.EndUpdate() + Write-Log "총 $(@($users).Count)명의 사용자를 찾았습니다." +} + +# 검색된 Azure AD 사용자 리스트에서 항목 선택 시 이벤트 +$hm_listBoxFoundAzure_SelectedIndexChanged = { + $selectedItem = $this.SelectedItem + if ($null -eq $selectedItem) { + return + } + + # UI 상태 초기화 + $groupOnPrem = $script:hm_textBoxOnPremUser.Parent + $groupCreate = $script:hm_groupCreate + $groupOnPrem.Visible = $false + $groupCreate.Visible = $false + $script:hm_buttonExecuteAction.Enabled = $false + $script:hm_labelGuidMatchStatus.Visible = $false + + # 선택된 Azure AD 사용자 정보 표시 + $script:hm_selectedAzureUser = $selectedItem.AzureObject + $script:hm_textBoxAzureUser.Text = "$($script:hm_selectedAzureUser.DisplayName) ($($script:hm_selectedAzureUser.UserPrincipalName))" + $script:hm_textBoxImmutableId.Text = "ImmutableId: $($script:hm_selectedAzureUser.onPremisesImmutableId)" + + # UPN을 기준으로 온프레미스 AD에서 동일한 SamAccountName을 가진 사용자 검색 + $samAccountName = $script:hm_selectedAzureUser.UserPrincipalName.Split('@')[0] + $onPremUser = Invoke-Synchronous -TriggerControl $this -StatusMessage "온프레미스에서 '$samAccountName' 확인 중..." -ScriptBlock { + Get-ADUser -Filter "SamAccountName -eq '$samAccountName'" -Properties objectGUID, DisplayName, SamAccountName -Server $script:Configuration.OnPremDomainController -ErrorAction SilentlyContinue + } + $script:hm_foundOnPremUser = $onPremUser + + # 분기 처리: 온프레미스에 계정이 있는 경우 vs 없는 경우 + if ($script:hm_foundOnPremUser) { + # [CASE 1] 온프레미스 계정이 존재할 경우: 정보 표시 및 상태 진단 + $groupOnPrem.Visible = $true + $script:hm_groupCreate.Visible = $false + $script:hm_textBoxOnPremUser.Text = "$($script:hm_foundOnPremUser.DisplayName) (sAM: $($script:hm_foundOnPremUser.SamAccountName))" + $script:hm_textBoxObjectGuid.Text = "ObjectGUID: $($script:hm_foundOnPremUser.objectGUID)" + + $immutableIdFromGuid = [System.Convert]::ToBase64String($script:hm_foundOnPremUser.objectGUID.ToByteArray()) + $script:hm_textBoxConvertedImmutableId.Text = "변환된 ImmutableId: $immutableIdFromGuid" + $script:hm_labelGuidMatchStatus.Visible = $true + + # ImmutableId 값 비교를 통해 매칭 상태 진단 + if ([string]::IsNullOrEmpty($script:hm_selectedAzureUser.onPremisesImmutableId)) { + $script:hm_labelGuidMatchStatus.Text = "⚠️ 매칭 필요 (Azure AD ImmutableId 없음)" + $script:hm_labelGuidMatchStatus.ForeColor = [System.Drawing.Color]::Orange + $script:hm_currentMode = "MatchExisting" + $script:hm_buttonExecuteAction.Text = "기존 계정과 하드 매칭 실행" + $script:hm_buttonExecuteAction.Enabled = $true + } + elseif ($script:hm_selectedAzureUser.onPremisesImmutableId -eq $immutableIdFromGuid) { + $script:hm_labelGuidMatchStatus.Text = "✅ ID 일치 (정상 동기화 상태)" + $script:hm_labelGuidMatchStatus.ForeColor = [System.Drawing.Color]::Green + $script:hm_currentMode = "Idle" + $script:hm_buttonExecuteAction.Text = "작업 불필요" + $script:hm_buttonExecuteAction.Enabled = $false + } + else { + $script:hm_labelGuidMatchStatus.Text = "❌ ID 불일치 (강제 매칭 필요)" + $script:hm_labelGuidMatchStatus.ForeColor = [System.Drawing.Color]::Red + $script:hm_currentMode = "MatchExisting" + $script:hm_buttonExecuteAction.Text = "기존 계정과 하드 매칭 실행 (덮어쓰기)" + $script:hm_buttonExecuteAction.Enabled = $true + } + } else { + # [CASE 2] 온프레미스 계정이 없을 경우: 신규 생성 및 매칭 준비 + $groupCreate.Visible = $true + $script:hm_currentMode = "CreateAndMatch" + $script:hm_buttonExecuteAction.Text = "계정 생성 및 하드 매칭 실행" + $script:hm_buttonExecuteAction.Enabled = $true + + # Azure AD 사용자 정보를 기반으로 생성 정보 자동 채우기 + $script:hm_textBoxLastNameKr.Text = $script:hm_selectedAzureUser.Surname + $script:hm_textBoxFirstNameKr.Text = $script:hm_selectedAzureUser.GivenName + + # OU 목록을 TreeView로 로드 + $allOUs = Invoke-Synchronous -TriggerControl $this -StatusMessage "[하드 매칭] OU 목록을 조회합니다..." -ScriptBlock { + Get-ADOrganizationalUnit -Filter * -SearchBase $script:CurrentADDomainDN -SearchScope Subtree -Server $script:Configuration.OnPremDomainController -ErrorAction SilentlyContinue + } + if ($allOUs) { + $ouHierarchy = @{} + foreach ($ou in $allOUs) { + $parentDN = $ou.DistinguishedName -replace '^OU=[^,]+,', '' + if (-not $ouHierarchy.ContainsKey($parentDN)) { + $ouHierarchy[$parentDN] = [System.Collections.ArrayList]::new() + } + $ouHierarchy[$parentDN].Add($ou) | Out-Null + } + $script:hm_treeViewOUs.BeginUpdate() + $script:hm_treeViewOUs.Nodes.Clear() + $rootNode = New-Object System.Windows.Forms.TreeNode($script:CurrentADDomainInfo.DnsRoot) + $rootNode.Tag = $script:CurrentADDomainDN + $script:hm_treeViewOUs.Nodes.Add($rootNode) | Out-Null + Build-OU-TreeView -ParentDN $script:CurrentADDomainDN -ParentUiNode $rootNode -OUHierarchy $ouHierarchy + $rootNode.Expand() + $script:hm_treeViewOUs.EndUpdate() + } + } +} + +# OU TreeView 에서 노드 선택 시 이벤트 +$hm_treeViewOUs_AfterSelect = { + if ($this.SelectedNode -and $this.SelectedNode.Tag) { + $selectedOUDN = $this.SelectedNode.Tag.ToString() + $script:hm_textBoxSelectedOU.Text = $selectedOUDN + + # 기본 배경색으로 초기화 + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::White + + # 루트 도메인은 생성 경로로 선택할 수 없음 + if ($selectedOUDN -eq $script:CurrentADDomainDN) { + Write-Log "루트 도메인은 선택할 수 없습니다." -Level "WARNING" + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::LightGray + return + } + + # 선택된 OU가 AD Connect 동기화 범위에 포함되는지 확인 + $isSynchronized = $false + foreach ($syncRootOU in $script:SynchronizedOURoots) { + if ($selectedOUDN -eq $syncRootOU -or $selectedOUDN.EndsWith(",$syncRootOU", "OrdinalIgnoreCase")) { + $isSynchronized = $true + break + } + } + + # 동기화 여부에 따라 배경색 변경으로 사용자에게 시각적 피드백 제공 + if ($isSynchronized) { + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::LightGreen + } else { + Write-Log "주의: 선택된 OU($selectedOUDN)는 동기화 대상이 아닐 수 있습니다!" -Level "WARNING" + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::LightPink + } + } else { + # 선택 해제 시 텍스트박스 초기화 + $script:hm_textBoxSelectedOU.Text = "" + $script:hm_textBoxSelectedOU.BackColor = [System.Drawing.Color]::White + } +} + +# '작업 실행' 버튼 클릭 이벤트 (모든 하드 매칭 작업의 시작점) +$hm_buttonExecuteAction_Click = { + $isSyncMode = $script:hm_currentMode -ne "Idle" + $success = Invoke-Synchronous -TriggerControl $this -StatusMessage "하드 매칭 작업을 실행합니다..." -ScriptBlock { + if (-not $script:hm_selectedAzureUser) { + return $false + } + + $onPremUserToMatch = $null + + # 모드 1: 신규 계정 생성 후 매칭 + if ($script:hm_currentMode -eq "CreateAndMatch") { + Write-Log "하드 매칭 모드: 신규 계정 생성 및 매칭" + $sam = $script:hm_selectedAzureUser.UserPrincipalName.Split('@')[0] + + # SamAccountName 유효성 및 중복 검사 + $validationResult = Test-SamAccountName -AccountName $sam + if (-not $validationResult.IsValid) { + if ($validationResult.Reason -like "*이미 사용 중인 계정명*") { + throw "온프레미스에 SamAccountName이 '$sam'인 계정이 이미 존재합니다. 해당 계정과 직접 매칭을 시도하거나, Azure AD 사용자의 UPN을 변경한 후 다시 시도하세요." + } else { + throw "생성하려는 계정명 '$sam'이 규칙에 맞지 않습니다: $($validationResult.Reason)" + } + } + + $upn = "$sam@$($script:Configuration.UPNSuffix)" + $password = $script:hm_textBoxPassword.Text + $targetOU = $script:hm_textBoxSelectedOU.Text + if (-not ($password -and $targetOU)) { + throw "신규 생성 시 비밀번호와 대상 OU는 필수입니다." + } + + # 신규 AD 사용자 생성을 위한 파라미터 준비 + $newUserParams = @{ + SamAccountName = $sam + UserPrincipalName = $upn + Name = $script:hm_selectedAzureUser.DisplayName + DisplayName = $script:hm_selectedAzureUser.DisplayName + GivenName = $script:hm_textBoxFirstNameKr.Text + Surname = $script:hm_textBoxLastNameKr.Text + Path = $targetOU + EmailAddress = $script:hm_selectedAzureUser.Mail + AccountPassword = (ConvertTo-SecureString $password -AsPlainText -Force) + Enabled = $true + } + $onPremUserToMatch = New-ADUser @newUserParams -PassThru -Server $script:Configuration.OnPremDomainController -ErrorAction Stop + Write-Log "온프레미스 AD 사용자 '$($onPremUserToMatch.Name)' 생성 성공." + + # 모드 2: 기존 계정과 매칭 + } elseif ($script:hm_currentMode -eq "MatchExisting") { + Write-Log "하드 매칭 모드: 기존 계정과 매칭" + $onPremUserToMatch = $script:hm_foundOnPremUser + } else { + throw "알 수 없는 작업 모드입니다: $($script:hm_currentMode)" + } + + if (-not $onPremUserToMatch) { + throw "매칭할 온프레미스 AD 계정을 식별할 수 없습니다." + } + + # 대상 온프레미스 계정의 ObjectGUID를 ImmutableId로 변환하여 Azure AD에 설정 + $immutableId = [System.Convert]::ToBase64String((Get-ADUser -Identity $onPremUserToMatch.SamAccountName -Properties objectGUID).ObjectGUID.ToByteArray()) + Write-Log "하드 매칭 실행: '$($script:hm_selectedAzureUser.UserPrincipalName)'의 ImmutableId를 '$immutableId'(으)로 설정합니다." + Set-MgUser -UserId $script:hm_selectedAzureUser.Id -OnPremisesImmutableId $immutableId -ErrorAction Stop + + # 즉시 델타 동기화 실행 + Invoke-AadConnectSync -PolicyType Delta + + # 최종 검증: ImmutableId가 올바르게 업데이트되었는지 확인 (최대 30초 대기) + Write-Log "검증 단계: 업데이트된 ImmutableId를 다시 조회합니다 (최대 30초 대기)..." + for ($i = 0; $i -lt 3; $i++) { + Start-Sleep -Seconds 10 + $refreshedUser = Get-MgUser -UserId $script:hm_selectedAzureUser.Id -Property "onPremisesImmutableId" -ErrorAction SilentlyContinue + if ($refreshedUser.onPremisesImmutableId -eq $immutableId) { + return $true + } + } + throw "검증 실패: ImmutableId가 업데이트되지 않았습니다." + } -RequiresAzureAD:$isSyncMode + + if ($success) { + [System.Windows.Forms.MessageBox]::Show("하드 매칭 및 동기화, 최종 검증까지 성공적으로 완료되었습니다.", "모든 작업 성공", "OK", "Information") | Out-Null + Reset-HardMatchTab + } +} +#endregion + +Write-Host "UI-Tab-HardMatch.ps1 로드 완료." -ForegroundColor Cyan \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..26a1d26 --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "OnPremDomainController": "oneunivrs.corp", + "AADConnectServerName": "DC001", + "AzureTenantId": "1e8605cc-8007-46b0-993f-b388917f9499", + "UPNSuffix": "oneunivrs.com", + "SynchronizedOUs": "Dev;Newbie;액션스퀘어;원유니버스", + "DefaultPassword": "action!1234", + "DefaultUsageLocation": "KR" +} diff --git a/logs/ad_tool_2025-06-17.log b/logs/ad_tool_2025-06-17.log new file mode 100644 index 0000000..4bd1733 --- /dev/null +++ b/logs/ad_tool_2025-06-17.log @@ -0,0 +1,64 @@ +2025-06-17 12:33:05 [INFO]: AD/M365 통합 관리 도구(v1.13)를 시작합니다. +2025-06-17 12:33:12 [INFO]: 설정 정보 저장 완료. +2025-06-17 12:33:12 [INFO]: 스크립트 환경 초기화 시작. +2025-06-17 12:33:12 [INFO]: 온프레미스 AD 도메인 정보 로드 성공: oneunivrs +2025-06-17 12:33:12 [INFO]: 동기화 OU 루트 추가: OU=Dev,DC=oneunivrs,DC=corp +2025-06-17 12:33:12 [INFO]: 동기화 OU 루트 추가: OU=Newbie,DC=oneunivrs,DC=corp +2025-06-17 12:33:12 [INFO]: 동기화 OU 루트 추가: OU=액션스퀘어,DC=oneunivrs,DC=corp +2025-06-17 12:33:12 [INFO]: 동기화 OU 루트 추가: OU=원유니버스,DC=oneunivrs,DC=corp +2025-06-17 12:33:12 [INFO]: Azure AD 자동 연결을 시도합니다... +2025-06-17 12:33:16 [INFO]: Azure AD 자동 연결 성공. +2025-06-17 12:33:16 [INFO]: 라이선스 목록 조회 중... +2025-06-17 12:33:19 [INFO]: [계정 생성] 라이선스 목록 갱신 완료. +2025-06-17 12:33:19 [INFO]: [계정 생성] OU 목록을 조회합니다... +2025-06-17 12:33:19 [INFO]: [계정 생성] OU 목록 로드/갱신 완료. +2025-06-17 12:33:39 [INFO]: 온프레미스 AD에서 사용자 '철민' 검색 중... +2025-06-17 12:33:39 [INFO]: 1명의 사용자를 찾았습니다. +2025-06-17 12:33:44 [INFO]: 온프레미스 AD에서 사용자 '범진' 검색 중... +2025-06-17 12:33:44 [INFO]: 1명의 사용자를 찾았습니다. +2025-06-17 12:34:28 [INFO]: 프로그램을 종료합니다. +2025-06-17 12:40:30 [INFO]: AD/M365 통합 관리 도구(v1.13)를 시작합니다. +2025-06-17 12:40:42 [INFO]: 설정 정보 저장 완료. +2025-06-17 12:40:42 [INFO]: 스크립트 환경 초기화 시작. +2025-06-17 12:40:43 [INFO]: 온프레미스 AD 도메인 정보 로드 성공: oneunivrs +2025-06-17 12:40:43 [INFO]: 동기화 OU 루트 추가: OU=Dev,DC=oneunivrs,DC=corp +2025-06-17 12:40:43 [INFO]: 동기화 OU 루트 추가: OU=Newbie,DC=oneunivrs,DC=corp +2025-06-17 12:40:43 [INFO]: 동기화 OU 루트 추가: OU=액션스퀘어,DC=oneunivrs,DC=corp +2025-06-17 12:40:43 [INFO]: 동기화 OU 루트 추가: OU=원유니버스,DC=oneunivrs,DC=corp +2025-06-17 12:40:43 [INFO]: Azure AD 자동 연결을 시도합니다... +2025-06-17 12:40:43 [INFO]: Azure AD 자동 연결 성공. +2025-06-17 12:40:43 [INFO]: 라이선스 목록 조회 중... +2025-06-17 12:40:43 [INFO]: [계정 생성] 라이선스 목록 갱신 완료. +2025-06-17 12:40:43 [INFO]: [계정 생성] OU 목록을 조회합니다... +2025-06-17 12:40:43 [INFO]: [계정 생성] OU 목록 로드/갱신 완료. +2025-06-17 12:40:57 [INFO]: 프로그램을 종료합니다. +2025-06-17 12:52:43 [INFO]: AD/M365 통합 관리 도구를 시작합니다. +2025-06-17 12:52:59 [INFO]: 설정 정보 저장 완료. +2025-06-17 12:52:59 [INFO]: 스크립트 환경 초기화 시작. +2025-06-17 12:52:59 [INFO]: 온프레미스 AD 도메인 정보 로드 성공: oneunivrs +2025-06-17 12:52:59 [INFO]: 동기화 OU 루트 추가: OU=Dev,DC=oneunivrs,DC=corp +2025-06-17 12:52:59 [INFO]: 동기화 OU 루트 추가: OU=Newbie,DC=oneunivrs,DC=corp +2025-06-17 12:52:59 [INFO]: 동기화 OU 루트 추가: OU=액션스퀘어,DC=oneunivrs,DC=corp +2025-06-17 12:52:59 [INFO]: 동기화 OU 루트 추가: OU=원유니버스,DC=oneunivrs,DC=corp +2025-06-17 12:52:59 [INFO]: Azure AD 자동 연결을 시도합니다... +2025-06-17 12:52:59 [INFO]: Azure AD 자동 연결 성공. +2025-06-17 12:52:59 [INFO]: 라이선스 목록 조회 중... +2025-06-17 12:53:00 [INFO]: [계정 생성] 라이선스 목록 갱신 완료. +2025-06-17 12:53:00 [INFO]: [계정 생성] OU 목록을 조회합니다... +2025-06-17 12:53:00 [INFO]: [계정 생성] OU 목록 로드/갱신 완료. +2025-06-17 12:53:15 [INFO]: 계정 생성 작업을 실행합니다... +2025-06-17 12:53:15 [INFO]: 온프레미스 AD 사용자 생성을 시작합니다: mrkim +2025-06-17 12:53:16 [INFO]: 온프레미스 AD 사용자 'mrkim' 생성 성공. +2025-06-17 12:53:16 [INFO]: AAD Connect 동기화 실행 중... +2025-06-17 12:53:18 [SUCCESS]: AAD Connect 동기화 명령 전송 완료. 클라우드 적용까지 시간이 소요될 수 있습니다. +2025-06-17 12:53:18 [INFO]: Azure AD에서 사용자 'mrkim@oneunivrs.com' 확인 시도 (최대 120초 대기)... +2025-06-17 12:53:18 [INFO]: Azure AD에서 사용자 확인 중... (0초 경과) +2025-06-17 12:53:28 [INFO]: Azure AD에서 사용자 확인 중... (10초 경과) +2025-06-17 12:53:38 [INFO]: Azure AD에서 사용자 확인 중... (20초 경과) +2025-06-17 12:53:38 [SUCCESS]: 사용자 'mrkim@oneunivrs.com'을(를) Azure AD에서 찾았습니다! (소요 시간: 20초) +2025-06-17 12:53:39 [INFO]: 사용 위치 설정 중: KR +2025-06-17 12:53:55 [INFO]: 라이선스 목록 조회 중... +2025-06-17 12:53:55 [INFO]: [계정 생성] 라이선스 목록 갱신 완료. +2025-06-17 12:54:07 [INFO]: 온프레미스 AD에서 사용자 '가네' 검색 중... +2025-06-17 12:54:08 [INFO]: 1명의 사용자를 찾았습니다. +2025-06-17 12:57:47 [INFO]: 프로그램을 종료합니다.