Compare commits
8 Commits
0b1ef77689
...
ac62158de9
Author | SHA1 | Date | |
---|---|---|---|
ac62158de9 | |||
44e1a18e9a | |||
e0f2c3219f | |||
d2d52dc041 | |||
d7d142152c | |||
5c321311cd | |||
ffc8a5f44d | |||
dbff94e7b3 |
@ -21,7 +21,7 @@ func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
|
|||||||
|
|
||||||
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||||
router.Route("/admin", func(r chi.Router) {
|
router.Route("/admin", func(r chi.Router) {
|
||||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||||
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
|
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
|
||||||
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
|
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
|
||||||
|
|
||||||
@ -37,6 +37,8 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
|||||||
r.Get("/users/{id}", h.GetUser)
|
r.Get("/users/{id}", h.GetUser)
|
||||||
|
|
||||||
r.Get("/user-sessions", h.GetUserSessions)
|
r.Get("/user-sessions", h.GetUserSessions)
|
||||||
|
r.Patch("/user-sessions/revoke/{id}", h.RevokeUserSession)
|
||||||
|
|
||||||
r.Get("/service-sessions", h.GetServiceSessions)
|
r.Get("/service-sessions", h.GetServiceSessions)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -3,17 +3,20 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
"gitea.local/admin/hspguard/internal/types"
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetSessionsParams struct {
|
type GetSessionsParams struct {
|
||||||
Limit int32 `json:"limit"`
|
PageSize int `json:"size"`
|
||||||
Offset int32 `json:"offset"`
|
Page int `json:"page"`
|
||||||
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,17 +25,22 @@ func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
params := GetSessionsParams{}
|
params := GetSessionsParams{}
|
||||||
|
|
||||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||||
params.Limit = int32(limit)
|
params.PageSize = pageSize
|
||||||
|
} else {
|
||||||
|
params.PageSize = 15
|
||||||
}
|
}
|
||||||
|
|
||||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||||
params.Offset = int32(offset)
|
params.Page = page
|
||||||
|
} else {
|
||||||
|
web.Error(w, "page is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
||||||
Limit: params.Limit,
|
Limit: int32(params.PageSize),
|
||||||
Offset: params.Offset,
|
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("ERR: Failed to read user sessions from db:", err)
|
log.Println("ERR: Failed to read user sessions from db:", err)
|
||||||
@ -40,35 +48,77 @@ func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var mapped []*types.UserSessionDTO
|
totalSessions, err := h.repo.GetUserSessionsCount(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ERR: Failed to get total count of user sessions:", err)
|
||||||
|
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped := make([]*types.UserSessionDTO, 0)
|
||||||
|
|
||||||
for _, session := range sessions {
|
for _, session := range sessions {
|
||||||
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(mapped); err != nil {
|
type Response struct {
|
||||||
|
Items []*types.UserSessionDTO `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := Response{
|
||||||
|
Items: mapped,
|
||||||
|
Page: params.Page,
|
||||||
|
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) RevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionId := chi.URLParam(r, "id")
|
||||||
|
parsed, err := uuid.Parse(sessionId)
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.RevokeUserSession(r.Context(), parsed); err != nil {
|
||||||
|
log.Println("ERR: Failed to revoke user session:", err)
|
||||||
|
web.Error(w, "failed to revoke user session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("{\"success\":true}"))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
|
|
||||||
params := GetSessionsParams{}
|
params := GetSessionsParams{}
|
||||||
|
|
||||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||||
params.Limit = int32(limit)
|
params.PageSize = pageSize
|
||||||
|
} else {
|
||||||
|
params.PageSize = 15
|
||||||
}
|
}
|
||||||
|
|
||||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||||
params.Offset = int32(offset)
|
params.Page = page
|
||||||
|
} else {
|
||||||
|
web.Error(w, "page is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
||||||
Limit: params.Limit,
|
Limit: int32(params.PageSize),
|
||||||
Offset: params.Offset,
|
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("ERR: Failed to read api sessions from db:", err)
|
log.Println("ERR: Failed to read api sessions from db:", err)
|
||||||
@ -76,13 +126,32 @@ func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var mapped []*types.ServiceSessionDTO
|
totalSessions, err := h.repo.GetServiceSessionsCount(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ERR: Failed to get total count of service sessions:", err)
|
||||||
|
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped := make([]*types.ServiceSessionDTO, 0)
|
||||||
|
|
||||||
for _, session := range sessions {
|
for _, session := range sessions {
|
||||||
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(sessions); err != nil {
|
type Response struct {
|
||||||
|
Items []*types.ServiceSessionDTO `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := Response{
|
||||||
|
Items: mapped,
|
||||||
|
Page: params.Page,
|
||||||
|
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
@ -89,7 +89,7 @@ func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.A
|
|||||||
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
||||||
api.Route("/auth", func(r chi.Router) {
|
api.Route("/auth", func(r chi.Router) {
|
||||||
r.Group(func(protected chi.Router) {
|
r.Group(func(protected chi.Router) {
|
||||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||||
protected.Use(authMiddleware.Runner)
|
protected.Use(authMiddleware.Runner)
|
||||||
|
|
||||||
protected.Get("/profile", h.getProfile)
|
protected.Get("/profile", h.getProfile)
|
||||||
|
@ -3,22 +3,27 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/config"
|
"gitea.local/admin/hspguard/internal/config"
|
||||||
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
"gitea.local/admin/hspguard/internal/types"
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
"gitea.local/admin/hspguard/internal/util"
|
"gitea.local/admin/hspguard/internal/util"
|
||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
cfg *config.AppConfig
|
cfg *config.AppConfig
|
||||||
|
repo *repository.Queries
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
|
func NewAuthMiddleware(cfg *config.AppConfig, repo *repository.Queries) *AuthMiddleware {
|
||||||
return &AuthMiddleware{
|
return &AuthMiddleware{
|
||||||
cfg,
|
cfg,
|
||||||
|
repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +50,26 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: redis caching
|
||||||
|
parsed, err := uuid.Parse(userClaims.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERR: Failed to parse token JTI '%s': %v\n", userClaims.ID, err)
|
||||||
|
web.Error(w, "failed to get session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := m.repo.GetUserSessionByAccessJTI(r.Context(), &parsed)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERR: Failed to find session with '%s' JTI: %v\n", parsed.String(), err)
|
||||||
|
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.IsActive {
|
||||||
|
log.Printf("INFO: Inactive session trying to authorize: %s\n", session.AccessTokenID)
|
||||||
|
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
|
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
|
||||||
ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID)
|
ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
@ -25,7 +25,7 @@ func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.
|
|||||||
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
|
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
|
||||||
router.Route("/oauth", func(r chi.Router) {
|
router.Route("/oauth", func(r chi.Router) {
|
||||||
r.Group(func(protected chi.Router) {
|
r.Group(func(protected chi.Router) {
|
||||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||||
protected.Use(authMiddleware.Runner)
|
protected.Use(authMiddleware.Runner)
|
||||||
|
|
||||||
protected.Post("/code", h.getAuthCode)
|
protected.Post("/code", h.getAuthCode)
|
||||||
|
@ -102,7 +102,6 @@ func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenI
|
|||||||
const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one
|
const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one
|
||||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||||
WHERE refresh_token_id = $1
|
WHERE refresh_token_id = $1
|
||||||
AND is_active = TRUE
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) {
|
func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) {
|
||||||
@ -210,6 +209,17 @@ func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessions
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getServiceSessionsCount = `-- name: GetServiceSessionsCount :one
|
||||||
|
SELECT COUNT(*) FROM service_sessions
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetServiceSessionsCount(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getServiceSessionsCount)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many
|
const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many
|
||||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||||
WHERE client_id = $1
|
WHERE client_id = $1
|
||||||
|
@ -98,7 +98,6 @@ func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *
|
|||||||
const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one
|
const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one
|
||||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||||
WHERE refresh_token_id = $1
|
WHERE refresh_token_id = $1
|
||||||
AND is_active = TRUE
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) {
|
func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) {
|
||||||
@ -188,6 +187,17 @@ func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUserSessionsCount = `-- name: GetUserSessionsCount :one
|
||||||
|
SELECT COUNT(*) FROM user_sessions
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserSessionsCount(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserSessionsCount)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const listActiveUserSessions = `-- name: ListActiveUserSessions :many
|
const listActiveUserSessions = `-- name: ListActiveUserSessions :many
|
||||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
@ -38,7 +38,7 @@ func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *c
|
|||||||
|
|
||||||
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
||||||
api.Group(func(protected chi.Router) {
|
api.Group(func(protected chi.Router) {
|
||||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||||
protected.Use(authMiddleware.Runner)
|
protected.Use(authMiddleware.Runner)
|
||||||
|
|
||||||
protected.Put("/avatar", h.uploadAvatar)
|
protected.Put("/avatar", h.uploadAvatar)
|
||||||
|
@ -31,8 +31,7 @@ WHERE access_token_id = $1
|
|||||||
|
|
||||||
-- name: GetServiceSessionByRefreshJTI :one
|
-- name: GetServiceSessionByRefreshJTI :one
|
||||||
SELECT * FROM service_sessions
|
SELECT * FROM service_sessions
|
||||||
WHERE refresh_token_id = $1
|
WHERE refresh_token_id = $1;
|
||||||
AND is_active = TRUE;
|
|
||||||
|
|
||||||
-- name: RevokeServiceSession :exec
|
-- name: RevokeServiceSession :exec
|
||||||
UPDATE service_sessions
|
UPDATE service_sessions
|
||||||
@ -59,3 +58,6 @@ JOIN api_services AS service ON service.id = session.service_id
|
|||||||
JOIN users AS u ON u.id = session.user_id
|
JOIN users AS u ON u.id = session.user_id
|
||||||
ORDER BY session.issued_at DESC
|
ORDER BY session.issued_at DESC
|
||||||
LIMIT $1 OFFSET $2;
|
LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
-- name: GetServiceSessionsCount :one
|
||||||
|
SELECT COUNT(*) FROM service_sessions;
|
||||||
|
@ -23,8 +23,7 @@ WHERE access_token_id = $1
|
|||||||
|
|
||||||
-- name: GetUserSessionByRefreshJTI :one
|
-- name: GetUserSessionByRefreshJTI :one
|
||||||
SELECT * FROM user_sessions
|
SELECT * FROM user_sessions
|
||||||
WHERE refresh_token_id = $1
|
WHERE refresh_token_id = $1;
|
||||||
AND is_active = TRUE;
|
|
||||||
|
|
||||||
-- name: RevokeUserSession :exec
|
-- name: RevokeUserSession :exec
|
||||||
UPDATE user_sessions
|
UPDATE user_sessions
|
||||||
@ -56,3 +55,6 @@ FROM user_sessions AS session
|
|||||||
JOIN users AS u ON u.id = session.user_id
|
JOIN users AS u ON u.id = session.user_id
|
||||||
ORDER BY session.issued_at DESC
|
ORDER BY session.issued_at DESC
|
||||||
LIMIT $1 OFFSET $2;
|
LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
-- name: GetUserSessionsCount :one
|
||||||
|
SELECT COUNT(*) FROM user_sessions;
|
||||||
|
@ -2,11 +2,15 @@ import type { ServiceSession, UserSession } from "@/types";
|
|||||||
import { axios, handleApiError } from "..";
|
import { axios, handleApiError } from "..";
|
||||||
|
|
||||||
export interface FetchUserSessionsRequest {
|
export interface FetchUserSessionsRequest {
|
||||||
limit: number;
|
page: number;
|
||||||
offset: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FetchUserSessionsResponse = UserSession[];
|
export interface FetchUserSessionsResponse {
|
||||||
|
items: UserSession[];
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const adminGetUserSessionsApi = async (
|
export const adminGetUserSessionsApi = async (
|
||||||
req: FetchUserSessionsRequest,
|
req: FetchUserSessionsRequest,
|
||||||
@ -24,6 +28,17 @@ export const adminGetUserSessionsApi = async (
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const adminRevokeUserSessionApi = async (
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||||
|
`/api/v1/admin/user-sessions/revoke/${sessionId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 200 && response.status !== 201)
|
||||||
|
throw await handleApiError(response);
|
||||||
|
};
|
||||||
|
|
||||||
export interface FetchServiceSessionsRequest {
|
export interface FetchServiceSessionsRequest {
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { adminGetUserSessionsApi } from "@/api/admin/sessions";
|
|
||||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Avatar from "@/feature/Avatar";
|
import Avatar from "@/feature/Avatar";
|
||||||
import type { DeviceInfo, UserSession } from "@/types";
|
import type { DeviceInfo } from "@/types";
|
||||||
import { Ban } from "lucide-react";
|
import { Ban } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState, type FC } from "react";
|
import { useCallback, useEffect, useMemo, type FC } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
|
import { useUserSessions } from "@/store/admin/userSessions";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
|
||||||
const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
||||||
const parsed = useMemo<DeviceInfo>(
|
const parsed = useMemo<DeviceInfo>(
|
||||||
@ -23,20 +24,29 @@ const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AdminSessionsPage: FC = () => {
|
const AdminSessionsPage: FC = () => {
|
||||||
const loading = false;
|
const loading = useUserSessions((s) => s.loading);
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const sessions = useUserSessions((s) => s.items);
|
||||||
|
|
||||||
|
const page = useUserSessions((s) => s.page);
|
||||||
|
const totalPages = useUserSessions((s) => s.totalPages);
|
||||||
|
|
||||||
|
const fetchSessions = useUserSessions((s) => s.fetch);
|
||||||
|
const revokeSession = useUserSessions((s) => s.revoke);
|
||||||
|
|
||||||
|
const revokingId = useUserSessions((s) => s.revokingId);
|
||||||
|
|
||||||
|
const profile = useAuth((s) => s.profile);
|
||||||
|
|
||||||
|
const handleRevokeSession = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
revokeSession(id);
|
||||||
|
},
|
||||||
|
[revokeSession],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminGetUserSessionsApi({
|
fetchSessions(1);
|
||||||
limit: 10,
|
}, [fetchSessions]);
|
||||||
offset: 0,
|
|
||||||
}).then((res) => {
|
|
||||||
console.log("get sessions response:", res);
|
|
||||||
if (Array.isArray(res)) {
|
|
||||||
return setSessions(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col items-stretch w-full">
|
<div className="relative flex flex-col items-stretch w-full">
|
||||||
@ -113,11 +123,6 @@ const AdminSessionsPage: FC = () => {
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
{/* <td className="px-6 py-4 text-sm font-medium text-blue-600 border border-gray-300 dark:border-gray-700">
|
|
||||||
<span className="inline-block px-2 py-1 text-xs rounded-full font-semibold bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
|
||||||
{sessionsType}
|
|
||||||
</span>
|
|
||||||
</td> */}
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||||
<div className="flex flex-row items-center gap-2 justify-start">
|
<div className="flex flex-row items-center gap-2 justify-start">
|
||||||
{typeof session.user?.profile_picture === "string" && (
|
{typeof session.user?.profile_picture === "string" && (
|
||||||
@ -126,9 +131,11 @@ const AdminSessionsPage: FC = () => {
|
|||||||
className="w-7 h-7 min-w-7"
|
className="w-7 h-7 min-w-7"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Link to={`/admin/users/${session.user_id}`}>
|
|
||||||
<p className="cursor-pointer text-blue-500">
|
<Link to={`/admin/users/view/${session.user_id}`}>
|
||||||
{session.user?.full_name ?? ""}
|
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||||
|
{session.user?.full_name ?? ""}{" "}
|
||||||
|
{session.user_id === profile?.id ? "(You)" : ""}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -177,6 +184,8 @@ const AdminSessionsPage: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||||
|
onClick={() => handleRevokeSession(session.id)}
|
||||||
|
disabled={revokingId === session.id}
|
||||||
>
|
>
|
||||||
<Ban size={18} />
|
<Ban size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -187,9 +196,12 @@ const AdminSessionsPage: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<Pagination currentPage={1} onPageChange={console.log} totalPages={2} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
onPageChange={(newPage) => fetchSessions(newPage)}
|
||||||
|
totalPages={totalPages}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -62,8 +62,9 @@ export default function LoginPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
setError(
|
setError(
|
||||||
"Failed to create account. " +
|
err.response?.data?.error ??
|
||||||
(err.message ?? "Unexpected error happened"),
|
err.message ??
|
||||||
|
"Unexpected error happened",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
57
web/src/store/admin/userSessions.ts
Normal file
57
web/src/store/admin/userSessions.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
adminGetUserSessionsApi,
|
||||||
|
adminRevokeUserSessionApi,
|
||||||
|
} from "@/api/admin/sessions";
|
||||||
|
import type { UserSession } from "@/types";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export const ADMIN_USER_SESSIONS_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export interface IUserSessionsState {
|
||||||
|
items: UserSession[];
|
||||||
|
totalPages: number;
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
loading: boolean;
|
||||||
|
|
||||||
|
revokingId: string | null;
|
||||||
|
|
||||||
|
fetch: (page: number) => Promise<void>;
|
||||||
|
revoke: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserSessions = create<IUserSessionsState>((set) => ({
|
||||||
|
items: [],
|
||||||
|
totalPages: 0,
|
||||||
|
page: 1,
|
||||||
|
loading: false,
|
||||||
|
revokingId: null,
|
||||||
|
|
||||||
|
fetch: async (page) => {
|
||||||
|
set({ loading: true, page });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminGetUserSessionsApi({
|
||||||
|
page,
|
||||||
|
size: ADMIN_USER_SESSIONS_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
set({ items: response.items, totalPages: response.total_pages });
|
||||||
|
} catch (err) {
|
||||||
|
console.log("ERR: Failed to fetch admin user sessions:", err);
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
revoke: async (id) => {
|
||||||
|
set({ revokingId: id });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminRevokeUserSessionApi(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("ERR: Failed to revoke user sessions:", err);
|
||||||
|
} finally {
|
||||||
|
set({ revokingId: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
Reference in New Issue
Block a user