Compare commits

...

11 Commits

17 changed files with 731 additions and 47 deletions

View File

@ -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)
}
}

View File

@ -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)

View 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)
}
}

View File

@ -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
}

View File

@ -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

View 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
View 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,
}
}

View File

@ -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;

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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 /> }],
},
],
},
],

View 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;
};

View 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;

View File

@ -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",
},
]
: []),
],

View 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;

View File

@ -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;
}