convert to gitea

This commit is contained in:
2025-09-15 13:46:19 +09:00
commit 21921c1b53
9 changed files with 3259 additions and 0 deletions

654
Scripts/Common.ps1 Normal file
View File

@ -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

490
Scripts/UI-Tab-AddUser.ps1 Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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