diff --git a/client/app.go b/client/app.go
index 49e6d82..79471f5 100644
--- a/client/app.go
+++ b/client/app.go
@@ -104,6 +104,8 @@ type App struct {
wifiRejected bool
quitDialogShown bool
monitoringTornDown bool
+ chatUnread int
+ replyStaffID uint
}
type LocalStats struct {
@@ -274,10 +276,7 @@ func (a *App) startLocalServer() {
blocker.Instance.Start()
// Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện
- go a.fetchAllowedApps(st.SystemID)
- go a.fetchStudentStatus(st.StudentID)
- go a.fetchWifiPolicy()
- go a.refreshNetworkStatus()
+ go a.refreshStudentData()
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
runtime.WindowReloadApp(a.ctx)
@@ -315,10 +314,8 @@ func (a *App) loadSession() {
log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode)
blocker.Instance.Start()
- go a.fetchAllowedApps(s.SystemID)
- go a.fetchStudentStatus(s.StudentID)
+ go a.refreshStudentData()
go a.fetchWifiPolicy()
- go a.refreshNetworkStatus()
go a.syncProfileToServer(&s)
}
}
@@ -589,6 +586,33 @@ func (a *App) runMonitorTick() {
}
}
+// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
+func (a *App) resolveClassRkID() int64 {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.dashboard.ClassRkID > 0 {
+ return a.dashboard.ClassRkID
+ }
+ return 0
+}
+
+func (a *App) refreshStudentData() {
+ a.mu.Lock()
+ student := a.student
+ a.mu.Unlock()
+ if student == nil {
+ return
+ }
+ a.fetchStudentStatus(student.StudentID)
+ classID := a.resolveClassRkID()
+ if classID > 0 {
+ a.fetchAllowedApps(classID)
+ } else {
+ a.fetchAllowedApps(0)
+ }
+ a.refreshNetworkStatus()
+}
+
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
func (a *App) refreshNetworkStatus() {
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
@@ -601,11 +625,14 @@ func (a *App) refreshNetworkStatus() {
a.wifiBSSID = wifi.BSSID
a.backendOnline = backendOnline
a.mu.Unlock()
+ if backendOnline {
+ a.connectWS()
+ }
}
func (a *App) pingBackend() bool {
client := http.Client{Timeout: 3 * time.Second}
- resp, err := client.Get("http://127.0.0.1:8080/api/stats")
+ resp, err := client.Get("http://127.0.0.1:8080/api/health")
if err != nil {
return false
}
@@ -628,12 +655,18 @@ func (a *App) syncLogsToServer() {
return
}
- go a.fetchStudentStatus(student.StudentID)
- go a.fetchAllowedApps(student.SystemID)
+ go func() {
+ a.fetchStudentStatus(student.StudentID)
+ classID := a.resolveClassRkID()
+ if classID <= 0 {
+ classID = student.SystemID
+ }
+ a.fetchAllowedApps(classID)
+ }()
payload := map[string]any{
"studentRkId": student.StudentID,
- "classRkId": student.SystemID,
+ "classRkId": a.resolveClassRkID(),
"sessionDate": time.Now().Format("2006-01-02"),
"addOnlineSeconds": unsyncedOn,
"addOfflineSeconds": unsyncedOff,
@@ -809,8 +842,9 @@ func (a *App) fetchAllowedApps(classId int64) {
}
a.mu.Unlock()
+ url := fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID)
client := http.Client{Timeout: 4 * time.Second}
- resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID))
+ resp, err := client.Get(url)
if err != nil {
return
}
@@ -888,7 +922,7 @@ func (a *App) fetchStudentStatus(studentID int64) {
}
func (a *App) connectWS() {
- if !a.isMonitoringActive() {
+ if !a.CheckLoginStatus() {
return
}
a.mu.Lock()
@@ -897,9 +931,13 @@ func (a *App) connectWS() {
return
}
student := a.student
+ classID := a.dashboard.ClassRkID
+ if classID <= 0 {
+ classID = student.SystemID
+ }
a.mu.Unlock()
- wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID)
+ wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, classID)
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
conn, _, err := dialer.Dial(wsUrl, nil)
if err != nil {
@@ -934,6 +972,20 @@ func (a *App) connectWS() {
runtime.EventsEmit(a.ctx, "start_webcam_stream")
case "stop_webcam_stream":
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
+ case "chat:message":
+ if staffVal, ok := msg.Data["staffId"].(float64); ok {
+ a.mu.Lock()
+ a.replyStaffID = uint(staffVal)
+ a.chatUnread++
+ unread := a.chatUnread
+ a.mu.Unlock()
+ runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{
+ "unread": unread,
+ "preview": msg.Data["body"],
+ "from": msg.Data["staffName"],
+ })
+ }
+ runtime.EventsEmit(a.ctx, "chat:message", msg.Data)
}
}
}()
@@ -1003,3 +1055,104 @@ func (a *App) stopScreenshotStream() {
close(a.streamScStop)
log.Println("[WS] Screenshot screen-streaming stopped.")
}
+
+func (a *App) GetChatUnread() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.chatUnread
+}
+
+func (a *App) ClearChatUnread() {
+ a.mu.Lock()
+ a.chatUnread = 0
+ a.mu.Unlock()
+}
+
+func (a *App) GetChatConversations() ([]map[string]any, error) {
+ a.mu.Lock()
+ student := a.student
+ a.mu.Unlock()
+ if student == nil {
+ return nil, errors.New("chưa đăng nhập")
+ }
+ url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/conversations?studentRkId=%d", student.StudentID)
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var payload struct {
+ Data []map[string]any `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, err
+ }
+ return payload.Data, nil
+}
+
+func (a *App) GetChatMessages(staffID uint) ([]map[string]any, error) {
+ a.mu.Lock()
+ student := a.student
+ a.mu.Unlock()
+ if student == nil {
+ return nil, errors.New("chưa đăng nhập")
+ }
+ url := fmt.Sprintf("http://127.0.0.1:8080/api/student/chat/messages?studentRkId=%d&staffId=%d", student.StudentID, staffID)
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var payload struct {
+ Data []map[string]any `json:"data"`
+ ReplyStaffID uint `json:"replyStaffId"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, err
+ }
+ if staffID > 0 {
+ a.mu.Lock()
+ a.replyStaffID = staffID
+ a.mu.Unlock()
+ } else if payload.ReplyStaffID > 0 {
+ a.mu.Lock()
+ a.replyStaffID = payload.ReplyStaffID
+ a.mu.Unlock()
+ }
+ a.ClearChatUnread()
+ return payload.Data, nil
+}
+
+func (a *App) SendChatMessage(body string) error {
+ body = strings.TrimSpace(body)
+ if body == "" {
+ return errors.New("tin nhắn trống")
+ }
+ a.mu.Lock()
+ student := a.student
+ staffID := a.replyStaffID
+ a.mu.Unlock()
+ if student == nil {
+ return errors.New("chưa đăng nhập")
+ }
+ payload := map[string]any{
+ "studentRkId": student.StudentID,
+ "staffId": staffID,
+ "body": body,
+ }
+ b, _ := json.Marshal(payload)
+ resp, err := http.Post("http://127.0.0.1:8080/api/student/chat/messages", "application/json", bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ var errBody map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&errBody)
+ if msg, ok := errBody["error"].(string); ok {
+ return errors.New(msg)
+ }
+ return fmt.Errorf("gửi tin thất bại (%d)", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css
index 40cf21d..1e30cf4 100644
--- a/client/frontend/src/app.css
+++ b/client/frontend/src/app.css
@@ -437,45 +437,291 @@ body {
font-family: monospace;
}
-.clock-display-summary {
- display: flex;
- flex-wrap: wrap;
- gap: 0.45rem;
- margin-bottom: 0.45rem;
- padding-bottom: 0.45rem;
- border-bottom: 1px dashed var(--border-color);
- flex: 0 0 auto;
+.card-title-row--shifts {
+ margin-bottom: 0.2rem;
+ flex-wrap: nowrap;
+ gap: 0.35rem 0.5rem;
+ align-items: center;
}
-.clock-chip {
+.clock-inline {
display: inline-flex;
align-items: center;
gap: 0.35rem;
- padding: 0.2rem 0.5rem;
- border-radius: 6px;
- border: 1px solid var(--border-color);
- background: var(--bg-subtle);
- font-size: 0.72rem;
-}
-
-.clock-chip em {
- font-style: normal;
+ flex-wrap: wrap;
+ margin-left: auto;
+ font-size: 0.68rem;
color: var(--text-muted);
- font-weight: 600;
}
-.clock-chip strong {
+.clock-inline-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+}
+
+.clock-inline-item strong {
font-family: 'Share Tech Mono', monospace;
- font-size: 0.78rem;
+ font-size: 0.7rem;
font-weight: 700;
}
-.clock-chip--online strong {
- color: var(--online-color);
+.clock-inline-item strong.online { color: var(--online-color); }
+.clock-inline-item strong.offline { color: var(--offline-color); }
+
+.clock-inline-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ flex-shrink: 0;
}
-.clock-chip--offline strong {
- color: var(--offline-color);
+.clock-inline-dot.online { background: var(--online-color); }
+.clock-inline-dot.offline { background: var(--offline-color); }
+
+.clock-inline-sep {
+ opacity: 0.35;
+ font-size: 0.65rem;
+}
+
+.card-title-row--shifts .card-title-sub {
+ font-size: 0.62rem;
+ margin-left: 0.35rem;
+ white-space: nowrap;
+}
+
+/* Student chat messenger */
+.student-chat-dock {
+ position: fixed;
+ right: 12px;
+ bottom: 12px;
+ z-index: 1000;
+}
+
+.student-chat-toast {
+ position: absolute;
+ right: 0;
+ bottom: 64px;
+ width: 260px;
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ background: #1e293b;
+ color: #fff;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
+ font-size: 0.78rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.student-chat-fab {
+ position: relative;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ color: #fff;
+ font-size: 1.15rem;
+ cursor: pointer;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
+}
+
+.student-chat-badge {
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: #ef4444;
+ color: #fff;
+ font-size: 0.62rem;
+ font-weight: 700;
+ line-height: 16px;
+}
+
+.student-chat-messenger {
+ position: absolute;
+ right: 0;
+ bottom: 58px;
+ width: min(520px, calc(100vw - 24px));
+ height: min(400px, calc(100vh - 80px));
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.student-chat-messenger-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.55rem 0.7rem;
+ border-bottom: 1px solid var(--border-color);
+ font-size: 0.82rem;
+ flex-shrink: 0;
+}
+
+.student-chat-close {
+ border: none;
+ background: none;
+ color: var(--text-muted);
+ font-size: 1.1rem;
+ cursor: pointer;
+}
+
+.student-chat-messenger-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.student-chat-sidebar {
+ width: 150px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border-color);
+ overflow-y: auto;
+ background: rgba(255, 255, 255, 0.02);
+ display: flex;
+ flex-direction: column;
+}
+
+.student-chat-conv-btn {
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ padding: 0.55rem 0.5rem;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+.student-chat-conv-btn.active {
+ background: rgba(187, 33, 38, 0.12);
+ border-left: 2px solid var(--accent);
+}
+
+.student-chat-conv-name {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.72rem;
+ font-weight: 600;
+}
+
+.student-chat-conv-unread {
+ font-style: normal;
+ background: var(--accent);
+ color: #fff;
+ border-radius: 8px;
+ padding: 0 5px;
+ font-size: 0.6rem;
+}
+
+.student-chat-conv-preview {
+ display: block;
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 0.15rem;
+}
+
+.student-chat-thread {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.student-chat-thread-head {
+ padding: 0.45rem 0.65rem;
+ border-bottom: 1px solid var(--border-color);
+ font-size: 0.75rem;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.student-chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.55rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ background: rgba(0, 0, 0, 0.12);
+}
+
+.student-chat-empty {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ text-align: center;
+ padding: 1rem 0.5rem;
+}
+
+.student-chat-bubble {
+ max-width: 88%;
+ font-size: 0.76rem;
+}
+
+.student-chat-bubble--staff { align-self: flex-start; }
+.student-chat-bubble--student { align-self: flex-end; }
+
+.student-chat-label {
+ font-size: 0.6rem;
+ color: var(--text-muted);
+ margin-bottom: 0.1rem;
+}
+
+.student-chat-body {
+ padding: 0.4rem 0.55rem;
+ border-radius: 10px;
+ line-height: 1.35;
+ word-break: break-word;
+}
+
+.student-chat-bubble--staff .student-chat-body {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--border-color);
+}
+
+.student-chat-bubble--student .student-chat-body {
+ background: var(--accent);
+ color: #fff;
+}
+
+.student-chat-time {
+ font-size: 0.58rem;
+ color: var(--text-muted);
+ margin-top: 0.1rem;
+}
+
+.student-chat-compose {
+ display: flex;
+ gap: 0.35rem;
+ padding: 0.45rem 0.55rem;
+ border-top: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.student-chat-compose input {
+ flex: 1;
+ font-size: 0.74rem;
+ padding: 0.4rem 0.5rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-base);
+ color: var(--text-primary);
+}
+
+.student-chat-compose input:disabled {
+ opacity: 0.5;
}
.shifts-wrap {
@@ -622,15 +868,15 @@ body {
.clocks-card {
display: flex;
flex-direction: column;
- gap: 0.4rem;
+ gap: 0.3rem;
flex: 1;
min-height: 0;
overflow: hidden;
- padding: 0.75rem 0.85rem;
+ padding: 0.5rem 0.65rem;
}
.card-title {
- font-size: 0.85rem;
+ font-size: 0.78rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.2px;
diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js
index f77ead9..5cd42f7 100644
--- a/client/frontend/src/main.js
+++ b/client/frontend/src/main.js
@@ -23,6 +23,11 @@ let stats = {
shifts: []
};
+let chatOpen = false;
+let chatMessages = [];
+let chatConversations = [];
+let activeStaffId = 0;
+
// Quản lý webcam
let webcamStream = null;
let webcamInterval = null;
@@ -46,6 +51,15 @@ function init() {
window.runtime.EventsOn('start_webcam_stream', startWebcam);
window.runtime.EventsOn('stop_webcam_stream', stopWebcam);
+ window.runtime.EventsOn('chat:message', () => {
+ updateChatBadge();
+ loadChatConversations().catch(console.error);
+ if (chatOpen && activeStaffId) loadChatMessages(activeStaffId).catch(console.error);
+ });
+ window.runtime.EventsOn('chat:notify', (data) => {
+ updateChatBadge();
+ showChatToast(data);
+ });
checkLogin();
}
@@ -57,6 +71,8 @@ async function checkLogin() {
studentInfo = await window.go.main.App.GetStudentInfo();
renderDashboard();
startStatsTicker();
+ ensureChatWidget();
+ updateChatBadge();
} else {
loggedIn = false;
renderLoginPrompt();
@@ -226,19 +242,14 @@ function renderDashboard() {
-
+
Hôm nay — theo ca
-
${stats.sessionDate || ''}
-
-
-
- Trực tuyến
- 00:00:00
-
-
- Ngoại tuyến
- 00:00:00
-
+
+ 00:00:00
+ ·
+ 00:00:00
+ ${stats.sessionDate || ''}
+
${renderShiftsTable(stats.shifts)}
@@ -254,11 +265,177 @@ function renderDashboard() {
loggedIn = false;
studentInfo = null;
stopWebcam();
+ const w = document.getElementById('student-chat-widget');
+ if (w) w.remove();
await window.go.main.App.Logout();
}
});
}
+function ensureChatWidget() {
+ if (document.getElementById('student-chat-widget')) return;
+ const wrap = document.createElement('div');
+ wrap.id = 'student-chat-widget';
+ wrap.className = 'student-chat-dock';
+ wrap.innerHTML = `
+
+
+ 💬0
+
+
+
+
+
+
+ Chọn hội thoại
+
+
+
+ Gửi
+
+
+
+
+ `;
+ document.body.appendChild(wrap);
+ document.getElementById('student-chat-fab').addEventListener('click', toggleChat);
+ document.getElementById('student-chat-close').addEventListener('click', () => setChatOpen(false));
+ document.getElementById('student-chat-send').addEventListener('click', sendStudentChat);
+ document.getElementById('student-chat-input').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); sendStudentChat(); }
+ });
+}
+
+function showChatToast(data) {
+ const toast = document.getElementById('student-chat-toast');
+ if (!toast) return;
+ const from = data?.from || 'Giảng viên';
+ const preview = data?.preview || 'Bạn có tin nhắn mới';
+ toast.innerHTML = `
${escapeHtml(from)} ${escapeHtml(preview)} `;
+ toast.hidden = false;
+ clearTimeout(showChatToast._t);
+ showChatToast._t = setTimeout(() => { toast.hidden = true; }, 5000);
+}
+
+function setChatOpen(open) {
+ chatOpen = open;
+ const panel = document.getElementById('student-chat-messenger');
+ if (panel) panel.hidden = !open;
+ if (open) {
+ loadChatConversations().catch(console.error);
+ if (activeStaffId) loadChatMessages(activeStaffId).catch(console.error);
+ }
+}
+
+function toggleChat() {
+ setChatOpen(!chatOpen);
+}
+
+async function loadChatConversations() {
+ const rows = await window.go.main.App.GetChatConversations();
+ chatConversations = Array.isArray(rows) ? rows : [];
+ renderChatConversations();
+ updateChatBadge();
+}
+
+function renderChatConversations() {
+ const list = document.getElementById('student-chat-conv-list');
+ if (!list) return;
+ if (!chatConversations.length) {
+ list.innerHTML = '
Chưa có tin nhắn
';
+ return;
+ }
+ list.innerHTML = chatConversations.map((c) => {
+ const sid = Number(c.staffId || 0);
+ const unread = Number(c.unread || 0);
+ const active = sid === activeStaffId ? ' active' : '';
+ const name = escapeHtml(c.staffName || c.staffEmail || 'Giảng viên');
+ const preview = escapeHtml(c.lastMessage || '');
+ return `
+ ${name}${unread > 0 ? `${unread} ` : ''}
+ ${preview}
+ `;
+ }).join('');
+ list.querySelectorAll('.student-chat-conv-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const sid = Number(btn.getAttribute('data-staff-id'));
+ pickStaffChat(sid);
+ });
+ });
+}
+
+async function pickStaffChat(staffId) {
+ activeStaffId = staffId;
+ const head = document.getElementById('student-chat-thread-head');
+ const conv = chatConversations.find((c) => Number(c.staffId) === staffId);
+ if (head) head.textContent = conv?.staffName || conv?.staffEmail || 'Giảng viên';
+ const input = document.getElementById('student-chat-input');
+ const sendBtn = document.getElementById('student-chat-send');
+ if (input) input.disabled = !staffId;
+ if (sendBtn) sendBtn.disabled = !staffId;
+ renderChatConversations();
+ if (staffId) await loadChatMessages(staffId);
+}
+
+async function loadChatMessages(staffId) {
+ const msgs = await window.go.main.App.GetChatMessages(staffId);
+ chatMessages = Array.isArray(msgs) ? msgs : [];
+ renderChatMessages();
+ updateChatBadge();
+ loadChatConversations().catch(console.error);
+}
+
+function renderChatMessages() {
+ const box = document.getElementById('student-chat-messages');
+ if (!box) return;
+ if (!chatMessages.length) {
+ box.innerHTML = '
Chưa có tin nhắn
';
+ return;
+ }
+ box.innerHTML = chatMessages.map((m) => {
+ const role = m.senderRole === 'student' ? 'student' : 'staff';
+ const time = m.createdAt ? new Date(m.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
+ const label = role === 'staff' ? (m.staffName || 'Giảng viên') : 'Bạn';
+ return `
${label}
${escapeHtml(m.body || '')}
${time}
`;
+ }).join('');
+ box.scrollTop = box.scrollHeight;
+}
+
+function escapeHtml(s) {
+ return String(s).replace(/&/g, '&').replace(//g, '>');
+}
+
+async function sendStudentChat() {
+ const input = document.getElementById('student-chat-input');
+ if (!input || !input.value.trim() || !activeStaffId) return;
+ try {
+ await window.go.main.App.SendChatMessage(input.value.trim());
+ input.value = '';
+ await loadChatMessages(activeStaffId);
+ } catch (err) {
+ alert(err?.message || err || 'Gửi tin thất bại');
+ }
+}
+
+async function updateChatBadge() {
+ const badge = document.getElementById('student-chat-badge');
+ if (!badge || !window.go?.main?.App?.GetChatUnread) return;
+ try {
+ const n = await window.go.main.App.GetChatUnread();
+ if (n > 0) {
+ badge.hidden = false;
+ badge.textContent = n > 9 ? '9+' : String(n);
+ } else {
+ badge.hidden = true;
+ }
+ } catch {
+ /* ignore */
+ }
+}
+
function formatDuration(totalSeconds) {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
@@ -330,6 +507,8 @@ function startStatsTicker() {
if (indicator) {
indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`;
}
+
+ updateChatBadge();
} catch (err) {
console.error('Error fetching stats:', err);
}
diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts
index f5b9cba..da304b0 100644
--- a/client/frontend/wailsjs/go/main/App.d.ts
+++ b/client/frontend/wailsjs/go/main/App.d.ts
@@ -3,6 +3,14 @@
export function CheckLoginStatus():Promise
;
+export function ClearChatUnread():Promise;
+
+export function GetChatConversations():Promise>>;
+
+export function GetChatMessages(arg1:number):Promise>>;
+
+export function GetChatUnread():Promise;
+
export function GetStats():Promise>;
export function GetStudentInfo():Promise>;
@@ -11,4 +19,6 @@ export function Logout():Promise;
export function NavigateToLogin():Promise;
+export function SendChatMessage(arg1:string):Promise;
+
export function SendWebcamFrame(arg1:string):Promise;
diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js
index 0f9cd8f..a576798 100644
--- a/client/frontend/wailsjs/go/main/App.js
+++ b/client/frontend/wailsjs/go/main/App.js
@@ -6,6 +6,22 @@ export function CheckLoginStatus() {
return window['go']['main']['App']['CheckLoginStatus']();
}
+export function ClearChatUnread() {
+ return window['go']['main']['App']['ClearChatUnread']();
+}
+
+export function GetChatConversations() {
+ return window['go']['main']['App']['GetChatConversations']();
+}
+
+export function GetChatMessages(arg1) {
+ return window['go']['main']['App']['GetChatMessages'](arg1);
+}
+
+export function GetChatUnread() {
+ return window['go']['main']['App']['GetChatUnread']();
+}
+
export function GetStats() {
return window['go']['main']['App']['GetStats']();
}
@@ -22,6 +38,10 @@ export function NavigateToLogin() {
return window['go']['main']['App']['NavigateToLogin']();
}
+export function SendChatMessage(arg1) {
+ return window['go']['main']['App']['SendChatMessage'](arg1);
+}
+
export function SendWebcamFrame(arg1) {
return window['go']['main']['App']['SendWebcamFrame'](arg1);
}
diff --git a/client/internal/blocker/blocker.go b/client/internal/blocker/blocker.go
index 1b42d4b..f58aa67 100644
--- a/client/internal/blocker/blocker.go
+++ b/client/internal/blocker/blocker.go
@@ -182,19 +182,63 @@ func EnumerateGUIWindows() ([]WindowInfo, error) {
return res, nil
}
+func parseKeywordList(keywords string) []string {
+ keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
+ parts := strings.FieldsFunc(keywords, func(r rune) bool {
+ return r == ',' || r == '\n' || r == ';' || r == '|'
+ })
+ seen := map[string]bool{}
+ var list []string
+ for _, p := range parts {
+ trimmed := strings.TrimSpace(strings.ToLower(p))
+ trimmed = strings.TrimSuffix(trimmed, ".exe")
+ if trimmed == "" || seen[trimmed] {
+ continue
+ }
+ seen[trimmed] = true
+ list = append(list, trimmed)
+ }
+ return list
+}
+
+var keywordAliases = map[string][]string{
+ "lark": {"lark", "feishu", "larkshell", "larkhelper"},
+ "feishu": {"lark", "feishu", "larkshell", "larkhelper"},
+ "teams": {"teams", "ms-teams", "msteams"},
+ "zalo": {"zalo", "zalopcb"},
+}
+
+func expandKeyword(kw string) []string {
+ base := []string{kw}
+ if aliases, ok := keywordAliases[kw]; ok {
+ base = append(base, aliases...)
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, a := range base {
+ a = strings.TrimSpace(strings.ToLower(a))
+ if a != "" && !seen[a] {
+ seen[a] = true
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+func matchesAllowedKeyword(kw, pNameLower, wTitleLower string) bool {
+ for _, variant := range expandKeyword(kw) {
+ if strings.Contains(pNameLower, variant) || strings.Contains(wTitleLower, variant) {
+ return true
+ }
+ }
+ return false
+}
+
func (b *Blocker) SetKeywords(keywords string) {
b.mu.Lock()
defer b.mu.Unlock()
- parts := strings.Split(keywords, ",")
- var list []string
- for _, p := range parts {
- trimmed := strings.TrimSpace(strings.ToLower(p))
- if trimmed != "" {
- list = append(list, trimmed)
- }
- }
- b.allowedKeywords = list
+ b.allowedKeywords = parseKeywordList(keywords)
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
}
@@ -254,7 +298,7 @@ func (b *Blocker) checkAndKill() {
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
allowed := false
for _, kw := range keywords {
- if strings.Contains(pNameLower, kw) || strings.Contains(wTitleLower, kw) {
+ if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
allowed = true
break
}
diff --git a/management/src/App.tsx b/management/src/App.tsx
index e599717..487a83b 100644
--- a/management/src/App.tsx
+++ b/management/src/App.tsx
@@ -4,9 +4,12 @@ import { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab';
import { LearningTab } from './components/LearningTab';
import { NetworkTab } from './components/NetworkTab';
+import { AccountsTab } from './components/AccountsTab';
+import { ChatWidget } from './components/ChatWidget';
import { ClassWorkspace } from './components/ClassWorkspace';
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
+import { useAuth } from './auth/AuthContext';
const IconDashboard = () => (
@@ -46,8 +49,16 @@ const IconNetwork = () => (
);
+const IconAccounts = () => (
+
+
+
+
+);
+
function App() {
const route = useRoute();
+ const { staff, logout } = useAuth();
useEffect(() => {
const initial = parseRoute();
@@ -128,14 +139,27 @@ function App() {
Quản lý mạng
+
+ Hệ thống
+
+ navigate('accounts')}
+ >
+
+ Tài khoản
+
+
-
Kết nối hệ thống
-
- QLDT_TOKEN đã kết nối
+
+ {staff?.email}
+
+ Đăng xuất
+
@@ -157,10 +181,12 @@ function App() {
{route.tab === 'students' &&
}
{route.tab === 'learning' &&
}
{route.tab === 'network' &&
}
+ {route.tab === 'accounts' &&
}
>
)}
+
>
);
}
diff --git a/management/src/api.ts b/management/src/api.ts
index a9e32c0..282f437 100644
--- a/management/src/api.ts
+++ b/management/src/api.ts
@@ -1,5 +1,161 @@
const API_BASE = 'http://127.0.0.1:8080/api';
+function getToken(): string | null {
+ return localStorage.getItem('sc_staff_token');
+}
+
+async function staffFetch(path: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers);
+ const token = getToken();
+ if (token) headers.set('Authorization', `Bearer ${token}`);
+ if (init.body && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+ return fetch(`${API_BASE}${path}`, { ...init, headers });
+}
+
+async function parseError(res: Response, fallback: string): Promise {
+ const errData = await res.json().catch(() => ({}));
+ throw new Error((errData as { error?: string }).error || fallback);
+}
+
+export interface StaffUser {
+ id: number;
+ email: string;
+ fullName: string;
+ mustChangePassword: boolean;
+}
+
+export const apiAuth = {
+ provision: async (email: string): Promise<{ ok: boolean; message: string }> => {
+ const res = await fetch(`${API_BASE}/auth/provision`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ if (!res.ok) await parseError(res, 'Provision failed');
+ return res.json();
+ },
+ login: async (email: string, password: string): Promise<{ token: string; staff: StaffUser; mustChangePassword: boolean }> => {
+ const res = await fetch(`${API_BASE}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ if (!res.ok) await parseError(res, 'Đăng nhập thất bại');
+ return res.json();
+ },
+ me: async (): Promise<{ staff: StaffUser }> => {
+ const res = await staffFetch('/auth/me');
+ if (!res.ok) await parseError(res, 'Unauthorized');
+ return res.json();
+ },
+ changePassword: async (oldPassword: string, newPassword: string): Promise<{ ok: boolean; token: string; staff: StaffUser }> => {
+ const res = await staffFetch('/auth/change-password', {
+ method: 'POST',
+ body: JSON.stringify({ oldPassword, newPassword }),
+ });
+ if (!res.ok) await parseError(res, 'Đổi mật khẩu thất bại');
+ return res.json();
+ },
+ forgotPassword: async (email: string): Promise<{ ok: boolean; message: string }> => {
+ const res = await fetch(`${API_BASE}/auth/forgot-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ if (!res.ok) await parseError(res, 'Gửi mail thất bại');
+ return res.json();
+ },
+ resetPassword: async (token: string, newPassword: string): Promise<{ ok: boolean; message: string }> => {
+ const res = await fetch(`${API_BASE}/auth/reset-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, newPassword }),
+ });
+ if (!res.ok) await parseError(res, 'Đặt lại mật khẩu thất bại');
+ return res.json();
+ },
+};
+
+export interface EmailDomainItem {
+ id: number;
+ domain: string;
+ isActive: boolean;
+}
+
+export const apiAdmin = {
+ listEmailDomains: async (): Promise<{ data: EmailDomainItem[] }> => {
+ const res = await staffFetch('/admin/email-domains');
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+ addEmailDomain: async (domain: string) => {
+ const res = await staffFetch('/admin/email-domains', { method: 'POST', body: JSON.stringify({ domain }) });
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+ deleteEmailDomain: async (id: number) => {
+ const res = await staffFetch(`/admin/email-domains/${id}`, { method: 'DELETE' });
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+};
+
+export interface ChatStudent {
+ studentRkId: number;
+ fullName: string;
+ studentCode: string;
+ email: string;
+ online: boolean;
+}
+
+export interface ChatMessage {
+ id: number;
+ staffId: number;
+ studentRkId: number;
+ senderRole: 'staff' | 'student';
+ body: string;
+ createdAt: string;
+ staffName?: string;
+}
+
+export interface ChatConversation {
+ studentRkId: number;
+ fullName: string;
+ studentCode: string;
+ lastMessage: string;
+ lastAt: string;
+ unread: number;
+}
+
+export const apiChat = {
+ listConversations: async (): Promise<{ data: ChatConversation[] }> => {
+ const res = await staffFetch('/chat/conversations');
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+ searchStudents: async (q = ''): Promise<{ data: ChatStudent[] }> => {
+ const params = q ? `?q=${encodeURIComponent(q)}` : '';
+ const res = await staffFetch(`/chat/students${params}`);
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+ listMessages: async (studentRkId: number): Promise<{ data: ChatMessage[] }> => {
+ const res = await staffFetch(`/chat/messages/${studentRkId}`);
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+ sendMessage: async (studentRkId: number, body: string): Promise<{ ok: boolean; data: ChatMessage }> => {
+ const res = await staffFetch('/chat/messages', {
+ method: 'POST',
+ body: JSON.stringify({ studentRkId, body }),
+ });
+ if (!res.ok) await parseError(res, 'Failed');
+ return res.json();
+ },
+};
+
export interface ClassItem {
rkId: number;
name: string;
@@ -125,7 +281,7 @@ export interface StudentSessionLogItem {
export const api = {
getStats: async (): Promise => {
- const res = await fetch(`${API_BASE}/stats`);
+ const res = await staffFetch('/stats');
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
return res.json();
},
@@ -145,15 +301,14 @@ export const api = {
if (params.systemRkId) query.set('systemRkId', String(params.systemRkId));
if (params.studyingOnly) query.set('studyingOnly', 'true');
- const res = await fetch(`${API_BASE}/classes?${query}`);
+ const res = await staffFetch(`/classes?${query}`);
if (!res.ok) throw new Error('Failed to fetch classes');
return res.json();
},
updateClassStudying: async (rkId: number, isStudying: boolean): Promise => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/studying`, {
+ const res = await staffFetch(`/classes/${rkId}/studying`, {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isStudying }),
});
if (!res.ok) {
@@ -164,11 +319,17 @@ export const api = {
},
getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/students`);
+ const res = await staffFetch(`/classes/${rkId}/students`);
if (!res.ok) throw new Error('Failed to fetch class students roster');
return res.json();
},
+ getClass: async (rkId: number): Promise => {
+ const res = await staffFetch(`/classes/${rkId}`);
+ if (!res.ok) throw new Error('Failed to fetch class');
+ return res.json();
+ },
+
getStudents: async (params: {
page: number;
pageSize: number;
@@ -180,13 +341,13 @@ export const api = {
});
if (params.q) query.set('q', params.q);
- const res = await fetch(`${API_BASE}/students?${query}`);
+ const res = await staffFetch(`/students?${query}`);
if (!res.ok) throw new Error('Failed to fetch students');
return res.json();
},
startClassesSync: async (): Promise<{ ok: boolean; message: string }> => {
- const res = await fetch(`${API_BASE}/sync/classes/start`, { method: 'POST' });
+ const res = await staffFetch('/sync/classes/start', { method: 'POST' });
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to start classes sync');
@@ -195,13 +356,13 @@ export const api = {
},
getClassesSyncStatus: async (): Promise => {
- const res = await fetch(`${API_BASE}/sync/classes/status`);
+ const res = await staffFetch('/sync/classes/status');
if (!res.ok) throw new Error('Failed to get classes sync status');
return res.json();
},
startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => {
- const res = await fetch(`${API_BASE}/sync/students/start`, { method: 'POST' });
+ const res = await staffFetch('/sync/students/start', { method: 'POST' });
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to start students sync');
@@ -210,7 +371,7 @@ export const api = {
},
getStudentsSyncStatus: async (): Promise => {
- const res = await fetch(`${API_BASE}/sync/students/status`);
+ const res = await staffFetch('/sync/students/status');
if (!res.ok) throw new Error('Failed to get students sync status');
return res.json();
},
@@ -218,21 +379,20 @@ export const api = {
// Export individual learning functions to simplify imports in components
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
- const res = await fetch(`${API_BASE}/classes/schedules`);
+ const res = await staffFetch('/classes/schedules');
if (!res.ok) throw new Error('Failed to fetch active schedules');
return res.json();
};
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`);
+ const res = await staffFetch(`/classes/${rkId}/schedule`);
if (!res.ok) throw new Error('Failed to fetch class schedule');
return res.json();
};
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, {
+ const res = await staffFetch(`/classes/${rkId}/schedule`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules }),
});
if (!res.ok) throw new Error('Failed to save class schedule');
@@ -240,21 +400,20 @@ export const apiSaveClassSchedule = async (rkId: number, schedules: ClassSchedul
};
export const apiDeleteClassSchedule = async (rkId: number): Promise => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' });
+ const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete class schedule');
return res.json();
};
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`);
+ const res = await staffFetch(`/classes/${rkId}/allowed-apps`);
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
return res.json();
};
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, {
+ const res = await staffFetch(`/classes/${rkId}/allowed-apps`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keywords }),
});
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
@@ -277,7 +436,7 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
if (q.trim()) params.set('q', q.trim());
params.set('limit', String(limit));
const query = params.toString() ? `?${params.toString()}` : '';
- const res = await fetch(`${API_BASE}/app-pool${query}`);
+ const res = await staffFetch(`/app-pool${query}`);
if (!res.ok) throw new Error('Failed to fetch app pool');
return res.json();
};
@@ -305,21 +464,20 @@ export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: Wifi
if (q.trim()) params.set('q', q.trim());
params.set('limit', String(limit));
const query = params.toString() ? `?${params.toString()}` : '';
- const res = await fetch(`${API_BASE}/wifi-pool${query}`);
+ const res = await staffFetch(`/wifi-pool${query}`);
if (!res.ok) throw new Error('Failed to fetch wifi pool');
return res.json();
};
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
- const res = await fetch(`${API_BASE}/network/accepted-wifis`);
+ const res = await staffFetch('/network/accepted-wifis');
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
return res.json();
};
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise => {
- const res = await fetch(`${API_BASE}/network/accepted-wifis`, {
+ const res = await staffFetch('/network/accepted-wifis', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
if (!res.ok) throw new Error('Failed to save accepted wifis');
@@ -338,13 +496,13 @@ export const apiFetchClassSessionLogs = async (
if (date) params.set('date', date);
if (period) params.set('period', String(period));
const query = params.toString() ? `?${params.toString()}` : '';
- const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`);
+ const res = await staffFetch(`/classes/${rkId}/session-logs${query}`);
if (!res.ok) throw new Error('Failed to fetch class session logs');
return res.json();
};
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`);
+ const res = await staffFetch(`/classes/${rkId}/online-students`);
if (!res.ok) throw new Error('Failed to fetch online students list');
return res.json();
};
@@ -356,7 +514,7 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{
warning?: string;
hint?: string;
}> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/courses`);
+ const res = await staffFetch(`/classes/${rkId}/courses`);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT');
@@ -365,20 +523,20 @@ export const apiFetchClassCourses = async (rkId: number): Promise<{
};
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
+ const res = await staffFetch(`/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to apply schedule template');
return res.json();
};
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
const q = date ? `?date=${date}` : '';
- const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`);
+ const res = await staffFetch(`/classes/${rkId}/attendance/shifts${q}`);
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
return res.json();
};
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`);
+ const res = await staffFetch(`/classes/${rkId}/attendance?date=${date}&period=${period}`);
if (!res.ok) throw new Error('Failed to fetch attendance');
return res.json();
};
@@ -387,9 +545,8 @@ export const apiUpdateAttendanceStatus = async (
rkId: number,
payload: { date: string; period: number; studentRkId: number; status: number }
) => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, {
+ const res = await staffFetch(`/classes/${rkId}/attendance/status`, {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
@@ -400,9 +557,8 @@ export const apiUpdateAttendanceStatus = async (
};
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
- const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, {
+ const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, period }),
});
if (!res.ok) {
diff --git a/management/src/auth/AuthContext.tsx b/management/src/auth/AuthContext.tsx
new file mode 100644
index 0000000..5b40935
--- /dev/null
+++ b/management/src/auth/AuthContext.tsx
@@ -0,0 +1,114 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { apiAuth } from '../api';
+
+export interface StaffUser {
+ id: number;
+ email: string;
+ fullName: string;
+ mustChangePassword: boolean;
+}
+
+interface AuthState {
+ token: string | null;
+ staff: StaffUser | null;
+ loading: boolean;
+ login: (email: string, password: string) => Promise;
+ provision: (email: string) => Promise;
+ logout: () => void;
+ changePassword: (oldPassword: string, newPassword: string) => Promise;
+ forgotPassword: (email: string) => Promise;
+ resetPassword: (token: string, newPassword: string) => Promise;
+ refreshMe: () => Promise;
+}
+
+const TOKEN_KEY = 'sc_staff_token';
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY));
+ const [staff, setStaff] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const applySession = useCallback((nextToken: string | null, nextStaff: StaffUser | null) => {
+ setToken(nextToken);
+ setStaff(nextStaff);
+ if (nextToken) {
+ localStorage.setItem(TOKEN_KEY, nextToken);
+ } else {
+ localStorage.removeItem(TOKEN_KEY);
+ }
+ }, []);
+
+ const refreshMe = useCallback(async () => {
+ if (!token) {
+ setStaff(null);
+ setLoading(false);
+ return;
+ }
+ try {
+ const res = await apiAuth.me();
+ setStaff(res.staff);
+ } catch {
+ applySession(null, null);
+ } finally {
+ setLoading(false);
+ }
+ }, [token, applySession]);
+
+ useEffect(() => {
+ refreshMe();
+ }, [refreshMe]);
+
+ const login = useCallback(async (email: string, password: string) => {
+ const res = await apiAuth.login(email, password);
+ applySession(res.token, res.staff);
+ }, [applySession]);
+
+ const provision = useCallback(async (email: string) => {
+ const res = await apiAuth.provision(email);
+ return res.message;
+ }, []);
+
+ const logout = useCallback(() => {
+ applySession(null, null);
+ }, [applySession]);
+
+ const changePassword = useCallback(async (oldPassword: string, newPassword: string) => {
+ const res = await apiAuth.changePassword(oldPassword, newPassword);
+ applySession(res.token, res.staff);
+ }, [applySession]);
+
+ const forgotPassword = useCallback(async (email: string) => {
+ const res = await apiAuth.forgotPassword(email);
+ return res.message;
+ }, []);
+
+ const resetPassword = useCallback(async (resetToken: string, newPassword: string) => {
+ await apiAuth.resetPassword(resetToken, newPassword);
+ }, []);
+
+ const value = useMemo(() => ({
+ token,
+ staff,
+ loading,
+ login,
+ provision,
+ logout,
+ changePassword,
+ forgotPassword,
+ resetPassword,
+ refreshMe,
+ }), [token, staff, loading, login, provision, logout, changePassword, forgotPassword, resetPassword, refreshMe]);
+
+ return {children} ;
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider');
+ return ctx;
+}
+
+export function getStoredToken(): string | null {
+ return localStorage.getItem(TOKEN_KEY);
+}
diff --git a/management/src/components/AccountsTab.tsx b/management/src/components/AccountsTab.tsx
new file mode 100644
index 0000000..1b7eb4a
--- /dev/null
+++ b/management/src/components/AccountsTab.tsx
@@ -0,0 +1,167 @@
+import { useEffect, useState } from 'react';
+import { useAuth } from '../auth/AuthContext';
+import { apiAdmin, type EmailDomainItem } from '../api';
+
+export function ChangePasswordPage({ forced }: { forced?: boolean }) {
+ const { changePassword, logout } = useAuth();
+ const [oldPassword, setOldPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ if (newPassword.length < 8) {
+ setError('Mật khẩu mới tối thiểu 8 ký tự');
+ return;
+ }
+ if (newPassword !== confirm) {
+ setError('Xác nhận mật khẩu không khớp');
+ return;
+ }
+ setBusy(true);
+ try {
+ await changePassword(oldPassword, newPassword);
+ } catch (err: any) {
+ setError(err?.message || 'Đổi mật khẩu thất bại');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
{forced ? 'Đổi mật khẩu bắt buộc' : 'Đổi mật khẩu'}
+ {forced &&
Lần đăng nhập đầu tiên — vui lòng đặt mật khẩu mới trước khi tiếp tục.
}
+
+
+
+ );
+}
+
+export function AccountsTab() {
+ const { staff, changePassword } = useAuth();
+ const [domains, setDomains] = useState([]);
+ const [newDomain, setNewDomain] = useState('');
+ const [oldPw, setOldPw] = useState('');
+ const [newPw, setNewPw] = useState('');
+ const [msg, setMsg] = useState('');
+ const [err, setErr] = useState('');
+
+ const load = async () => {
+ const res = await apiAdmin.listEmailDomains();
+ setDomains(res.data);
+ };
+
+ useEffect(() => {
+ load().catch(console.error);
+ }, []);
+
+ const addDomain = async () => {
+ if (!newDomain.trim()) return;
+ await apiAdmin.addEmailDomain(newDomain.trim());
+ setNewDomain('');
+ await load();
+ };
+
+ const removeDomain = async (id: number) => {
+ if (!confirm('Xóa đuôi email này?')) return;
+ await apiAdmin.deleteEmailDomain(id);
+ await load();
+ };
+
+ const submitPw = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setErr('');
+ setMsg('');
+ try {
+ await changePassword(oldPw, newPw);
+ setMsg('Đã đổi mật khẩu');
+ setOldPw('');
+ setNewPw('');
+ } catch (e: any) {
+ setErr(e?.message || 'Lỗi');
+ }
+ };
+
+ return (
+
+
+
+
+
Tài khoản hiện tại
+
{staff?.email}
+
+
+
+
Đuôi email được phép
+
+ Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập.
+
+
+ setNewDomain(e.target.value)}
+ style={{ flex: 1 }}
+ />
+ Thêm
+
+
+ {domains.length === 0 && Chưa cấu hình — chấp nhận tất cả đuôi email }
+ {domains.map((d) => (
+
+ @{d.domain}
+ removeDomain(d.id)}>Xóa
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/management/src/components/ChatWidget.tsx b/management/src/components/ChatWidget.tsx
new file mode 100644
index 0000000..75863fd
--- /dev/null
+++ b/management/src/components/ChatWidget.tsx
@@ -0,0 +1,231 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
+import { useAuth } from '../auth/AuthContext';
+
+export function ChatWidget() {
+ const { staff } = useAuth();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const [students, setStudents] = useState([]);
+ const [conversations, setConversations] = useState([]);
+ const [active, setActive] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState('');
+ const [sending, setSending] = useState(false);
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const listRef = useRef(null);
+ const activeRef = useRef(null);
+
+ useEffect(() => {
+ activeRef.current = active;
+ }, [active]);
+
+ const scrollBottom = () => {
+ requestAnimationFrame(() => {
+ if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
+ });
+ };
+
+ const totalUnread = conversations.reduce((n, c) => n + (c.unread || 0), 0);
+
+ const loadConversations = useCallback(async () => {
+ const res = await apiChat.listConversations();
+ setConversations(res.data);
+ }, []);
+
+ const loadMessages = useCallback(async (studentRkId: number) => {
+ const res = await apiChat.listMessages(studentRkId);
+ setMessages(res.data);
+ scrollBottom();
+ await loadConversations();
+ }, [loadConversations]);
+
+ const searchStudents = useCallback(async (q: string) => {
+ const res = await apiChat.searchStudents(q);
+ setStudents(res.data);
+ }, []);
+
+ const pickStudent = async (s: ChatStudent) => {
+ setActive(s);
+ setPickerOpen(false);
+ setQuery('');
+ await loadMessages(s.studentRkId);
+ };
+
+ const pickFromConversation = async (c: ChatConversation) => {
+ await pickStudent({
+ studentRkId: c.studentRkId,
+ fullName: c.fullName,
+ studentCode: c.studentCode,
+ email: '',
+ online: false,
+ });
+ };
+
+ useEffect(() => {
+ if (!open || !staff?.id) return;
+ loadConversations().catch(console.error);
+ searchStudents('').catch(console.error);
+ }, [open, staff?.id, loadConversations, searchStudents]);
+
+ useEffect(() => {
+ if (!staff?.id) return;
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`;
+ const ws = new WebSocket(wsUrl);
+ ws.onmessage = (ev) => {
+ try {
+ const payload = JSON.parse(ev.data);
+ if (payload.event !== 'chat:message') return;
+ const msg = payload.data as ChatMessage;
+ loadConversations().catch(console.error);
+ const current = activeRef.current;
+ if (current && msg.studentRkId === current.studentRkId) {
+ setMessages((prev) => {
+ if (prev.some((m) => m.id === msg.id)) return prev;
+ return [...prev, msg];
+ });
+ scrollBottom();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+ return () => ws.close();
+ }, [staff?.id, loadConversations]);
+
+ const send = async () => {
+ if (!active || !draft.trim()) return;
+ setSending(true);
+ try {
+ const res = await apiChat.sendMessage(active.studentRkId, draft.trim());
+ setMessages((prev) => [...prev, res.data]);
+ setDraft('');
+ scrollBottom();
+ await loadConversations();
+ } finally {
+ setSending(false);
+ }
+ };
+
+ if (!staff) return null;
+
+ const dock = (
+
+ {!open ? (
+
setOpen(true)} title="Tin nhắn">
+ 💬
+ {totalUnread > 0 && (
+ {totalUnread > 9 ? '9+' : totalUnread}
+ )}
+
+ ) : (
+
+
+
+
+
+
+
+ {!active ? (
+
+
Chọn hội thoại bên trái hoặc tìm sinh viên để bắt đầu chat
+
+ ) : (
+ <>
+
+
+
{active.fullName}
+
{active.studentCode}
+
+
+
+ {messages.map((m) => (
+
+
{m.body}
+
+ {new Date(m.createdAt).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
+
+
+ ))}
+ {messages.length === 0 &&
Chưa có tin nhắn
}
+
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
+ />
+
+ Gửi
+
+
+ >
+ )}
+
+
+
+ )}
+
+ );
+
+ return createPortal(dock, document.body);
+}
diff --git a/management/src/components/ClassWorkspace.tsx b/management/src/components/ClassWorkspace.tsx
index 879aa14..c7f0f20 100644
--- a/management/src/components/ClassWorkspace.tsx
+++ b/management/src/components/ClassWorkspace.tsx
@@ -58,17 +58,14 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT
setLoading(true);
// Fetch specific class detail directly
- const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`);
- if (targetClassRes.ok) {
- const classData = await targetClassRes.json();
- setClassInfo(classData);
- pushNav({
- kind: 'class',
- tab: sourceTab,
- classId,
- label: classData.name || `Lớp ${classId}`,
- });
- }
+ const classData = await api.getClass(classId);
+ setClassInfo(classData);
+ pushNav({
+ kind: 'class',
+ tab: sourceTab,
+ classId,
+ label: classData.name || `Lớp ${classId}`,
+ });
// Fetch students roster
const studentsRes = await apiFetchClassStudents(classId);
diff --git a/management/src/components/LoginPage.tsx b/management/src/components/LoginPage.tsx
new file mode 100644
index 0000000..350e18a
--- /dev/null
+++ b/management/src/components/LoginPage.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react';
+import { useAuth } from '../auth/AuthContext';
+
+type Mode = 'login' | 'provision' | 'forgot' | 'reset';
+
+export function LoginPage() {
+ const { login, provision, forgotPassword, resetPassword } = useAuth();
+ const [mode, setMode] = useState('login');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [resetToken, setResetToken] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setMessage('');
+ setBusy(true);
+ try {
+ if (mode === 'login') {
+ await login(email, password);
+ } else if (mode === 'provision') {
+ const msg = await provision(email);
+ setMessage(msg);
+ setMode('login');
+ } else if (mode === 'forgot') {
+ const msg = await forgotPassword(email);
+ setMessage(msg);
+ setMode('reset');
+ } else if (mode === 'reset') {
+ await resetPassword(resetToken, newPassword);
+ setMessage('Đã đặt lại mật khẩu. Vui lòng đăng nhập.');
+ setMode('login');
+ }
+ } catch (err: any) {
+ setError(err?.message || 'Có lỗi xảy ra');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
Simple Care
+
Quản lý giám sát — Rikkei Education
+
+
+
+
+ {mode === 'login' && 'Đăng nhập'}
+ {mode === 'provision' && 'Truy cập lần đầu'}
+ {mode === 'forgot' && 'Quên mật khẩu'}
+ {mode === 'reset' && 'Đặt lại mật khẩu'}
+
+
+ {mode === 'provision'
+ ? 'Nhập email tổ chức. Hệ thống gửi mật khẩu tạm nếu đuôi email được phép.'
+ : 'Chỉ email có đuôi tổ chức được phép truy cập.'}
+
+
+
+
+
+ {mode === 'login' && (
+ <>
+ setMode('provision')}>Truy cập lần đầu
+ setMode('forgot')}>Quên mật khẩu?
+ >
+ )}
+ {mode !== 'login' && (
+ setMode('login')}>← Quay lại đăng nhập
+ )}
+
+
+
+ );
+}
diff --git a/management/src/index.css b/management/src/index.css
index e366454..9c2ebed 100644
--- a/management/src/index.css
+++ b/management/src/index.css
@@ -3233,3 +3233,427 @@ input:checked + .slider:before {
flex: 1;
}
}
+
+/* ── Login ── */
+.auth-shell {
+ width: 100%;
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ background: var(--bg-app);
+}
+
+.login-screen {
+ width: 100%;
+ max-width: 440px;
+ margin: 0 auto;
+}
+
+.login-card {
+ width: 100%;
+ max-width: 420px;
+ padding: 1.75rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+}
+
+.login-brand {
+ display: flex;
+ gap: 0.85rem;
+ align-items: center;
+ margin-bottom: 1.25rem;
+}
+
+.login-brand-title {
+ font-weight: 800;
+ font-size: 1.1rem;
+}
+
+.login-brand-sub {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.login-heading {
+ margin: 0 0 0.35rem;
+ font-size: 1.35rem;
+}
+
+.login-hint {
+ margin: 0 0 1rem;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.login-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.login-field input {
+ padding: 0.55rem 0.65rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.login-submit {
+ width: 100%;
+ margin-top: 0.25rem;
+}
+
+.login-error {
+ color: #ef4444;
+ font-size: 0.82rem;
+}
+
+.login-success {
+ color: #22c55e;
+ font-size: 0.82rem;
+}
+
+.login-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.link-btn {
+ background: none;
+ border: none;
+ color: var(--accent);
+ cursor: pointer;
+ font-size: 0.82rem;
+ padding: 0;
+}
+
+.domain-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.domain-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.domain-empty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ padding: 0.5rem 0;
+}
+
+/* ── Chat messenger dock ── */
+.chat-dock {
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ z-index: 10000;
+ font-family: var(--font-sans);
+}
+
+.chat-fab {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ color: #fff;
+ font-size: 1.4rem;
+ cursor: pointer;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
+}
+
+.chat-fab-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: #ef4444;
+ color: #fff;
+ font-size: 0.65rem;
+ font-weight: 700;
+ line-height: 18px;
+}
+
+.chat-messenger {
+ width: min(720px, calc(100vw - 40px));
+ height: min(520px, calc(100vh - 100px));
+ background: #fff;
+ border: 1px solid var(--border-color);
+ border-radius: 14px;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-messenger-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.chat-messenger-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ background: #fff;
+}
+
+.chat-sidebar {
+ width: 260px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background: #f8fafc;
+}
+
+.chat-sidebar-search {
+ padding: 0.6rem;
+ border-bottom: 1px solid var(--border-color);
+ background: #fff;
+}
+
+.chat-sidebar-search input {
+ width: 100%;
+ padding: 0.5rem 0.65rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ font-size: 0.82rem;
+}
+
+.chat-picker-list,
+.chat-conv-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+}
+
+.chat-picker-list {
+ max-height: 160px;
+ border-bottom: 1px solid var(--border-color);
+ background: #fff;
+}
+
+.chat-conv-list {
+ flex: 1;
+}
+
+.chat-picker-list button,
+.chat-conv-list button {
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ padding: 0.65rem 0.75rem;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border-light);
+ color: var(--text-primary);
+}
+
+.chat-conv-list button:hover,
+.chat-picker-list button:hover {
+ background: #eef2f7;
+}
+
+.chat-conv-list button.active {
+ background: var(--accent-light);
+ border-left: 3px solid var(--accent);
+}
+
+.chat-conv-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chat-conv-name {
+ font-size: 0.84rem;
+ font-weight: 600;
+ display: block;
+}
+
+.chat-conv-preview {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 0.15rem;
+}
+
+.chat-conv-meta {
+ font-size: 0.68rem;
+ color: var(--text-muted);
+}
+
+.chat-conv-unread {
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 0.62rem;
+ font-weight: 700;
+ line-height: 18px;
+ text-align: center;
+}
+
+.chat-empty-hint {
+ padding: 1rem 0.75rem;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ text-align: center;
+}
+
+.chat-thread {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ background: #fff;
+}
+
+.chat-thread-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+.chat-thread-head {
+ padding: 0.65rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.chat-sub {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+.chat-head-actions {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.chat-icon-btn {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1rem;
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+}
+
+.chat-icon-btn:hover {
+ background: var(--bg-subtle);
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ background: #f5f7fa;
+}
+
+.chat-bubble {
+ max-width: 78%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.chat-bubble--staff {
+ align-self: flex-start;
+}
+
+.chat-bubble--student {
+ align-self: flex-end;
+}
+
+.chat-bubble-body {
+ padding: 0.5rem 0.7rem;
+ border-radius: 14px;
+ font-size: 0.84rem;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
+.chat-bubble--staff .chat-bubble-body {
+ background: #fff;
+ border: 1px solid var(--border-color);
+}
+
+.chat-bubble--student .chat-bubble-body {
+ background: var(--accent);
+ color: #fff;
+}
+
+.chat-bubble-time {
+ font-size: 0.62rem;
+ color: var(--text-muted);
+ padding: 0 0.25rem;
+}
+
+.chat-compose {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.65rem 0.85rem;
+ border-top: 1px solid var(--border-color);
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.chat-compose input {
+ flex: 1;
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ font-size: 0.84rem;
+}
diff --git a/management/src/main.tsx b/management/src/main.tsx
index bef5202..ca71cbb 100644
--- a/management/src/main.tsx
+++ b/management/src/main.tsx
@@ -2,9 +2,40 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
+import { AuthProvider, useAuth } from './auth/AuthContext'
+import { LoginPage } from './components/LoginPage'
+import { ChangePasswordPage } from './components/AccountsTab'
+
+function AppGate() {
+ const { token, staff, loading } = useAuth();
+ if (loading) {
+ return (
+
+ );
+ }
+ if (!token) {
+ return (
+
+
+
+ );
+ }
+ if (staff?.mustChangePassword) {
+ return (
+
+
+
+ );
+ }
+ return ;
+}
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)
diff --git a/management/src/navigation.ts b/management/src/navigation.ts
index 8c0b0f9..0915b00 100644
--- a/management/src/navigation.ts
+++ b/management/src/navigation.ts
@@ -1,4 +1,4 @@
-export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network';
+export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network' | 'accounts';
export interface NavEntry {
id: string;
@@ -15,13 +15,14 @@ export const TAB_LABELS: Record = {
students: 'Sinh viên',
learning: 'Giám sát & Lịch học',
network: 'Quản lý mạng',
+ accounts: 'Tài khoản',
};
const HISTORY_KEY = 'sc_nav_history';
const MAX_HISTORY = 10;
function isTabId(value: string | null): value is TabId {
- return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network';
+ return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network' || value === 'accounts';
}
export function parseRoute(): { tab: TabId; classId: number | null } {
diff --git a/server/.env b/server/.env
index dd387d3..e010a0b 100644
--- a/server/.env
+++ b/server/.env
@@ -1 +1,9 @@
-QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
\ No newline at end of file
+QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
+
+JWT_SECRET=simple-care-staff-jwt-change-in-production
+
+MAIL_HOST=smtp.gmail.com
+MAIL_PORT=465
+MAIL_SECURE=true
+MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
+MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
\ No newline at end of file
diff --git a/server/go.mod b/server/go.mod
index ac93b16..603f54a 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -9,6 +9,7 @@ require (
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/gofiber/fiber/v2 v2.52.13 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -22,8 +23,9 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
- golang.org/x/sys v0.28.0 // indirect
- golang.org/x/text v0.20.0 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/gorm v1.31.2 // indirect
)
diff --git a/server/go.sum b/server/go.sum
index 9089d06..d253053 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -10,6 +10,8 @@ github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJb
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -37,12 +39,18 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go
new file mode 100644
index 0000000..82cc803
--- /dev/null
+++ b/server/internal/auth/auth.go
@@ -0,0 +1,105 @@
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+
+ "server/internal/models"
+)
+
+const staffCtxKey = "staffId"
+
+type StaffClaims struct {
+ StaffID uint `json:"staffId"`
+ Email string `json:"email"`
+ jwt.RegisteredClaims
+}
+
+func JWTSecret() string {
+ if s := os.Getenv("JWT_SECRET"); s != "" {
+ return s
+ }
+ return "simple-care-staff-jwt-change-me-in-production"
+}
+
+func HashPassword(password string) (string, error) {
+ b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ return string(b), err
+}
+
+func CheckPassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+func RandomPassword(n int) string {
+ if n < 8 {
+ n = 8
+ }
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)[:n]
+}
+
+func RandomToken(n int) string {
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+func IssueToken(staff *models.StaffAccount) (string, error) {
+ claims := StaffClaims{
+ StaffID: staff.ID,
+ Email: staff.Email,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ },
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return t.SignedString([]byte(JWTSecret()))
+}
+
+func ParseToken(tokenStr string) (*StaffClaims, error) {
+ t, err := jwt.ParseWithClaims(tokenStr, &StaffClaims{}, func(t *jwt.Token) (any, error) {
+ return []byte(JWTSecret()), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ claims, ok := t.Claims.(*StaffClaims)
+ if !ok || !t.Valid {
+ return nil, errors.New("invalid token")
+ }
+ return claims, nil
+}
+
+func EmailDomainPart(email string) string {
+ parts := strings.Split(strings.ToLower(strings.TrimSpace(email)), "@")
+ if len(parts) != 2 {
+ return ""
+ }
+ return parts[1]
+}
+
+func IsEmailDomainAllowed(db *gorm.DB, email string) bool {
+ domain := EmailDomainPart(email)
+ if domain == "" {
+ return false
+ }
+ var count int64
+ db.Model(&models.EmailDomain{}).Where("is_active = ?", true).Count(&count)
+ if count == 0 {
+ return true
+ }
+ var found int64
+ db.Model(&models.EmailDomain{}).Where("domain = ? AND is_active = ?", domain, true).Count(&found)
+ return found > 0
+}
diff --git a/server/internal/db/db.go b/server/internal/db/db.go
index 2fd6ffc..beec0a8 100644
--- a/server/internal/db/db.go
+++ b/server/internal/db/db.go
@@ -53,6 +53,10 @@ func AutoMigrate(db *gorm.DB) error {
&models.AcceptedWifi{},
&models.StudentSession{},
&models.AttendanceResult{},
+ &models.StaffAccount{},
+ &models.EmailDomain{},
+ &models.PasswordResetToken{},
+ &models.ChatMessage{},
); err != nil {
return err
}
diff --git a/server/internal/handlers/handlers_auth.go b/server/internal/handlers/handlers_auth.go
new file mode 100644
index 0000000..38646e7
--- /dev/null
+++ b/server/internal/handlers/handlers_auth.go
@@ -0,0 +1,289 @@
+package handlers
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "server/internal/auth"
+ "server/internal/mail"
+ "server/internal/models"
+
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+func normalizeEmail(email string) string {
+ return strings.ToLower(strings.TrimSpace(email))
+}
+
+func staffPublic(a models.StaffAccount) fiber.Map {
+ return fiber.Map{
+ "id": a.ID,
+ "email": a.Email,
+ "fullName": a.FullName,
+ "mustChangePassword": a.MustChangePassword,
+ }
+}
+
+// POST /api/auth/provision — lần đầu: gửi mật khẩu qua email nếu đuôi hợp lệ
+func ProvisionStaffHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ Email string `json:"email"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ email := normalizeEmail(req.Email)
+ if email == "" || !strings.Contains(email, "@") {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email không hợp lệ"})
+ }
+ if !auth.IsEmailDomainAllowed(db, email) {
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Đuôi email không được phép truy cập hệ thống"})
+ }
+
+ var existing models.StaffAccount
+ err := db.Where("email = ?", email).First(&existing).Error
+ if err == nil {
+ if !existing.IsActive {
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
+ }
+ return c.JSON(fiber.Map{"ok": true, "message": "Tài khoản đã tồn tại. Dùng đăng nhập hoặc quên mật khẩu."})
+ }
+ if err != gorm.ErrRecordNotFound {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ tempPass := auth.RandomPassword(12)
+ hash, err := auth.HashPassword(tempPass)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ staff := models.StaffAccount{
+ Email: email,
+ PasswordHash: hash,
+ MustChangePassword: true,
+ IsActive: true,
+ }
+ if err := db.Create(&staff).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ body := fmt.Sprintf(`Xin chào,
Tài khoản Simple Care Management đã được tạo.
+Email: %sMật khẩu tạm: %s
+Đăng nhập và đổi mật khẩu ngay lần đầu.
`, email, tempPass)
+ if err := mailer.Send(email, "Simple Care — Mật khẩu truy cập", body); err != nil {
+ return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": "Tạo tài khoản ok nhưng gửi mail thất bại: " + err.Error()})
+ }
+ return c.JSON(fiber.Map{"ok": true, "message": "Đã gửi mật khẩu tạm tới email của bạn"})
+ }
+}
+
+// POST /api/auth/login
+func LoginStaffHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ email := normalizeEmail(req.Email)
+ var staff models.StaffAccount
+ if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
+ }
+ if !staff.IsActive {
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Tài khoản đã bị khóa"})
+ }
+ if !auth.CheckPassword(staff.PasswordHash, req.Password) {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email hoặc mật khẩu không đúng"})
+ }
+ token, err := auth.IssueToken(&staff)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{
+ "token": token,
+ "mustChangePassword": staff.MustChangePassword,
+ "staff": staffPublic(staff),
+ })
+ }
+}
+
+// GET /api/auth/me
+func MeStaffHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID, ok := c.Locals("staffId").(uint)
+ if !ok || staffID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
+ }
+ var staff models.StaffAccount
+ if err := db.First(&staff, staffID).Error; err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
+ }
+ return c.JSON(fiber.Map{"staff": staffPublic(staff)})
+ }
+}
+
+// POST /api/auth/change-password
+func ChangePasswordHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID, ok := c.Locals("staffId").(uint)
+ if !ok || staffID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
+ }
+ var req struct {
+ OldPassword string `json:"oldPassword"`
+ NewPassword string `json:"newPassword"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ if len(req.NewPassword) < 8 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
+ }
+ var staff models.StaffAccount
+ if err := db.First(&staff, staffID).Error; err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Account not found"})
+ }
+ if !auth.CheckPassword(staff.PasswordHash, req.OldPassword) {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu hiện tại không đúng"})
+ }
+ hash, err := auth.HashPassword(req.NewPassword)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ staff.PasswordHash = hash
+ staff.MustChangePassword = false
+ if err := db.Save(&staff).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ token, _ := auth.IssueToken(&staff)
+ return c.JSON(fiber.Map{"ok": true, "token": token, "staff": staffPublic(staff)})
+ }
+}
+
+// POST /api/auth/forgot-password
+func ForgotPasswordHandler(db *gorm.DB, mailer mail.Config) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ Email string `json:"email"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ email := normalizeEmail(req.Email)
+ var staff models.StaffAccount
+ if err := db.Where("email = ?", email).First(&staff).Error; err != nil {
+ return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, link đặt lại mật khẩu đã được gửi"})
+ }
+ token := auth.RandomToken(24)
+ row := models.PasswordResetToken{
+ StaffID: staff.ID,
+ Token: token,
+ ExpiresAt: time.Now().Add(2 * time.Hour),
+ }
+ if err := db.Create(&row).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ body := fmt.Sprintf(`Đặt lại mật khẩu Simple Care.
+Mã đặt lại (hiệu lực 2 giờ): %s
+Nhập mã này trên màn hình đặt lại mật khẩu.
`, token)
+ _ = mailer.Send(email, "Simple Care — Đặt lại mật khẩu", body)
+ return c.JSON(fiber.Map{"ok": true, "message": "Nếu email tồn tại, mã đặt lại đã được gửi"})
+ }
+}
+
+// POST /api/auth/reset-password
+func ResetPasswordHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ Token string `json:"token"`
+ NewPassword string `json:"newPassword"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ if len(req.NewPassword) < 8 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mật khẩu mới tối thiểu 8 ký tự"})
+ }
+ var row models.PasswordResetToken
+ if err := db.Where("token = ? AND used_at IS NULL AND expires_at > ?", req.Token, time.Now()).First(&row).Error; err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Mã không hợp lệ hoặc đã hết hạn"})
+ }
+ var staff models.StaffAccount
+ if err := db.First(&staff, row.StaffID).Error; err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Tài khoản không tồn tại"})
+ }
+ hash, err := auth.HashPassword(req.NewPassword)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ now := time.Now()
+ staff.PasswordHash = hash
+ staff.MustChangePassword = false
+ row.UsedAt = &now
+ if err := db.Save(&staff).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ _ = db.Save(&row)
+ return c.JSON(fiber.Map{"ok": true, "message": "Đã đặt lại mật khẩu. Vui lòng đăng nhập."})
+ }
+}
+
+// GET /api/admin/email-domains
+func ListEmailDomainsHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var rows []models.EmailDomain
+ db.Order("domain asc").Find(&rows)
+ return c.JSON(fiber.Map{"data": rows})
+ }
+}
+
+// POST /api/admin/email-domains
+func AddEmailDomainHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ Domain string `json:"domain"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ domain := strings.ToLower(strings.TrimSpace(req.Domain))
+ if domain == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "domain required"})
+ }
+ row := models.EmailDomain{Domain: domain, IsActive: true}
+ if err := db.Where("domain = ?", domain).FirstOrCreate(&row).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"ok": true, "data": row})
+ }
+}
+
+// DELETE /api/admin/email-domains/:id
+func DeleteEmailDomainHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ id := c.Params("id")
+ if err := db.Delete(&models.EmailDomain{}, id).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"ok": true})
+ }
+}
+
+// GET /api/admin/staff — danh sách tài khoản
+func ListStaffHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var rows []models.StaffAccount
+ db.Order("email asc").Find(&rows)
+ out := make([]fiber.Map, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, staffPublic(r))
+ }
+ return c.JSON(fiber.Map{"data": out})
+ }
+}
diff --git a/server/internal/handlers/handlers_chat.go b/server/internal/handlers/handlers_chat.go
new file mode 100644
index 0000000..da03a76
--- /dev/null
+++ b/server/internal/handlers/handlers_chat.go
@@ -0,0 +1,282 @@
+package handlers
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "server/internal/middleware"
+ "server/internal/models"
+ internalWs "server/internal/websocket"
+
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+func chatMessageDTO(m models.ChatMessage, staffName string) fiber.Map {
+ return fiber.Map{
+ "id": m.ID,
+ "staffId": m.StaffID,
+ "studentRkId": m.StudentRkID,
+ "senderRole": m.SenderRole,
+ "body": m.Body,
+ "createdAt": m.CreatedAt,
+ "readAt": m.ReadAt,
+ "staffName": staffName,
+ }
+}
+
+// GET /api/chat/students?q=
+func SearchChatStudentsHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ q := strings.TrimSpace(c.Query("q", ""))
+ limit := 30
+ query := db.Model(&models.Student{})
+ if q != "" {
+ like := "%" + q + "%"
+ query = query.Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", like, like, like)
+ }
+ var students []models.Student
+ query.Order("full_name asc").Limit(limit).Find(&students)
+ out := make([]fiber.Map, 0, len(students))
+ for _, s := range students {
+ online := internalWs.Hub.IsStudentOnline(s.RkID)
+ out = append(out, fiber.Map{
+ "studentRkId": s.RkID,
+ "fullName": s.FullName,
+ "studentCode": s.StudentCode,
+ "email": s.Email,
+ "online": online,
+ })
+ }
+ return c.JSON(fiber.Map{"data": out})
+ }
+}
+
+// GET /api/chat/conversations
+func ListChatConversationsHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID := middleware.StaffIDFromCtx(c)
+ type row struct {
+ StudentRkID int64
+ LastAt time.Time
+ Unread int64
+ }
+ var rows []row
+ db.Raw(`
+ SELECT student_rk_id AS student_rk_id, MAX(created_at) AS last_at,
+ SUM(CASE WHEN sender_role = 'student' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
+ FROM chat_messages WHERE staff_id = ? GROUP BY student_rk_id ORDER BY last_at DESC LIMIT 50
+ `, staffID).Scan(&rows)
+
+ out := make([]fiber.Map, 0, len(rows))
+ for _, r := range rows {
+ var st models.Student
+ db.Where("rk_id = ?", r.StudentRkID).First(&st)
+ var last models.ChatMessage
+ db.Where("staff_id = ? AND student_rk_id = ?", staffID, r.StudentRkID).Order("id desc").First(&last)
+ out = append(out, fiber.Map{
+ "studentRkId": r.StudentRkID,
+ "fullName": st.FullName,
+ "studentCode": st.StudentCode,
+ "lastMessage": last.Body,
+ "lastAt": r.LastAt,
+ "unread": r.Unread,
+ })
+ }
+ return c.JSON(fiber.Map{"data": out})
+ }
+}
+
+// GET /api/chat/messages/:studentRkId
+func ListChatMessagesHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID := middleware.StaffIDFromCtx(c)
+ studentRkID, _ := strconv.ParseInt(c.Params("studentRkId"), 10, 64)
+ if studentRkID <= 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid student id"})
+ }
+ var staff models.StaffAccount
+ _ = db.First(&staff, staffID).Error
+ var msgs []models.ChatMessage
+ db.Where("staff_id = ? AND student_rk_id = ?", staffID, studentRkID).Order("id asc").Limit(200).Find(&msgs)
+ now := time.Now()
+ db.Model(&models.ChatMessage{}).
+ Where("staff_id = ? AND student_rk_id = ? AND sender_role = 'student' AND read_at IS NULL", staffID, studentRkID).
+ Update("read_at", now)
+ out := make([]fiber.Map, 0, len(msgs))
+ for _, m := range msgs {
+ out = append(out, chatMessageDTO(m, staff.FullName))
+ }
+ return c.JSON(fiber.Map{"data": out})
+ }
+}
+
+// POST /api/chat/messages
+func SendChatMessageHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID := middleware.StaffIDFromCtx(c)
+ var req struct {
+ StudentRkID int64 `json:"studentRkId"`
+ Body string `json:"body"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ body := strings.TrimSpace(req.Body)
+ if body == "" || req.StudentRkID <= 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and body required"})
+ }
+ var staff models.StaffAccount
+ if err := db.First(&staff, staffID).Error; err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "staff not found"})
+ }
+ msg := models.ChatMessage{
+ StaffID: staffID,
+ StudentRkID: req.StudentRkID,
+ SenderRole: "staff",
+ Body: body,
+ }
+ if err := db.Create(&msg).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ dto := chatMessageDTO(msg, staff.FullName)
+ internalWs.Hub.PushChatToStudent(req.StudentRkID, dto)
+ return c.JSON(fiber.Map{"ok": true, "data": dto})
+ }
+}
+
+// GET /api/student/chat/conversations?studentRkId=
+func StudentListChatConversationsHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
+ if studentRkID <= 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
+ }
+ type row struct {
+ StaffID uint
+ LastAt time.Time
+ Unread int64
+ }
+ var rows []row
+ db.Raw(`
+ SELECT staff_id AS staff_id, MAX(created_at) AS last_at,
+ SUM(CASE WHEN sender_role = 'staff' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
+ FROM chat_messages WHERE student_rk_id = ? GROUP BY staff_id ORDER BY last_at DESC LIMIT 50
+ `, studentRkID).Scan(&rows)
+
+ out := make([]fiber.Map, 0, len(rows))
+ for _, r := range rows {
+ var staff models.StaffAccount
+ name, email := "", ""
+ if db.First(&staff, r.StaffID).Error == nil {
+ name = staff.FullName
+ email = staff.Email
+ if name == "" {
+ name = email
+ }
+ }
+ var last models.ChatMessage
+ db.Where("staff_id = ? AND student_rk_id = ?", r.StaffID, studentRkID).Order("id desc").First(&last)
+ out = append(out, fiber.Map{
+ "staffId": r.StaffID,
+ "staffName": name,
+ "staffEmail": email,
+ "lastMessage": last.Body,
+ "lastAt": r.LastAt,
+ "unread": r.Unread,
+ })
+ }
+ return c.JSON(fiber.Map{"data": out})
+ }
+}
+
+// GET /api/student/chat/messages?studentRkId=&staffId=
+func StudentListChatHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ studentRkID, _ := strconv.ParseInt(c.Query("studentRkId"), 10, 64)
+ staffID, _ := strconv.ParseUint(c.Query("staffId", "0"), 10, 64)
+ if studentRkID <= 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId required"})
+ }
+ query := db.Where("student_rk_id = ?", studentRkID)
+ if staffID > 0 {
+ query = query.Where("staff_id = ?", staffID)
+ }
+ var msgs []models.ChatMessage
+ query.Order("id asc").Limit(200).Find(&msgs)
+ now := time.Now()
+ readQ := db.Model(&models.ChatMessage{}).
+ Where("student_rk_id = ? AND sender_role = 'staff' AND read_at IS NULL", studentRkID)
+ if staffID > 0 {
+ readQ = readQ.Where("staff_id = ?", staffID)
+ }
+ readQ.Update("read_at", now)
+
+ staffNames := map[uint]string{}
+ out := make([]fiber.Map, 0, len(msgs))
+ for _, m := range msgs {
+ name := staffNames[m.StaffID]
+ if name == "" {
+ var st models.StaffAccount
+ if db.First(&st, m.StaffID).Error == nil {
+ name = st.FullName
+ if name == "" {
+ name = st.Email
+ }
+ }
+ staffNames[m.StaffID] = name
+ }
+ out = append(out, chatMessageDTO(m, name))
+ }
+ var lastStaffID uint
+ for i := len(msgs) - 1; i >= 0; i-- {
+ if msgs[i].SenderRole == "staff" {
+ lastStaffID = msgs[i].StaffID
+ break
+ }
+ }
+ return c.JSON(fiber.Map{"data": out, "replyStaffId": lastStaffID})
+ }
+}
+
+// POST /api/student/chat/messages
+func StudentSendChatHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var req struct {
+ StudentRkID int64 `json:"studentRkId"`
+ StaffID uint `json:"staffId"`
+ Body string `json:"body"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
+ }
+ body := strings.TrimSpace(req.Body)
+ if body == "" || req.StudentRkID <= 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
+ }
+ staffID := req.StaffID
+ if staffID == 0 {
+ var last models.ChatMessage
+ if err := db.Where("student_rk_id = ? AND sender_role = 'staff'", req.StudentRkID).
+ Order("id desc").First(&last).Error; err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "chưa có giáo viên nhắn tin"})
+ }
+ staffID = last.StaffID
+ }
+ msg := models.ChatMessage{
+ StaffID: staffID,
+ StudentRkID: req.StudentRkID,
+ SenderRole: "student",
+ Body: body,
+ }
+ if err := db.Create(&msg).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ var staff models.StaffAccount
+ _ = db.First(&staff, staffID).Error
+ dto := chatMessageDTO(msg, staff.FullName)
+ internalWs.Hub.PushChatToStaff(staffID, dto)
+ return c.JSON(fiber.Map{"ok": true, "data": dto})
+ }
+}
diff --git a/server/internal/mail/mail.go b/server/internal/mail/mail.go
new file mode 100644
index 0000000..f4c14da
--- /dev/null
+++ b/server/internal/mail/mail.go
@@ -0,0 +1,91 @@
+package mail
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net"
+ "net/smtp"
+ "os"
+ "strings"
+)
+
+type Config struct {
+ Host string
+ Port string
+ Secure bool
+ User string
+ Password string
+ From string
+}
+
+func LoadConfigFromEnv() Config {
+ return Config{
+ Host: strings.TrimSpace(os.Getenv("MAIL_HOST")),
+ Port: strings.TrimSpace(os.Getenv("MAIL_PORT")),
+ Secure: strings.EqualFold(os.Getenv("MAIL_SECURE"), "true"),
+ User: strings.TrimSpace(os.Getenv("MAIL_AUTH_USER")),
+ Password: strings.Trim(strings.TrimSpace(os.Getenv("MAIL_AUTH_PASS")), `"`),
+ From: strings.TrimSpace(os.Getenv("MAIL_FROM")),
+ }
+}
+
+func (c Config) Enabled() bool {
+ return c.Host != "" && c.Port != "" && c.User != "" && c.Password != ""
+}
+
+func (c Config) fromAddr() string {
+ if c.From != "" {
+ return c.From
+ }
+ return c.User
+}
+
+func (c Config) Send(to, subject, body string) error {
+ if !c.Enabled() {
+ return fmt.Errorf("mail chưa cấu hình (MAIL_HOST, MAIL_AUTH_USER, MAIL_AUTH_PASS)")
+ }
+ msg := strings.Join([]string{
+ "From: Simple Care <" + c.fromAddr() + ">",
+ "To: " + to,
+ "Subject: " + subject,
+ "MIME-Version: 1.0",
+ "Content-Type: text/html; charset=UTF-8",
+ "",
+ body,
+ }, "\r\n")
+
+ addr := net.JoinHostPort(c.Host, c.Port)
+ auth := smtp.PlainAuth("", c.User, c.Password, c.Host)
+
+ if c.Secure {
+ tlsConfig := &tls.Config{ServerName: c.Host}
+ conn, err := tls.Dial("tcp", addr, tlsConfig)
+ if err != nil {
+ return err
+ }
+ client, err := smtp.NewClient(conn, c.Host)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+ if err := client.Auth(auth); err != nil {
+ return err
+ }
+ if err := client.Mail(c.fromAddr()); err != nil {
+ return err
+ }
+ if err := client.Rcpt(to); err != nil {
+ return err
+ }
+ w, err := client.Data()
+ if err != nil {
+ return err
+ }
+ if _, err := w.Write([]byte(msg)); err != nil {
+ return err
+ }
+ return w.Close()
+ }
+
+ return smtp.SendMail(addr, auth, c.fromAddr(), []string{to}, []byte(msg))
+}
diff --git a/server/internal/middleware/staff.go b/server/internal/middleware/staff.go
new file mode 100644
index 0000000..8d4eb1e
--- /dev/null
+++ b/server/internal/middleware/staff.go
@@ -0,0 +1,33 @@
+package middleware
+
+import (
+ "strings"
+
+ "server/internal/auth"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func RequireStaff() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ header := c.Get("Authorization")
+ if header == "" || !strings.HasPrefix(header, "Bearer ") {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
+ }
+ token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
+ claims, err := auth.ParseToken(token)
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
+ }
+ c.Locals("staffId", claims.StaffID)
+ c.Locals("staffEmail", claims.Email)
+ return c.Next()
+ }
+}
+
+func StaffIDFromCtx(c *fiber.Ctx) uint {
+ if v, ok := c.Locals("staffId").(uint); ok {
+ return v
+ }
+ return 0
+}
diff --git a/server/internal/models/staff.go b/server/internal/models/staff.go
new file mode 100644
index 0000000..3a5c2c6
--- /dev/null
+++ b/server/internal/models/staff.go
@@ -0,0 +1,52 @@
+package models
+
+import "time"
+
+// StaffAccount — tài khoản thầy cô truy cập management
+type StaffAccount struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ Email string `gorm:"column:email;size:255;not null;uniqueIndex" json:"email"`
+ PasswordHash string `gorm:"column:password_hash;size:255;not null" json:"-"`
+ FullName string `gorm:"column:full_name;size:255" json:"fullName"`
+ MustChangePassword bool `gorm:"column:must_change_password;not null;default:true" json:"mustChangePassword"`
+ IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
+}
+
+func (StaffAccount) TableName() string { return "staff_accounts" }
+
+// EmailDomain — đuôi email được phép đăng ký staff
+type EmailDomain struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `json:"createdAt"`
+ Domain string `gorm:"column:domain;size:128;not null;uniqueIndex" json:"domain"`
+ IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
+}
+
+func (EmailDomain) TableName() string { return "email_domains" }
+
+// PasswordResetToken — quên mật khẩu
+type PasswordResetToken struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `json:"createdAt"`
+ StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
+ Token string `gorm:"column:token;size:64;not null;uniqueIndex" json:"-"`
+ ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"`
+ UsedAt *time.Time `gorm:"column:used_at" json:"usedAt,omitempty"`
+}
+
+func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
+
+// ChatMessage — tin nhắn thầy cô ↔ sinh viên
+type ChatMessage struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `json:"createdAt"`
+ StaffID uint `gorm:"column:staff_id;not null;index:idx_chat_thread,priority:1" json:"staffId"`
+ StudentRkID int64 `gorm:"column:student_rk_id;not null;index:idx_chat_thread,priority:2" json:"studentRkId"`
+ SenderRole string `gorm:"column:sender_role;size:16;not null" json:"senderRole"` // staff | student
+ Body string `gorm:"column:body;type:text;not null" json:"body"`
+ ReadAt *time.Time `gorm:"column:read_at" json:"readAt,omitempty"`
+}
+
+func (ChatMessage) TableName() string { return "chat_messages" }
diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go
index 8425c2d..e0f2c71 100644
--- a/server/internal/websocket/websocket.go
+++ b/server/internal/websocket/websocket.go
@@ -20,21 +20,56 @@ type SocketClient struct {
Conn *websocket.Conn
StudentID int64
ClassID int64
+ StaffID uint
Role string // "student" | "teacher"
Addr string
}
type WsHub struct {
- mu sync.RWMutex
- students map[int64]*SocketClient
- teachers map[string]*SocketClient
- subscribers map[int64][]string // studentId -> list of teacher connection addresses
+ mu sync.RWMutex
+ students map[int64]*SocketClient
+ teachers map[string]*SocketClient
+ teachersByStaff map[uint][]string // staffId -> teacher connection addresses
+ subscribers map[int64][]string // studentId -> list of teacher connection addresses
}
var Hub = &WsHub{
- students: make(map[int64]*SocketClient),
- teachers: make(map[string]*SocketClient),
- subscribers: make(map[int64][]string),
+ students: make(map[int64]*SocketClient),
+ teachers: make(map[string]*SocketClient),
+ teachersByStaff: make(map[uint][]string),
+ subscribers: make(map[int64][]string),
+}
+
+func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ _, ok := h.students[studentRkID]
+ return ok
+}
+
+func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
+ h.mu.RLock()
+ client, ok := h.students[studentRkID]
+ h.mu.RUnlock()
+ if !ok || client == nil {
+ return
+ }
+ _ = client.Conn.WriteJSON(SocketMsg{Event: "chat:message", Data: data})
+}
+
+func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
+ h.mu.RLock()
+ addrs := append([]string(nil), h.teachersByStaff[staffID]...)
+ h.mu.RUnlock()
+ msg := SocketMsg{Event: "chat:message", Data: data}
+ for _, addr := range addrs {
+ h.mu.RLock()
+ t, found := h.teachers[addr]
+ h.mu.RUnlock()
+ if found && t != nil {
+ _ = t.Conn.WriteJSON(msg)
+ }
+ }
}
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
@@ -63,7 +98,10 @@ func (h *WsHub) Register(c *SocketClient) {
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
} else if c.Role == "teacher" {
h.teachers[c.Addr] = c
- log.Printf("[WS] Teacher registered (Address: %s)", c.Addr)
+ if c.StaffID > 0 {
+ h.teachersByStaff[c.StaffID] = append(h.teachersByStaff[c.StaffID], c.Addr)
+ }
+ log.Printf("[WS] Teacher registered (Address: %s, Staff: %d)", c.Addr, c.StaffID)
}
}
@@ -89,6 +127,20 @@ func (h *WsHub) Unregister(c *SocketClient) {
}
} else if c.Role == "teacher" {
delete(h.teachers, c.Addr)
+ if c.StaffID > 0 {
+ list := h.teachersByStaff[c.StaffID]
+ next := list[:0]
+ for _, addr := range list {
+ if addr != c.Addr {
+ next = append(next, addr)
+ }
+ }
+ if len(next) == 0 {
+ delete(h.teachersByStaff, c.StaffID)
+ } else {
+ h.teachersByStaff[c.StaffID] = next
+ }
+ }
log.Printf("[WS] Teacher %s disconnected", c.Addr)
// Dọn dẹp subscriptions của giáo viên này
@@ -206,9 +258,11 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
role := c.Query("role", "student")
studentIDStr := c.Query("studentId", "0")
classIDStr := c.Query("classId", "0")
+ staffIDStr := c.Query("staffId", "0")
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
+ staffID64, _ := strconv.ParseUint(staffIDStr, 10, 64)
if role == "student" && db != nil {
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
@@ -221,6 +275,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
Conn: c,
StudentID: studentID,
ClassID: classID,
+ StaffID: uint(staffID64),
Role: role,
}
diff --git a/server/main.go b/server/main.go
index a0e3c1d..7ecc90e 100644
--- a/server/main.go
+++ b/server/main.go
@@ -6,6 +6,8 @@ import (
"server/internal/db"
"server/internal/handlers"
+ "server/internal/mail"
+ "server/internal/middleware"
"server/internal/qldt"
"server/internal/syncjobs"
internalWs "server/internal/websocket"
@@ -47,6 +49,7 @@ func main() {
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
classesJob := syncjobs.NewClassesSyncJob()
studentsJob := syncjobs.NewStudentsSyncJob()
+ mailer := mail.LoadConfigFromEnv()
app := fiber.New(fiber.Config{
AppName: "Simple Care Sync Backend",
@@ -72,54 +75,82 @@ func main() {
api := app.Group("/api")
- // Dashboard Stats
- api.Get("/stats", handlers.GetStatsHandler(gormDB))
+ // Auth công khai (management)
+ api.Post("/auth/provision", handlers.ProvisionStaffHandler(gormDB, mailer))
+ api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
+ api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
+ api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
- // Classes endpoints
- api.Get("/classes", handlers.ListClassesHandler(gormDB))
- api.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
- api.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
- api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
- api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
-
- // Students endpoints
- api.Get("/students", handlers.ListAllStudentsHandler(gormDB))
-
- // Sync endpoints
- api.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
- api.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
- api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
- api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
-
- // Learning Management endpoints
- api.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
- api.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
- api.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
- api.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
- api.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
- api.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
-
- api.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
- api.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
- api.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
- api.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
- api.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
- api.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
- api.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
-
- api.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
- api.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
- api.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
- api.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
- api.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
- api.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
-
- // Student syncing offline-resilient log
+ // Student client (không cần JWT staff)
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
+ api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
+ api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
+ api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
+
+ // Health check (public)
+ api.Get("/health", func(c *fiber.Ctx) error {
+ return c.JSON(fiber.Map{"ok": true})
+ })
+
+ // Management — yêu cầu đăng nhập staff
+ staff := api.Group("", middleware.RequireStaff())
+ staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
+ staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
+
+ staff.Get("/stats", handlers.GetStatsHandler(gormDB))
+
+ // Classes endpoints
+ staff.Get("/classes", handlers.ListClassesHandler(gormDB))
+ staff.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
+ staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
+ staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
+ staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
+
+ // Students endpoints
+ staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
+
+ // Sync endpoints
+ staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
+ staff.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
+ staff.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
+ staff.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
+
+ // Learning Management endpoints
+ staff.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
+ staff.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
+ staff.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
+ staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
+ staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
+ staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
+
+ staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
+ staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
+ staff.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
+ staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
+ staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
+ staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
+ staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
+
+ staff.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
+ staff.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
+ staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
+ staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
+ staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
+ staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
+
+ // Chat & quản lý tài khoản
+ staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB))
+ staff.Get("/chat/conversations", handlers.ListChatConversationsHandler(gormDB))
+ staff.Get("/chat/messages/:studentRkId", handlers.ListChatMessagesHandler(gormDB))
+ staff.Post("/chat/messages", handlers.SendChatMessageHandler(gormDB))
+ staff.Get("/admin/email-domains", handlers.ListEmailDomainsHandler(gormDB))
+ staff.Post("/admin/email-domains", handlers.AddEmailDomainHandler(gormDB))
+ staff.Delete("/admin/email-domains/:id", handlers.DeleteEmailDomainHandler(gormDB))
+ staff.Get("/admin/staff", handlers.ListStaffHandler(gormDB))
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s...", port)