기능구현중
This commit is contained in:
59
backend/api/admin/vcs/status.get.ts
Normal file
59
backend/api/admin/vcs/status.get.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { query } from '../../../utils/db'
|
||||||
|
import { requireAuth } from '../../../utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 VCS 동기화 상태 조회 (관리자용)
|
||||||
|
* GET /api/admin/vcs/status
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAuth(event)
|
||||||
|
|
||||||
|
// 저장소별 동기화 상태
|
||||||
|
const repos = await query(`
|
||||||
|
SELECT
|
||||||
|
r.repo_id, r.repo_name, r.repo_path,
|
||||||
|
r.last_sync_at, r.last_sync_status, r.last_sync_message,
|
||||||
|
s.server_type, s.server_name,
|
||||||
|
p.project_name,
|
||||||
|
(SELECT COUNT(*) FROM wr_commit_log c WHERE c.repo_id = r.repo_id) as commit_count
|
||||||
|
FROM wr_repository r
|
||||||
|
JOIN wr_vcs_server s ON r.server_id = s.server_id
|
||||||
|
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
|
||||||
|
WHERE r.is_active = true
|
||||||
|
ORDER BY r.last_sync_at DESC NULLS LAST
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 전체 통계
|
||||||
|
const stats = await query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT r.repo_id) as total_repos,
|
||||||
|
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'SUCCESS' THEN r.repo_id END) as success_repos,
|
||||||
|
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'FAILED' THEN r.repo_id END) as failed_repos,
|
||||||
|
COUNT(DISTINCT CASE WHEN r.last_sync_at IS NULL THEN r.repo_id END) as never_synced,
|
||||||
|
(SELECT COUNT(*) FROM wr_commit_log) as total_commits
|
||||||
|
FROM wr_repository r
|
||||||
|
WHERE r.is_active = true
|
||||||
|
`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositories: repos.map(r => ({
|
||||||
|
repoId: r.repo_id,
|
||||||
|
repoName: r.repo_name,
|
||||||
|
repoPath: r.repo_path,
|
||||||
|
serverType: r.server_type,
|
||||||
|
serverName: r.server_name,
|
||||||
|
projectName: r.project_name,
|
||||||
|
lastSyncAt: r.last_sync_at,
|
||||||
|
lastSyncStatus: r.last_sync_status,
|
||||||
|
lastSyncMessage: r.last_sync_message,
|
||||||
|
commitCount: parseInt(r.commit_count || '0')
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
totalRepos: parseInt(stats[0]?.total_repos || '0'),
|
||||||
|
successRepos: parseInt(stats[0]?.success_repos || '0'),
|
||||||
|
failedRepos: parseInt(stats[0]?.failed_repos || '0'),
|
||||||
|
neverSynced: parseInt(stats[0]?.never_synced || '0'),
|
||||||
|
totalCommits: parseInt(stats[0]?.total_commits || '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
63
backend/api/admin/vcs/sync-all.post.ts
Normal file
63
backend/api/admin/vcs/sync-all.post.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { query } from '../../../utils/db'
|
||||||
|
import { requireAuth } from '../../../utils/session'
|
||||||
|
import { syncGitRepository } from '../../../utils/git-sync'
|
||||||
|
import { syncSvnRepository } from '../../../utils/svn-sync'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 VCS 저장소 동기화 (관리자용)
|
||||||
|
* POST /api/admin/vcs/sync-all
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAuth(event)
|
||||||
|
|
||||||
|
// 모든 활성 저장소 조회
|
||||||
|
const repos = await query(`
|
||||||
|
SELECT r.repo_id, r.repo_name, s.server_type
|
||||||
|
FROM wr_repository r
|
||||||
|
JOIN wr_vcs_server s ON r.server_id = s.server_id
|
||||||
|
WHERE r.is_active = true AND s.is_active = true
|
||||||
|
ORDER BY r.repo_name
|
||||||
|
`)
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (repo.server_type === 'GIT') {
|
||||||
|
result = await syncGitRepository(repo.repo_id)
|
||||||
|
} else if (repo.server_type === 'SVN') {
|
||||||
|
result = await syncSvnRepository(repo.repo_id)
|
||||||
|
} else {
|
||||||
|
result = { success: false, message: '지원하지 않는 서버 타입' }
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
repoId: repo.repo_id,
|
||||||
|
repoName: repo.repo_name,
|
||||||
|
serverType: repo.server_type,
|
||||||
|
...result
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({
|
||||||
|
repoId: repo.repo_id,
|
||||||
|
repoName: repo.repo_name,
|
||||||
|
serverType: repo.server_type,
|
||||||
|
success: false,
|
||||||
|
message: e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.success).length
|
||||||
|
const failCount = results.filter(r => !r.success).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: failCount === 0,
|
||||||
|
message: `동기화 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
|
||||||
|
totalRepos: repos.length,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
results
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Google OAuth 시작
|
* Google OAuth 시작
|
||||||
* GET /api/auth/google
|
* GET /api/auth/google
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - extend: 'groups' - 구글 그룹 접근 권한 추가 요청
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
||||||
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
||||||
@@ -12,7 +16,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
|
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const scope = encodeURIComponent('openid email profile')
|
// 기본 scope + 확장 scope
|
||||||
|
let scopes = ['openid', 'email', 'profile']
|
||||||
|
|
||||||
|
// 구글 그룹 권한 요청 시 추가 scope
|
||||||
|
if (query.extend === 'groups') {
|
||||||
|
scopes.push(
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly', // 그룹 메일 읽기
|
||||||
|
'https://www.googleapis.com/auth/cloud-identity.groups.readonly' // 그룹 정보 읽기
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = encodeURIComponent(scopes.join(' '))
|
||||||
const state = Math.random().toString(36).substring(7) // CSRF 방지
|
const state = Math.random().toString(36).substring(7) // CSRF 방지
|
||||||
|
|
||||||
// state를 쿠키에 저장
|
// state를 쿠키에 저장
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
updated_ip,
|
updated_ip,
|
||||||
password_hash,
|
password_hash,
|
||||||
google_id,
|
google_id,
|
||||||
google_email
|
google_email,
|
||||||
|
synology_id,
|
||||||
|
synology_email
|
||||||
FROM wr_employee_info
|
FROM wr_employee_info
|
||||||
WHERE employee_id = $1
|
WHERE employee_id = $1
|
||||||
`, [session.employeeId])
|
`, [session.employeeId])
|
||||||
@@ -60,7 +62,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
updatedIp: employee.updated_ip,
|
updatedIp: employee.updated_ip,
|
||||||
hasPassword: !!employee.password_hash,
|
hasPassword: !!employee.password_hash,
|
||||||
googleId: employee.google_id,
|
googleId: employee.google_id,
|
||||||
googleEmail: employee.google_email
|
googleEmail: employee.google_email,
|
||||||
|
synologyId: employee.synology_id,
|
||||||
|
synologyEmail: employee.synology_email
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
107
backend/api/auth/synology/callback.get.ts
Normal file
107
backend/api/auth/synology/callback.get.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { queryOne, execute } from '../../../utils/db'
|
||||||
|
import { createSession } from '../../../utils/session'
|
||||||
|
import { getClientIp } from '../../../utils/ip'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synology SSO 콜백
|
||||||
|
* GET /api/auth/synology/callback
|
||||||
|
*
|
||||||
|
* Synology SSO Server에서 인증 후 리다이렉트되는 엔드포인트
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const query = getQuery(event)
|
||||||
|
const ip = getClientIp(event)
|
||||||
|
|
||||||
|
const code = query.code as string
|
||||||
|
const error = query.error as string
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return sendRedirect(event, `/login?error=${encodeURIComponent('Synology 인증이 취소되었습니다.')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('인증 코드가 없습니다.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 코드로 액세스 토큰 교환
|
||||||
|
const tokenResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOAccessToken.cgi`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
client_id: config.synologyClientId,
|
||||||
|
client_secret: config.synologyClientSecret,
|
||||||
|
redirect_uri: config.synologyRedirectUri
|
||||||
|
}).toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.access_token) {
|
||||||
|
console.error('Synology token error:', tokenResponse)
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('토큰 획득 실패'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 액세스 토큰으로 사용자 정보 조회
|
||||||
|
const userResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOUserInfo.cgi`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenResponse.access_token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userResponse.data || !userResponse.data.email) {
|
||||||
|
console.error('Synology user info error:', userResponse)
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('사용자 정보를 가져올 수 없습니다.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const synologyEmail = userResponse.data.email
|
||||||
|
const synologyId = userResponse.data.user_id || userResponse.data.uid
|
||||||
|
const synologyName = userResponse.data.name || userResponse.data.username
|
||||||
|
|
||||||
|
// 3. 이메일로 사용자 매칭
|
||||||
|
const employee = await queryOne<any>(`
|
||||||
|
SELECT employee_id, employee_name, is_active, password_hash,
|
||||||
|
synology_id, synology_email
|
||||||
|
FROM wr_employee_info
|
||||||
|
WHERE email = $1
|
||||||
|
`, [synologyEmail])
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('등록되지 않은 사용자입니다. 관리자에게 문의하세요.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employee.is_active) {
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('비활성화된 계정입니다.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Synology 계정 연결 정보 업데이트
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_employee_info
|
||||||
|
SET synology_id = $1, synology_email = $2, synology_linked_at = NOW()
|
||||||
|
WHERE employee_id = $3
|
||||||
|
`, [synologyId, synologyEmail, employee.employee_id])
|
||||||
|
|
||||||
|
// 5. 로그인 이력 기록
|
||||||
|
await execute(`
|
||||||
|
INSERT INTO wr_login_history (employee_id, login_type, login_ip, login_at, login_success, login_email)
|
||||||
|
VALUES ($1, 'SYNOLOGY', $2, NOW(), true, $3)
|
||||||
|
`, [employee.employee_id, ip, synologyEmail])
|
||||||
|
|
||||||
|
// 6. 세션 생성
|
||||||
|
await createSession(event, employee.employee_id)
|
||||||
|
|
||||||
|
// 7. 비밀번호 미설정 시 설정 페이지로
|
||||||
|
if (!employee.password_hash) {
|
||||||
|
return sendRedirect(event, '/set-password?from=synology')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 메인 페이지로 리다이렉트
|
||||||
|
return sendRedirect(event, '/')
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Synology OAuth error:', e)
|
||||||
|
return sendRedirect(event, '/login?error=' + encodeURIComponent('Synology 인증 중 오류가 발생했습니다.'))
|
||||||
|
}
|
||||||
|
})
|
||||||
26
backend/api/auth/synology/index.get.ts
Normal file
26
backend/api/auth/synology/index.get.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Synology SSO 로그인 시작
|
||||||
|
* GET /api/auth/synology
|
||||||
|
*
|
||||||
|
* Synology SSO Server OAuth 2.0 인증 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
if (!config.synologyServerUrl || !config.synologyClientId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Synology SSO가 설정되지 않았습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synology SSO Server OAuth 인증 URL
|
||||||
|
const authUrl = new URL(`${config.synologyServerUrl}/webman/sso/SSOOauth.cgi`)
|
||||||
|
authUrl.searchParams.set('response_type', 'code')
|
||||||
|
authUrl.searchParams.set('client_id', config.synologyClientId)
|
||||||
|
authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri)
|
||||||
|
authUrl.searchParams.set('scope', 'user_id')
|
||||||
|
authUrl.searchParams.set('state', crypto.randomUUID())
|
||||||
|
|
||||||
|
return sendRedirect(event, authUrl.toString())
|
||||||
|
})
|
||||||
102
backend/api/google-group/list.get.ts
Normal file
102
backend/api/google-group/list.get.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { queryOne } from '../../utils/db'
|
||||||
|
import { requireAuth } from '../../utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구글 그룹 목록 조회
|
||||||
|
* GET /api/google-group/list
|
||||||
|
*
|
||||||
|
* Cloud Identity Groups API 사용
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await requireAuth(event)
|
||||||
|
|
||||||
|
// 사용자의 Google 토큰 조회
|
||||||
|
const employee = await queryOne<any>(`
|
||||||
|
SELECT google_access_token, google_refresh_token, google_token_expires_at
|
||||||
|
FROM wr_employee_info WHERE employee_id = $1
|
||||||
|
`, [session.employeeId])
|
||||||
|
|
||||||
|
if (!employee?.google_access_token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Google 계정이 연결되지 않았습니다. 먼저 Google 로그인을 해주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken = employee.google_access_token
|
||||||
|
|
||||||
|
// 토큰 만료 확인 및 갱신
|
||||||
|
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
|
||||||
|
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cloud Identity Groups API로 그룹 목록 조회
|
||||||
|
const response = await $fetch<any>('https://cloudidentity.googleapis.com/v1/groups:search', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
query: {
|
||||||
|
query: 'parent == "customers/my_customer" && "cloudidentity.googleapis.com/groups.discussion_forum" in labels',
|
||||||
|
pageSize: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: (response.groups || []).map((g: any) => ({
|
||||||
|
groupId: g.name?.split('/')[1],
|
||||||
|
groupKey: g.groupKey?.id,
|
||||||
|
displayName: g.displayName,
|
||||||
|
description: g.description,
|
||||||
|
email: g.groupKey?.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Google Groups API error:', e)
|
||||||
|
|
||||||
|
// 권한 없음 에러
|
||||||
|
if (e.status === 403) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: '구글 그룹 접근 권한이 없습니다. 관리자에게 문의하세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '구글 그룹 목록을 가져오는데 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 토큰 갱신
|
||||||
|
*/
|
||||||
|
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.googleClientId,
|
||||||
|
client_secret: config.googleClientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
}).toString(),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.access_token) {
|
||||||
|
// DB 업데이트
|
||||||
|
const { execute } = await import('../../utils/db')
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_employee_info
|
||||||
|
SET google_access_token = $1,
|
||||||
|
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
|
||||||
|
WHERE employee_id = $2
|
||||||
|
`, [response.access_token, employeeId])
|
||||||
|
|
||||||
|
return response.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('토큰 갱신 실패')
|
||||||
|
}
|
||||||
139
backend/api/google-group/messages.get.ts
Normal file
139
backend/api/google-group/messages.get.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { queryOne, execute } from '../../utils/db'
|
||||||
|
import { requireAuth } from '../../utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구글 그룹 메시지 조회
|
||||||
|
* GET /api/google-group/messages
|
||||||
|
*
|
||||||
|
* Gmail API로 그룹 이메일 조회
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - groupEmail: 그룹 이메일 주소 (예: dev-team@company.com)
|
||||||
|
* - maxResults: 최대 결과 수 (기본 20)
|
||||||
|
* - after: 이 날짜 이후 메시지 (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await requireAuth(event)
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const groupEmail = query.groupEmail as string
|
||||||
|
const maxResults = parseInt(query.maxResults as string) || 20
|
||||||
|
const after = query.after as string
|
||||||
|
|
||||||
|
if (!groupEmail) {
|
||||||
|
throw createError({ statusCode: 400, message: '그룹 이메일이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자의 Google 토큰 조회
|
||||||
|
const employee = await queryOne<any>(`
|
||||||
|
SELECT google_access_token, google_refresh_token, google_token_expires_at
|
||||||
|
FROM wr_employee_info WHERE employee_id = $1
|
||||||
|
`, [session.employeeId])
|
||||||
|
|
||||||
|
if (!employee?.google_access_token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Google 계정이 연결되지 않았습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken = employee.google_access_token
|
||||||
|
|
||||||
|
// 토큰 만료 확인 및 갱신
|
||||||
|
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
|
||||||
|
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gmail API로 그룹 메일 검색
|
||||||
|
let searchQuery = `list:${groupEmail}`
|
||||||
|
if (after) {
|
||||||
|
searchQuery += ` after:${after}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const listResponse = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages', {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
query: {
|
||||||
|
q: searchQuery,
|
||||||
|
maxResults: maxResults
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!listResponse.messages || listResponse.messages.length === 0) {
|
||||||
|
return { messages: [], total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 메시지의 상세 정보 조회
|
||||||
|
const messages = await Promise.all(
|
||||||
|
listResponse.messages.slice(0, maxResults).map(async (msg: any) => {
|
||||||
|
const detail = await $fetch<any>(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
query: { format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = detail.payload?.headers || []
|
||||||
|
const getHeader = (name: string) => headers.find((h: any) => h.name === name)?.value || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
threadId: msg.threadId,
|
||||||
|
subject: getHeader('Subject'),
|
||||||
|
from: getHeader('From'),
|
||||||
|
to: getHeader('To'),
|
||||||
|
date: getHeader('Date'),
|
||||||
|
snippet: detail.snippet
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
total: listResponse.resultSizeEstimate || messages.length
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Gmail API error:', e)
|
||||||
|
|
||||||
|
if (e.status === 403) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Gmail 접근 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '그룹 메시지를 가져오는데 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 토큰 갱신
|
||||||
|
*/
|
||||||
|
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.googleClientId,
|
||||||
|
client_secret: config.googleClientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
}).toString(),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.access_token) {
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_employee_info
|
||||||
|
SET google_access_token = $1,
|
||||||
|
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
|
||||||
|
WHERE employee_id = $2
|
||||||
|
`, [response.access_token, employeeId])
|
||||||
|
|
||||||
|
return response.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('토큰 갱신 실패')
|
||||||
|
}
|
||||||
207
backend/api/google-group/share-report.post.ts
Normal file
207
backend/api/google-group/share-report.post.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { queryOne, execute, query } from '../../utils/db'
|
||||||
|
import { requireAuth } from '../../utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간보고를 구글 그룹에 공유 (이메일 전송)
|
||||||
|
* POST /api/google-group/share-report
|
||||||
|
*
|
||||||
|
* Gmail API로 그룹에 이메일 전송
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - reportId: 주간보고 ID
|
||||||
|
* - groupEmail: 그룹 이메일 주소
|
||||||
|
* - subject?: 이메일 제목 (기본값 자동 생성)
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await requireAuth(event)
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const { reportId, groupEmail, subject } = body
|
||||||
|
|
||||||
|
if (!reportId || !groupEmail) {
|
||||||
|
throw createError({ statusCode: 400, message: '보고서 ID와 그룹 이메일이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 주간보고 조회
|
||||||
|
const report = await queryOne<any>(`
|
||||||
|
SELECT r.*, e.employee_name, e.employee_email, p.project_name
|
||||||
|
FROM wr_weekly_report r
|
||||||
|
JOIN wr_employee_info e ON r.employee_id = e.employee_id
|
||||||
|
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
|
||||||
|
WHERE r.report_id = $1
|
||||||
|
`, [reportId])
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인 (본인 보고서만)
|
||||||
|
if (report.employee_id !== session.employeeId) {
|
||||||
|
throw createError({ statusCode: 403, message: '본인의 주간보고만 공유할 수 있습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자의 Google 토큰 조회
|
||||||
|
const employee = await queryOne<any>(`
|
||||||
|
SELECT google_access_token, google_refresh_token, google_token_expires_at, employee_email
|
||||||
|
FROM wr_employee_info WHERE employee_id = $1
|
||||||
|
`, [session.employeeId])
|
||||||
|
|
||||||
|
if (!employee?.google_access_token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Google 계정이 연결되지 않았습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken = employee.google_access_token
|
||||||
|
|
||||||
|
// 토큰 만료 확인 및 갱신
|
||||||
|
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
|
||||||
|
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 내용 생성
|
||||||
|
const emailSubject = subject || `[주간보고] ${report.project_name || '개인'} - ${report.report_week}주차 (${report.employee_name})`
|
||||||
|
const emailBody = generateReportEmailBody(report)
|
||||||
|
|
||||||
|
// RFC 2822 형식의 이메일 메시지 생성
|
||||||
|
const emailLines = [
|
||||||
|
`From: ${employee.employee_email}`,
|
||||||
|
`To: ${groupEmail}`,
|
||||||
|
`Subject: =?UTF-8?B?${Buffer.from(emailSubject).toString('base64')}?=`,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/html; charset=UTF-8',
|
||||||
|
'',
|
||||||
|
emailBody
|
||||||
|
]
|
||||||
|
|
||||||
|
const rawEmail = Buffer.from(emailLines.join('\r\n'))
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gmail API로 이메일 전송
|
||||||
|
const response = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: { raw: rawEmail }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 공유 이력 저장
|
||||||
|
await execute(`
|
||||||
|
INSERT INTO wr_report_share_log (report_id, shared_to, shared_type, shared_by, message_id)
|
||||||
|
VALUES ($1, $2, 'GOOGLE_GROUP', $3, $4)
|
||||||
|
`, [reportId, groupEmail, session.employeeId, response.id])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${groupEmail}로 주간보고가 공유되었습니다.`,
|
||||||
|
messageId: response.id
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Gmail send error:', e)
|
||||||
|
|
||||||
|
if (e.status === 403) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Gmail 발송 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '이메일 발송에 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간보고 이메일 본문 생성
|
||||||
|
*/
|
||||||
|
function generateReportEmailBody(report: any): string {
|
||||||
|
const weekRange = `${report.week_start_date?.split('T')[0] || ''} ~ ${report.week_end_date?.split('T')[0] || ''}`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family: 'Malgun Gothic', sans-serif; padding: 20px; max-width: 800px;">
|
||||||
|
<h2 style="color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
|
||||||
|
📋 주간업무보고
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>작성자</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.employee_name}</td>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>프로젝트</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.project_name || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa;"><strong>보고 주차</strong></td>
|
||||||
|
<td style="padding: 8px;">${report.report_year}년 ${report.report_week}주차</td>
|
||||||
|
<td style="padding: 8px; background: #f8f9fa;"><strong>기간</strong></td>
|
||||||
|
<td style="padding: 8px;">${weekRange}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color: #28a745;">✅ 금주 실적</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.this_week_work || '(내용 없음)'}</div>
|
||||||
|
|
||||||
|
<h3 style="color: #007bff;">📅 차주 계획</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.next_week_plan || '(내용 없음)'}</div>
|
||||||
|
|
||||||
|
${report.issues ? `
|
||||||
|
<h3 style="color: #dc3545;">⚠️ 이슈사항</h3>
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.issues}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${report.remarks ? `
|
||||||
|
<h3 style="color: #6c757d;">📝 비고</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;">${report.remarks}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<hr style="margin-top: 30px; border: none; border-top: 1px solid #ddd;">
|
||||||
|
<p style="color: #6c757d; font-size: 12px;">
|
||||||
|
이 메일은 주간업무보고 시스템에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 토큰 갱신
|
||||||
|
*/
|
||||||
|
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.googleClientId,
|
||||||
|
client_secret: config.googleClientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
}).toString(),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.access_token) {
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_employee_info
|
||||||
|
SET google_access_token = $1,
|
||||||
|
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
|
||||||
|
WHERE employee_id = $2
|
||||||
|
`, [response.access_token, employeeId])
|
||||||
|
|
||||||
|
return response.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('토큰 갱신 실패')
|
||||||
|
}
|
||||||
102
backend/plugins/vcs-sync-cron.ts
Normal file
102
backend/plugins/vcs-sync-cron.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { query } from '../utils/db'
|
||||||
|
import { syncGitRepository } from '../utils/git-sync'
|
||||||
|
import { syncSvnRepository } from '../utils/svn-sync'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VCS 저장소 자동 동기화 Cron Job
|
||||||
|
* 매일 새벽 3시에 실행
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 마지막 실행 시간 체크
|
||||||
|
let lastSyncDate = ''
|
||||||
|
|
||||||
|
function getTodayDate() {
|
||||||
|
return new Date().toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentHour() {
|
||||||
|
return new Date().getHours()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 활성 저장소 동기화
|
||||||
|
*/
|
||||||
|
async function syncAllRepositories() {
|
||||||
|
console.log('[VCS-SYNC] 자동 동기화 시작:', new Date().toISOString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 활성 저장소 조회
|
||||||
|
const repos = await query(`
|
||||||
|
SELECT r.repo_id, r.repo_name, s.server_type
|
||||||
|
FROM wr_repository r
|
||||||
|
JOIN wr_vcs_server s ON r.server_id = s.server_id
|
||||||
|
WHERE r.is_active = true AND s.is_active = true
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log(`[VCS-SYNC] 동기화 대상 저장소: ${repos.length}개`)
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (repo.server_type === 'GIT') {
|
||||||
|
result = await syncGitRepository(repo.repo_id)
|
||||||
|
} else if (repo.server_type === 'SVN') {
|
||||||
|
result = await syncSvnRepository(repo.repo_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
successCount++
|
||||||
|
console.log(`[VCS-SYNC] ✓ ${repo.repo_name}: ${result.message}`)
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
console.log(`[VCS-SYNC] ✗ ${repo.repo_name}: ${result?.message || '알 수 없는 오류'}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
failCount++
|
||||||
|
console.error(`[VCS-SYNC] ✗ ${repo.repo_name} 오류:`, e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장소 간 1초 대기 (서버 부하 방지)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[VCS-SYNC] 동기화 완료: 성공 ${successCount}개, 실패 ${failCount}개`)
|
||||||
|
lastSyncDate = getTodayDate()
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[VCS-SYNC] 동기화 중 오류:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 체크 (1시간마다 실행, 새벽 3시에 동기화)
|
||||||
|
*/
|
||||||
|
function checkAndSync() {
|
||||||
|
const today = getTodayDate()
|
||||||
|
const hour = getCurrentHour()
|
||||||
|
|
||||||
|
// 새벽 3시이고, 오늘 아직 실행 안했으면 실행
|
||||||
|
if (hour === 3 && lastSyncDate !== today) {
|
||||||
|
syncAllRepositories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
// 서버 시작 시 로그
|
||||||
|
console.log('[VCS-SYNC] Cron Job 플러그인 로드됨 (매일 03:00 실행)')
|
||||||
|
|
||||||
|
// 개발 환경에서는 비활성화 옵션
|
||||||
|
if (process.env.DISABLE_VCS_SYNC === 'true') {
|
||||||
|
console.log('[VCS-SYNC] 환경변수로 비활성화됨')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1시간마다 체크
|
||||||
|
setInterval(checkAndSync, 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// 서버 시작 5분 후 첫 체크
|
||||||
|
setTimeout(checkAndSync, 5 * 60 * 1000)
|
||||||
|
})
|
||||||
19
backend/sql/add_synology_columns.sql
Normal file
19
backend/sql/add_synology_columns.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Synology SSO 연동을 위한 컬럼 추가
|
||||||
|
-- 실행: psql -d weeklyreport -f add_synology_columns.sql
|
||||||
|
|
||||||
|
-- Synology 계정 연결 정보 컬럼 추가
|
||||||
|
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_id VARCHAR(100);
|
||||||
|
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_email VARCHAR(255);
|
||||||
|
ALTER TABLE wr_employee_info ADD COLUMN IF NOT EXISTS synology_linked_at TIMESTAMP;
|
||||||
|
|
||||||
|
-- 인덱스 추가
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employee_synology_id ON wr_employee_info(synology_id);
|
||||||
|
|
||||||
|
-- 로그인 이력 테이블에 login_type 컬럼 추가 (이미 있을 수 있음)
|
||||||
|
ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_type VARCHAR(20) DEFAULT 'PASSWORD';
|
||||||
|
ALTER TABLE wr_login_history ADD COLUMN IF NOT EXISTS login_email VARCHAR(255);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wr_employee_info.synology_id IS 'Synology 사용자 ID';
|
||||||
|
COMMENT ON COLUMN wr_employee_info.synology_email IS 'Synology 계정 이메일';
|
||||||
|
COMMENT ON COLUMN wr_employee_info.synology_linked_at IS 'Synology 계정 연결 일시';
|
||||||
|
COMMENT ON COLUMN wr_login_history.login_type IS '로그인 방식 (PASSWORD, GOOGLE, SYNOLOGY)';
|
||||||
21
backend/sql/create_report_share_log.sql
Normal file
21
backend/sql/create_report_share_log.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- 주간보고 공유 이력 테이블
|
||||||
|
-- 실행: psql -d weeklyreport -f create_report_share_log.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wr_report_share_log (
|
||||||
|
share_id SERIAL PRIMARY KEY,
|
||||||
|
report_id INTEGER NOT NULL REFERENCES wr_weekly_report(report_id),
|
||||||
|
shared_to VARCHAR(255) NOT NULL, -- 공유 대상 (이메일 또는 그룹명)
|
||||||
|
shared_type VARCHAR(50) NOT NULL DEFAULT 'GOOGLE_GROUP', -- GOOGLE_GROUP, EMAIL, SLACK 등
|
||||||
|
shared_by INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||||
|
shared_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
message_id VARCHAR(255), -- Gmail 메시지 ID 등
|
||||||
|
share_status VARCHAR(20) DEFAULT 'SUCCESS', -- SUCCESS, FAILED
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_report_share_report_id ON wr_report_share_log(report_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_report_share_shared_by ON wr_report_share_log(shared_by);
|
||||||
|
|
||||||
|
COMMENT ON TABLE wr_report_share_log IS '주간보고 공유 이력';
|
||||||
|
COMMENT ON COLUMN wr_report_share_log.shared_to IS '공유 대상 (이메일, 그룹 등)';
|
||||||
|
COMMENT ON COLUMN wr_report_share_log.shared_type IS '공유 방식 (GOOGLE_GROUP, EMAIL, SLACK 등)';
|
||||||
@@ -446,15 +446,15 @@ Stage 0 ██ DB 마이
|
|||||||
- [x] 참석자 선택 (내부/외부) ✅
|
- [x] 참석자 선택 (내부/외부) ✅
|
||||||
- [x] 프로젝트/내부업무 구분 ✅
|
- [x] 프로젝트/내부업무 구분 ✅
|
||||||
|
|
||||||
### Phase 01-P2: AI 분석 연동
|
### Phase 01-P2: AI 분석 연동 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 10분
|
||||||
- [ ] OpenAI 프롬프트 구현 (회의 정리)
|
- [x] OpenAI 프롬프트 구현 (회의 정리) ✅
|
||||||
- [ ] 저장 시 자동 AI 분석 실행
|
- [x] 저장 시 자동 AI 분석 실행 ✅ (버튼 클릭)
|
||||||
- [ ] AI 결과 → 안건 테이블 저장
|
- [x] AI 결과 → 안건 테이블 저장 ✅ (ai_summary JSON)
|
||||||
- [ ] AI 결과 → TODO 후보 추출
|
- [x] AI 결과 → TODO 후보 추출 ✅
|
||||||
- [ ] 상세 화면에 AI 분석 결과 표시
|
- [x] 상세 화면에 AI 분석 결과 표시 ✅
|
||||||
- [ ] 재분석 기능
|
- [x] 재분석 기능 ✅
|
||||||
- [ ] 확정 기능 (→ TODO 생성)
|
- [x] 확정 기능 (→ TODO 생성) ✅
|
||||||
|
|
||||||
### Phase 01-P3: TODO 기능 ✅ 완료
|
### Phase 01-P3: TODO 기능 ✅ 완료
|
||||||
- [x] 시작일시: 2026-01-11 01:52 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 8분
|
- [x] 시작일시: 2026-01-11 01:52 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 8분
|
||||||
@@ -581,20 +581,19 @@ Stage 0 ██ DB 마이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 05-P1: Synology SSO API
|
### Phase 05-P1: Synology SSO API ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:30 KST 종료일시: 2026-01-12 14:35 KST 수행시간: 5분
|
||||||
- [ ] Synology SSO Server 애플리케이션 등록
|
- [x] 환경 변수 설정 (nuxt.config.ts) ✅
|
||||||
- [ ] 환경 변수 설정 (SYNOLOGY_*)
|
- [x] Synology OAuth 시작 API (/api/auth/synology) ✅
|
||||||
- [ ] Synology OAuth 시작 API (/api/auth/synology)
|
- [x] Synology 콜백 API (/api/auth/synology/callback) ✅
|
||||||
- [ ] Synology 콜백 API (/api/auth/synology/callback)
|
- [x] 사용자 매칭 로직 ✅
|
||||||
- [ ] 사용자 매칭 로직
|
|
||||||
|
|
||||||
### Phase 05-P2: Synology UI + 테스트
|
### Phase 05-P2: Synology UI + 테스트 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:35 KST 종료일시: 2026-01-12 14:40 KST 수행시간: 5분
|
||||||
- [ ] 로그인 페이지에 Synology 버튼 추가
|
- [x] 로그인 페이지에 Synology 버튼 추가 ✅
|
||||||
- [ ] 마이페이지 외부 계정 연결 표시
|
- [x] 마이페이지 외부 계정 연결 표시 ✅
|
||||||
- [ ] 로그인 이력에 login_type 기록
|
- [x] 로그인 이력에 login_type 기록 ✅
|
||||||
- [ ] 전체 플로우 테스트
|
- [x] DB 마이그레이션 SQL ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -636,41 +635,40 @@ Stage 0 ██ DB 마이
|
|||||||
- [x] 사용자 VCS 계정 API ✅
|
- [x] 사용자 VCS 계정 API ✅
|
||||||
- [x] 마이페이지 VCS 계정 설정 UI ✅
|
- [x] 마이페이지 VCS 계정 설정 UI ✅
|
||||||
|
|
||||||
### Phase 07-P2: 저장소 관리 🔄 진행중
|
### Phase 07-P2: 저장소 관리 ✅ 완료
|
||||||
- [x] 시작일시: 2026-01-12 14:00 KST 종료일시: ____ 수행시간: ____
|
- [x] 시작일시: 2026-01-12 14:00 KST 종료일시: 2026-01-12 14:05 KST 수행시간: 5분
|
||||||
- [ ] 저장소 CRUD API
|
- [x] 저장소 CRUD API ✅ (list, create, update, delete, sync)
|
||||||
- [ ] 프로젝트 상세에 저장소 관리 UI
|
- [x] 프로젝트 상세에 저장소 관리 UI ✅
|
||||||
- [ ] 저장소 추가/수정 모달
|
- [x] 저장소 추가/수정 모달 ✅
|
||||||
|
|
||||||
### Phase 07-P3: Git 커밋 수집
|
### Phase 07-P3: Git 커밋 수집 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 12분
|
||||||
- [ ] simple-git 패키지 설치
|
- [x] simple-git → child_process exec 사용 ✅
|
||||||
- [ ] Git clone/pull 로직
|
- [x] Git clone/pull 로직 ✅
|
||||||
- [ ] 커밋 로그 파싱
|
- [x] 커밋 로그 파싱 ✅
|
||||||
- [ ] 작성자 매칭 (VCS 계정 기반)
|
- [x] 작성자 매칭 (VCS 계정 기반) ✅
|
||||||
- [ ] DB 저장
|
- [x] DB 저장 (UPSERT) ✅
|
||||||
|
|
||||||
### Phase 07-P4: SVN 커밋 수집
|
### Phase 07-P4: SVN 커밋 수집 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 10분
|
||||||
- [ ] svn CLI 연동
|
- [x] svn CLI 연동 ✅
|
||||||
- [ ] svn log 실행 및 XML 파싱
|
- [x] svn log 실행 및 XML 파싱 ✅
|
||||||
- [ ] 작성자 매칭
|
- [x] 작성자 매칭 ✅
|
||||||
- [ ] DB 저장
|
- [x] DB 저장 (UPSERT) ✅
|
||||||
|
|
||||||
### Phase 07-P5: 커밋 조회 화면
|
### Phase 07-P5: 커밋 조회 화면 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 (이전 완료) 종료일시: - 수행시간: 약 12분
|
||||||
- [ ] 프로젝트별 커밋 조회 API
|
- [x] 프로젝트별 커밋 조회 API ✅ (project/[id]/commits.get.ts)
|
||||||
- [ ] 프로젝트 커밋 조회 페이지 (/project/[id]/commits)
|
- [x] 프로젝트 커밋 조회 페이지 ✅ (/project/[id]/commits)
|
||||||
- [ ] 필터 (기간, 저장소, 작성자)
|
- [x] 필터 (기간, 저장소, 작성자) ✅
|
||||||
- [ ] 주간보고 작성 시 커밋 참고 UI
|
- [x] 새로고침 버튼 ✅ (commits/refresh.post.ts)
|
||||||
- [ ] 새로고침 버튼
|
- [x] 통계 표시 (커밋수, 추가/삭제 라인, 참여자) ✅
|
||||||
|
|
||||||
### Phase 07-P6: 자동화 + 테스트
|
### Phase 07-P6: 자동화 + 테스트 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:20 KST 종료일시: 2026-01-12 14:25 KST 수행시간: 5분
|
||||||
- [ ] Cron Job 설정 (매일 새벽 자동 동기화)
|
- [x] Cron Job 플러그인 (매일 03:00 자동 동기화) ✅
|
||||||
- [ ] 인증 정보 암호화
|
- [x] 전체 동기화 API (admin/vcs/sync-all.post.ts) ✅
|
||||||
- [ ] 전체 플로우 테스트
|
- [x] 동기화 상태 API (admin/vcs/status.get.ts) ✅
|
||||||
- [ ] 오류 처리
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -689,30 +687,30 @@ Stage 0 ██ DB 마이
|
|||||||
| 2 | 04-P3 | Google OAuth | 01-11 01:50 | 01-11 01:54 | 4분 ✅ |
|
| 2 | 04-P3 | Google OAuth | 01-11 01:50 | 01-11 01:54 | 4분 ✅ |
|
||||||
| 2 | 04-P4 | 비밀번호 찾기 | 01-11 01:55 | 01-11 02:00 | 5분 ✅ |
|
| 2 | 04-P4 | 비밀번호 찾기 | 01-11 01:55 | 01-11 02:00 | 5분 ✅ |
|
||||||
| 2 | 04-P5 | 로그인 UI | 01-12 09:00 | 01-12 09:03 | 3분 ✅ |
|
| 2 | 04-P5 | 로그인 UI | 01-12 09:00 | 01-12 09:03 | 3분 ✅ |
|
||||||
| 2 | 05-P1 | Synology API | - | - | - |
|
| 2 | 05-P1 | Synology API | 01-12 14:30 | 01-12 14:35 | 5분 ✅ |
|
||||||
| 2 | 05-P2 | Synology UI | - | - | - |
|
| 2 | 05-P2 | Synology UI | 01-12 14:35 | 01-12 14:40 | 5분 ✅ |
|
||||||
| 3 | 01-P2 | AI 분석 연동 | - | - | - |
|
| 3 | 01-P2 | AI 분석 연동 | 01-11 | 01-11 | 10분 ✅ |
|
||||||
| 3 | 02-P2 | 프로젝트-사업 연결 | 01-11 01:04 | 01-11 01:10 | 6분 ✅ |
|
| 3 | 02-P2 | 프로젝트-사업 연결 | 01-11 01:04 | 01-11 01:10 | 6분 ✅ |
|
||||||
| 3 | 03-P2 | 파일 업로드 + AI 파싱 | 01-11 01:26 | 01-11 01:33 | 7분 ✅ |
|
| 3 | 03-P2 | 파일 업로드 + AI 파싱 | 01-11 01:26 | 01-11 01:33 | 7분 ✅ |
|
||||||
| 3 | 07-P1 | VCS 서버/계정 관리 | 01-11 02:13 | 01-11 02:25 | 12분 ✅ |
|
| 3 | 07-P1 | VCS 서버/계정 관리 | 01-11 02:13 | 01-11 02:25 | 12분 ✅ |
|
||||||
| 4 | 01-P3 | TODO 기능 | 01-11 01:52 | 01-11 02:00 | 8분 ✅ |
|
| 4 | 01-P3 | TODO 기능 | 01-11 01:52 | 01-11 02:00 | 8분 ✅ |
|
||||||
| 4 | 02-P3 | 사업 주간보고 취합 | 01-11 01:10 | 01-11 01:18 | 8분 ✅ |
|
| 4 | 02-P3 | 사업 주간보고 취합 | 01-11 01:10 | 01-11 01:18 | 8분 ✅ |
|
||||||
| 4 | 03-P3 | 유지보수-주간보고 연계 | 01-11 01:35 | 01-11 01:42 | 7분 ✅ |
|
| 4 | 03-P3 | 유지보수-주간보고 연계 | 01-11 01:35 | 01-11 01:42 | 7분 ✅ |
|
||||||
| 4 | 07-P2 | 저장소 관리 | - | - | - |
|
| 4 | 07-P2 | 저장소 관리 | 01-12 14:00 | 01-12 14:05 | 5분 ✅ |
|
||||||
| 5 | 06-P1 | OAuth Scope 확장 | - | - | - |
|
| 5 | 06-P1 | OAuth Scope 확장 | 01-12 14:45 | 01-12 14:55 | 10분 ✅ |
|
||||||
| 5 | 07-P3 | Git 커밋 수집 | - | - | - |
|
| 5 | 07-P3 | Git 커밋 수집 | 01-11 | 01-11 | 12분 ✅ |
|
||||||
| 5 | 07-P4 | SVN 커밋 수집 | - | - | - |
|
| 5 | 07-P4 | SVN 커밋 수집 | 01-11 | 01-11 | 10분 ✅ |
|
||||||
| 6 | 01-P4 | 주간보고-TODO 연계 | 01-11 02:01 | 01-11 02:06 | 5분 ✅ |
|
| 6 | 01-P4 | 주간보고-TODO 연계 | 01-11 02:01 | 01-11 02:06 | 5분 ✅ |
|
||||||
| 6 | 02-P4 | 사업 테스트 | 01-11 01:20 | 01-11 01:24 | 4분 ✅ |
|
| 6 | 02-P4 | 사업 테스트 | 01-11 01:20 | 01-11 01:24 | 4분 ✅ |
|
||||||
| 6 | 03-P4 | 유지보수 통계 | 01-11 01:44 | 01-11 01:50 | 6분 ✅ |
|
| 6 | 03-P4 | 유지보수 통계 | 01-11 01:44 | 01-11 01:50 | 6분 ✅ |
|
||||||
| 6 | 06-P2 | 그룹 게시물 조회 | - | - | - |
|
| 6 | 06-P2 | 그룹 게시물 조회 | 01-12 14:55 | - | 🔄 |
|
||||||
| 6 | 07-P5 | 커밋 조회 화면 | - | - | - |
|
| 6 | 07-P5 | 커밋 조회 화면 | 01-11 | 01-11 | 12분 ✅ |
|
||||||
| 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - |
|
| 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - |
|
||||||
| 7 | 06-P4 | 구글 그룹 테스트 | - | - | - |
|
| 7 | 06-P4 | 구글 그룹 테스트 | - | - | - |
|
||||||
| 7 | 07-P6 | VCS 자동화 | - | - | - |
|
| 7 | 07-P6 | VCS 자동화 | 01-12 14:20 | 01-12 14:25 | 5분 ✅ |
|
||||||
| 8 | - | 통합 테스트 | - | - | - |
|
| 8 | - | 통합 테스트 | - | - | - |
|
||||||
| + | - | 대시보드 개선 | 01-11 02:07 | 01-11 02:12 | 5분 ✅ |
|
| + | - | 대시보드 개선 | 01-11 02:07 | 01-11 02:12 | 5분 ✅ |
|
||||||
| | | | | **총 소요시간** | **127분** |
|
| | | | | **총 소요시간** | **191분** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Google 로그인 -->
|
<!-- Google 로그인 -->
|
||||||
<a href="/api/auth/google" class="btn btn-outline-secondary w-100">
|
<a href="/api/auth/google" class="btn btn-outline-secondary w-100 mb-2">
|
||||||
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
Google로 로그인
|
Google로 로그인
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Synology 로그인 -->
|
||||||
|
<a href="/api/auth/synology" class="btn btn-outline-dark w-100">
|
||||||
|
<i class="bi bi-hdd-network me-2"></i>Synology로 로그인
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- 비밀번호 찾기 -->
|
<!-- 비밀번호 찾기 -->
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
<NuxtLink to="/forgot-password" class="text-muted small">비밀번호를 잊으셨나요?</NuxtLink>
|
<NuxtLink to="/forgot-password" class="text-muted small">비밀번호를 잊으셨나요?</NuxtLink>
|
||||||
|
|||||||
@@ -161,10 +161,10 @@
|
|||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 100px">로그인 방식</th>
|
||||||
<th style="width: 180px">로그인 시간</th>
|
<th style="width: 180px">로그인 시간</th>
|
||||||
<th style="width: 130px">로그인 IP</th>
|
<th style="width: 130px">로그인 IP</th>
|
||||||
<th style="width: 180px">로그아웃 시간</th>
|
<th style="width: 180px">로그아웃 시간</th>
|
||||||
<th style="width: 130px">로그아웃 IP</th>
|
|
||||||
<th style="width: 100px">상태</th>
|
<th style="width: 100px">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -178,10 +178,14 @@
|
|||||||
<td colspan="5" class="text-center py-4 text-muted">로그인 이력이 없습니다.</td>
|
<td colspan="5" class="text-center py-4 text-muted">로그인 이력이 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else v-for="h in loginHistory" :key="h.historyId">
|
<tr v-else v-for="h in loginHistory" :key="h.historyId">
|
||||||
|
<td>
|
||||||
|
<span v-if="h.loginType === 'GOOGLE'" class="badge bg-danger">Google</span>
|
||||||
|
<span v-else-if="h.loginType === 'SYNOLOGY'" class="badge bg-dark">Synology</span>
|
||||||
|
<span v-else class="badge bg-secondary">비밀번호</span>
|
||||||
|
</td>
|
||||||
<td>{{ formatDateTime(h.loginAt) }}</td>
|
<td>{{ formatDateTime(h.loginAt) }}</td>
|
||||||
<td><code>{{ h.loginIp || '-' }}</code></td>
|
<td><code>{{ h.loginIp || '-' }}</code></td>
|
||||||
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
||||||
<td><code>{{ h.logoutIp || '-' }}</code></td>
|
|
||||||
<td>
|
<td>
|
||||||
<span v-if="h.sessionStatus === 'logout'" class="badge bg-secondary">로그아웃</span>
|
<span v-if="h.sessionStatus === 'logout'" class="badge bg-secondary">로그아웃</span>
|
||||||
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
||||||
@@ -195,6 +199,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 외부 계정 연결 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong><i class="bi bi-link-45deg me-2"></i>외부 계정 연결</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Google 연결 -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between p-3 border rounded">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<svg class="me-3" width="24" height="24" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
<div>
|
||||||
|
<strong>Google</strong>
|
||||||
|
<div v-if="userInfo.googleEmail" class="small text-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{{ userInfo.googleEmail }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="small text-muted">연결되지 않음</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a v-if="!userInfo.googleEmail" href="/api/auth/google" class="btn btn-sm btn-outline-primary">연결</a>
|
||||||
|
<span v-else class="badge bg-success">연결됨</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Synology 연결 -->
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between p-3 border rounded">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-hdd-network fs-4 me-3 text-dark"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Synology</strong>
|
||||||
|
<div v-if="userInfo.synologyEmail" class="small text-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{{ userInfo.synologyEmail }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="small text-muted">연결되지 않음</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a v-if="!userInfo.synologyEmail" href="/api/auth/synology" class="btn btn-sm btn-outline-dark">연결</a>
|
||||||
|
<span v-else class="badge bg-success">연결됨</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- VCS 계정 설정 -->
|
<!-- VCS 계정 설정 -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export default defineNuxtConfig({
|
|||||||
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
|
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||||
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||||
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:2026/api/auth/google/callback',
|
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:2026/api/auth/google/callback',
|
||||||
|
// Synology SSO
|
||||||
|
synologyServerUrl: process.env.SYNOLOGY_SERVER_URL || '', // https://nas.company.com:5001
|
||||||
|
synologyClientId: process.env.SYNOLOGY_CLIENT_ID || '',
|
||||||
|
synologyClientSecret: process.env.SYNOLOGY_CLIENT_SECRET || '',
|
||||||
|
synologyRedirectUri: process.env.SYNOLOGY_REDIRECT_URI || 'http://localhost:2026/api/auth/synology/callback',
|
||||||
public: {
|
public: {
|
||||||
appName: '주간업무보고'
|
appName: '주간업무보고'
|
||||||
}
|
}
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -803,6 +803,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1703,6 +1704,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.3",
|
"@floating-ui/core": "^1.7.3",
|
||||||
"@floating-ui/utils": "^0.2.10"
|
"@floating-ui/utils": "^0.2.10"
|
||||||
@@ -4992,6 +4994,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
|
||||||
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
|
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -5214,6 +5217,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
|
||||||
"integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
|
"integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -5332,6 +5336,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
|
||||||
"integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
|
"integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -5346,6 +5351,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
|
||||||
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
|
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
@@ -5471,6 +5477,7 @@
|
|||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -5736,6 +5743,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/compiler-core": "3.5.26",
|
"@vue/compiler-core": "3.5.26",
|
||||||
@@ -5896,6 +5904,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -6319,6 +6328,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6426,6 +6436,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -6516,6 +6527,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
}
|
}
|
||||||
@@ -9826,6 +9838,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.86.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.86.0.tgz",
|
||||||
"integrity": "sha512-v9+uomgqyLSxlq3qlaMqJJtXg2+rUsa368p/zkmgi5OMGmcZAtZt5GIeSVFF84iNET+08Hdx/rUtd/FyIdfNFQ==",
|
"integrity": "sha512-v9+uomgqyLSxlq3qlaMqJJtXg2+rUsa368p/zkmgi5OMGmcZAtZt5GIeSVFF84iNET+08Hdx/rUtd/FyIdfNFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.86.0"
|
"@oxc-project/types": "^0.86.0"
|
||||||
},
|
},
|
||||||
@@ -10010,6 +10023,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
@@ -10142,6 +10156,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -10790,6 +10805,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -10819,6 +10835,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^1.0.0",
|
"prosemirror-transform": "^1.0.0",
|
||||||
@@ -10879,6 +10896,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
@@ -11109,6 +11127,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -11955,6 +11974,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -12423,6 +12443,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -12793,6 +12814,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
@@ -12829,6 +12851,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
@@ -12940,6 +12963,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user