Compare commits
11 Commits
c7e88606e3
...
0b1ef77689
Author | SHA1 | Date | |
---|---|---|---|
0b1ef77689 | |||
b0005c6702 | |||
c2abf1a5ba | |||
ac50929e6e | |||
32785398ca | |||
cc497b6016 | |||
97ffcbdaa4 | |||
d09bf8ff02 | |||
c0814093e5 | |||
d48519741d | |||
213991126d |
@ -4,45 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
||||
return ApiServiceDTO{
|
||||
ID: service.ID,
|
||||
ClientID: service.ClientID,
|
||||
Name: service.Name,
|
||||
Description: service.Description,
|
||||
IconUrl: service.IconUrl,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := h.repo.ListApiServices(r.Context())
|
||||
if err != nil {
|
||||
@ -51,15 +21,15 @@ func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
apiServices := make([]ApiServiceDTO, 0)
|
||||
apiServices := make([]types.ApiServiceDTO, 0)
|
||||
|
||||
for _, service := range services {
|
||||
apiServices = append(apiServices, NewApiServiceDTO(service))
|
||||
apiServices = append(apiServices, types.NewApiServiceDTO(service))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []ApiServiceDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
Items []types.ApiServiceDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
@ -146,7 +116,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
service.ClientSecret = clientSecret
|
||||
|
||||
type Response struct {
|
||||
Service ApiServiceDTO `json:"service"`
|
||||
Service types.ApiServiceDTO `json:"service"`
|
||||
Credentials ApiServiceCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
@ -155,7 +125,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Service: NewApiServiceDTO(service),
|
||||
Service: types.NewApiServiceDTO(service),
|
||||
Credentials: ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: service.ClientSecret,
|
||||
@ -183,7 +153,7 @@ func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@ -201,7 +171,7 @@ func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@ -303,7 +273,7 @@ func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(updated)); err != nil {
|
||||
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,9 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
r.Get("/users", h.GetUsers)
|
||||
r.Post("/users", h.CreateUser)
|
||||
r.Get("/users/{id}", h.GetUser)
|
||||
|
||||
r.Get("/user-sessions", h.GetUserSessions)
|
||||
r.Get("/service-sessions", h.GetServiceSessions)
|
||||
})
|
||||
|
||||
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
|
||||
|
89
internal/admin/sessions.go
Normal file
89
internal/admin/sessions.go
Normal file
@ -0,0 +1,89 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
type GetSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
params.Limit = int32(limit)
|
||||
}
|
||||
|
||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||
params.Offset = int32(offset)
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
||||
Limit: params.Limit,
|
||||
Offset: params.Offset,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read user sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var mapped []*types.UserSessionDTO
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(mapped); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
params.Limit = int32(limit)
|
||||
}
|
||||
|
||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||
params.Offset = int32(offset)
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
||||
Limit: params.Limit,
|
||||
Offset: params.Offset,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read api sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var mapped []*types.ServiceSessionDTO
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(sessions); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@ -128,15 +128,103 @@ func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshToke
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessions = `-- name: GetServiceSessions :many
|
||||
SELECT session.id, session.service_id, session.client_id, session.user_id, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.is_active, session.revoked_at, session.scope, session.claims, service.id, service.client_id, service.client_secret, service.name, service.redirect_uris, service.scopes, service.grant_types, service.created_at, service.updated_at, service.is_active, service.description, service.icon_url, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetServiceSessionsRow struct {
|
||||
ServiceSession ServiceSession `json:"service_session"`
|
||||
ApiService ApiService `json:"api_service"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessionsParams) ([]GetServiceSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetServiceSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetServiceSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ServiceSession.ID,
|
||||
&i.ServiceSession.ServiceID,
|
||||
&i.ServiceSession.ClientID,
|
||||
&i.ServiceSession.UserID,
|
||||
&i.ServiceSession.IssuedAt,
|
||||
&i.ServiceSession.ExpiresAt,
|
||||
&i.ServiceSession.LastActive,
|
||||
&i.ServiceSession.IpAddress,
|
||||
&i.ServiceSession.UserAgent,
|
||||
&i.ServiceSession.AccessTokenID,
|
||||
&i.ServiceSession.RefreshTokenID,
|
||||
&i.ServiceSession.IsActive,
|
||||
&i.ServiceSession.RevokedAt,
|
||||
&i.ServiceSession.Scope,
|
||||
&i.ServiceSession.Claims,
|
||||
&i.ApiService.ID,
|
||||
&i.ApiService.ClientID,
|
||||
&i.ApiService.ClientSecret,
|
||||
&i.ApiService.Name,
|
||||
&i.ApiService.RedirectUris,
|
||||
&i.ApiService.Scopes,
|
||||
&i.ApiService.GrantTypes,
|
||||
&i.ApiService.CreatedAt,
|
||||
&i.ApiService.UpdatedAt,
|
||||
&i.ApiService.IsActive,
|
||||
&i.ApiService.Description,
|
||||
&i.ApiService.IconUrl,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, clientID string) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, clientID)
|
||||
type ListActiveServiceSessionsByClientParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, arg ListActiveServiceSessionsByClientParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -176,10 +264,16 @@ SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, i
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, userID *uuid.UUID) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, userID)
|
||||
type ListActiveServiceSessionsByUserParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, arg ListActiveServiceSessionsByUserParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -122,6 +122,72 @@ func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessions = `-- name: GetUserSessions :many
|
||||
SELECT session.id, session.user_id, session.session_type, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.device_info, session.is_active, session.revoked_at, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetUserSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetUserSessionsRow struct {
|
||||
UserSession UserSession `json:"user_session"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams) ([]GetUserSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUserSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetUserSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserSession.ID,
|
||||
&i.UserSession.UserID,
|
||||
&i.UserSession.SessionType,
|
||||
&i.UserSession.IssuedAt,
|
||||
&i.UserSession.ExpiresAt,
|
||||
&i.UserSession.LastActive,
|
||||
&i.UserSession.IpAddress,
|
||||
&i.UserSession.UserAgent,
|
||||
&i.UserSession.AccessTokenID,
|
||||
&i.UserSession.RefreshTokenID,
|
||||
&i.UserSession.DeviceInfo,
|
||||
&i.UserSession.IsActive,
|
||||
&i.UserSession.RevokedAt,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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
|
||||
WHERE user_id = $1
|
||||
|
38
internal/types/apiservices.go
Normal file
38
internal/types/apiservices.go
Normal file
@ -0,0 +1,38 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
||||
return ApiServiceDTO{
|
||||
ID: service.ID,
|
||||
ClientID: service.ClientID,
|
||||
Name: service.Name,
|
||||
Description: service.Description,
|
||||
IconUrl: service.IconUrl,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
29
internal/types/session.go
Normal file
29
internal/types/session.go
Normal file
@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import "gitea.local/admin/hspguard/internal/repository"
|
||||
|
||||
type ServiceSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
ApiService ApiServiceDTO `json:"api_service"`
|
||||
repository.ServiceSession
|
||||
}
|
||||
|
||||
func NewServiceSessionDTO(row *repository.GetServiceSessionsRow) *ServiceSessionDTO {
|
||||
return &ServiceSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
ApiService: NewApiServiceDTO(row.ApiService),
|
||||
ServiceSession: row.ServiceSession,
|
||||
}
|
||||
}
|
||||
|
||||
type UserSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
repository.UserSession
|
||||
}
|
||||
|
||||
func NewUserSessionDTO(row *repository.GetUserSessionsRow) *UserSessionDTO {
|
||||
return &UserSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
UserSession: row.UserSession,
|
||||
}
|
||||
}
|
@ -14,13 +14,15 @@ RETURNING *;
|
||||
SELECT * FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC;
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC;
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
@ -49,3 +51,11 @@ WHERE id = $1
|
||||
SELECT * FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(service), sqlc.embed(u)
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
@ -49,3 +49,10 @@ WHERE id = $1
|
||||
SELECT * FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(u)
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -3809,6 +3810,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
@ -15,6 +15,7 @@
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
@ -25,6 +25,7 @@ import VerifyEmailPage from "./pages/Verify/Email";
|
||||
import VerifyEmailOtpPage from "./pages/Verify/Email/OTP";
|
||||
import VerifyAvatarPage from "./pages/Verify/Avatar";
|
||||
import VerifyReviewPage from "./pages/Verify/Review";
|
||||
import AdminSessionsPage from "./pages/Admin/UserSessions";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -81,6 +82,10 @@ const router = createBrowserRouter([
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "user-sessions",
|
||||
children: [{ index: true, element: <AdminSessionsPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
48
web/src/api/admin/sessions.ts
Normal file
48
web/src/api/admin/sessions.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import type { ServiceSession, UserSession } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchUserSessionsRequest {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export type FetchUserSessionsResponse = UserSession[];
|
||||
|
||||
export const adminGetUserSessionsApi = async (
|
||||
req: FetchUserSessionsRequest,
|
||||
): Promise<FetchUserSessionsResponse> => {
|
||||
const response = await axios.get<FetchUserSessionsResponse>(
|
||||
"/api/v1/admin/user-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface FetchServiceSessionsRequest {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export type FetchServiceSessionsResponse = ServiceSession[];
|
||||
|
||||
export const adminGetServiceSessionsApi = async (
|
||||
req: FetchServiceSessionsRequest,
|
||||
): Promise<FetchServiceSessionsResponse> => {
|
||||
const response = await axios.get<FetchServiceSessionsResponse>(
|
||||
"/api/v1/admin/service-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
64
web/src/components/ui/pagination.tsx
Normal file
64
web/src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
type PaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const getPageNumbers = useCallback(() => {
|
||||
const delta = 2;
|
||||
const pages = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(1, currentPage - delta);
|
||||
i <= Math.min(totalPages, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex justify-center items-center gap-2 mt-4">
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
</Button>
|
||||
|
||||
{getPageNumbers().map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
variant={page === currentPage ? "contained" : "outlined"}
|
||||
>
|
||||
<p className="text-sm">{page}</p>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
@ -1,5 +1,5 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Blocks, Home, User, Users } from "lucide-react";
|
||||
import { Blocks, Home, User, UserLock, Users } from "lucide-react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
@ -81,6 +81,12 @@ export const useBarItems = (): [Item[], (item: Item) => boolean] => {
|
||||
tab: "admin.users",
|
||||
pathname: "/admin/users",
|
||||
},
|
||||
{
|
||||
icon: <UserLock />,
|
||||
title: "User Sessions",
|
||||
tab: "admin.user-sessions",
|
||||
pathname: "/admin/user-sessions",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
197
web/src/pages/Admin/UserSessions/index.tsx
Normal file
197
web/src/pages/Admin/UserSessions/index.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { adminGetUserSessionsApi } from "@/api/admin/sessions";
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import type { DeviceInfo, UserSession } from "@/types";
|
||||
import { Ban } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import moment from "moment";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
|
||||
const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
||||
const parsed = useMemo<DeviceInfo>(
|
||||
() => JSON.parse(atob(deviceInfo)),
|
||||
[deviceInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
<p>
|
||||
{parsed.os} {parsed.os_version} {parsed.browser} {parsed.browser_version}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminSessionsPage: FC = () => {
|
||||
const loading = false;
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
adminGetUserSessionsApi({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}).then((res) => {
|
||||
console.log("get sessions response:", res);
|
||||
if (Array.isArray(res)) {
|
||||
return setSessions(res);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "User Sessions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 flex flex-row items-center justify-between">
|
||||
<p className="text-gray-800 dark:text-gray-300">Search...</p>
|
||||
{/* TODO: Filters */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-medium">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Issued At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Revoked At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!loading && sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
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">
|
||||
<div className="flex flex-row items-center gap-2 justify-start">
|
||||
{typeof session.user?.profile_picture === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.user.profile_picture}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
<Link to={`/admin/users/${session.user_id}`}>
|
||||
<p className="cursor-pointer text-blue-500">
|
||||
{session.user?.full_name ?? ""}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<SessionSource deviceInfo={session.device_info} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm border border-gray-300 dark:border-gray-700">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
|
||||
!session.is_active ||
|
||||
(session.expires_at &&
|
||||
moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
))
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{session.is_active ? "Active" : "Inactive"}
|
||||
{moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
) && " (Expired)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{moment(session.issued_at).format("LLLL")}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.expires_at
|
||||
? moment(session.expires_at).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.last_active
|
||||
? moment(session.last_active).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.revoked_at
|
||||
? new Date(session.revoked_at).toLocaleString()
|
||||
: "never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||
>
|
||||
<Ban size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination currentPage={1} onPageChange={console.log} totalPages={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSessionsPage;
|
@ -31,3 +31,50 @@ export interface ApiServiceCredentials {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface ServiceSession {
|
||||
id: string;
|
||||
service_id: string;
|
||||
api_service?: ApiService | null;
|
||||
client_id: string;
|
||||
user_id?: string | null;
|
||||
user?: UserProfile | null;
|
||||
issued_at: string;
|
||||
expires_at?: string | null;
|
||||
last_active?: string | null;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
access_token_id?: string | null;
|
||||
refresh_token_id?: string | null;
|
||||
is_active: boolean;
|
||||
revoked_at?: string | null;
|
||||
scope?: string | null;
|
||||
claims: string; // base64 encoded
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user?: UserProfile | null;
|
||||
session_type: string; // "user" | "admin"
|
||||
issued_at: string;
|
||||
expires_at?: string | null;
|
||||
last_active?: string | null;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
access_token_id?: string | null;
|
||||
refresh_token_id?: string | null;
|
||||
device_info: string; // base64 encoded
|
||||
is_active: boolean;
|
||||
revoked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
os: string;
|
||||
os_version: string;
|
||||
device_name: string;
|
||||
device_type: string;
|
||||
location: string;
|
||||
browser: string;
|
||||
browser_version: string;
|
||||
}
|
||||
|
Reference in New Issue
Block a user