All checks were successful
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지) - 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계 - 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session - (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
3.8 KiB
Go
114 lines
3.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"spin/internal/models"
|
|
)
|
|
|
|
// spin is a single internal company, so authorization is a 2-tier model rather
|
|
// than eQMS's per-company membership matrix:
|
|
//
|
|
// - admin → super-admin (Keycloak group ∩ ADMIN_GROUPS) OR Member.Role==admin.
|
|
// Sees and manages everything: approvals, incentive console,
|
|
// accounting, all member/project data, the bracketed [admin-only]
|
|
// contract & payment fields.
|
|
// - member → sees only their OWN data and may only SUBMIT requests.
|
|
//
|
|
// Ownership is enforced per-handler by comparing the row's member email to the
|
|
// caller's email (case-insensitive).
|
|
|
|
// isSuperAdmin reports the Keycloak/dev super-admin flag from the auth middleware.
|
|
func (s *Server) isSuperAdmin(r *http.Request) bool {
|
|
return currentUser(r.Context()).IsSuperAdmin
|
|
}
|
|
|
|
// isAdmin reports whether the caller may manage company-wide data: either a
|
|
// super-admin or a Member whose Role is admin.
|
|
func (s *Server) isAdmin(r *http.Request) bool {
|
|
if s.isSuperAdmin(r) {
|
|
return true
|
|
}
|
|
m := s.lookupMember(currentUser(r.Context()).Email)
|
|
return m != nil && m.Role == models.RoleAdmin
|
|
}
|
|
|
|
// requireAdmin writes 403 and returns false when the caller is not an admin.
|
|
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
if s.isAdmin(r) {
|
|
return true
|
|
}
|
|
writeError(w, http.StatusForbidden, "관리자 권한이 필요합니다")
|
|
return false
|
|
}
|
|
|
|
// email returns the caller's (lowercased) email.
|
|
func (s *Server) email(r *http.Request) string {
|
|
return strings.ToLower(strings.TrimSpace(currentUser(r.Context()).Email))
|
|
}
|
|
|
|
// owns reports whether the given member email belongs to the caller.
|
|
func (s *Server) owns(r *http.Request, memberEmail string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(memberEmail), s.email(r))
|
|
}
|
|
|
|
// lookupMember loads the Member row matched to an email (nil if none).
|
|
func (s *Server) lookupMember(email string) *models.Member {
|
|
email = strings.TrimSpace(email)
|
|
if email == "" {
|
|
return nil
|
|
}
|
|
var m models.Member
|
|
if err := s.db.Where("lower(email) = lower(?)", email).First(&m).Error; err != nil {
|
|
return nil
|
|
}
|
|
return &m
|
|
}
|
|
|
|
// ensureMember auto-provisions a spin Member the first time an authenticated
|
|
// identity is seen (Keycloak first login). rank/department stay empty (nullable)
|
|
// for an admin to fill in later. Account lifecycle itself remains Keycloak's job.
|
|
func (s *Server) ensureMember(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r.Context())
|
|
if e := strings.TrimSpace(u.Email); e != "" && s.lookupMember(e) == nil {
|
|
s.db.Create(&models.Member{
|
|
Email: e,
|
|
DisplayName: firstNonEmpty(u.Name, e),
|
|
Role: models.RoleMember,
|
|
Status: "active",
|
|
})
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// notify writes an inbox Notification and fans out a push to the recipient's
|
|
// registered devices (best-effort; push is a no-op when FCM isn't configured).
|
|
func (s *Server) notify(recipient, typ, title, body, link string) {
|
|
if strings.TrimSpace(recipient) == "" {
|
|
return
|
|
}
|
|
s.db.Create(&models.Notification{Recipient: recipient, Type: typ, Title: title, Body: body, Link: link})
|
|
if s.push != nil && s.push.Enabled() {
|
|
var tokens []string
|
|
s.db.Model(&models.Device{}).Where("lower(member_email) = lower(?)", recipient).Pluck("token", &tokens)
|
|
if len(tokens) > 0 {
|
|
go s.push.Send(context.Background(), tokens, title, body, link)
|
|
}
|
|
}
|
|
}
|
|
|
|
// audit writes an AuditLog row (best-effort).
|
|
func (s *Server) audit(r *http.Request, action, entity, entityID, detail string) {
|
|
s.db.Create(&models.AuditLog{
|
|
Actor: currentUser(r.Context()).Email,
|
|
Action: action,
|
|
Entity: entity,
|
|
EntityID: entityID,
|
|
Detail: detail,
|
|
})
|
|
}
|