From d342637eb6525be121c2114320770520de0e3573 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Thu, 9 Apr 2026 19:00:16 +0300 Subject: [PATCH] feat(sessions): add detail page, Notification component, and session API improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add SessionDetailPage with breadcrumbs, DescriptionList, status badge, masked token - add Notification component (info/success/error/warning/note variants) for contextual messages - use Notification for 404 errors on session and environment detail pages - add GET /sessions/:id endpoint; sanitize token (head…tail) and strip cookies/localStorage - change DELETE /sessions/:id to return 204 No Content so client redirect works correctly - add onRowClick prop to Table for clickable rows; stop propagation on actions column - update status badges: open=success (green), closed=error (red) on both list and detail --- client/src/App.tsx | 2 + client/src/api/client.ts | 10 +- client/src/i18n/locales/en.json | 16 +++- client/src/pages/EnvironmentDetailPage.tsx | 11 ++- client/src/pages/Page.module.css | 11 +++ client/src/pages/SessionDetailPage.tsx | 93 +++++++++++++++++++ client/src/pages/SessionsPage.tsx | 17 +++- .../ui/Notification/Notification.module.css | 63 +++++++++++++ client/src/ui/Notification/Notification.tsx | 36 +++++++ client/src/ui/Table/Table.module.css | 4 + client/src/ui/Table/Table.tsx | 10 +- client/src/ui/index.ts | 3 + server/package.json | 1 + server/src/session/session.controller.ts | 24 ++++- 14 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 client/src/pages/SessionDetailPage.tsx create mode 100644 client/src/ui/Notification/Notification.module.css create mode 100644 client/src/ui/Notification/Notification.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index c3aa32c..022988f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import { CreateEnvironmentPage } from './pages/CreateEnvironmentPage'; import { EditEnvironmentPage } from './pages/EditEnvironmentPage'; import { KeysPage } from './pages/KeysPage'; import { SessionsPage } from './pages/SessionsPage'; +import { SessionDetailPage } from './pages/SessionDetailPage'; import { ScenariosPage } from './pages/ScenariosPage'; import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -58,6 +59,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 120c67c..4c0b3fd 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -21,8 +21,11 @@ async function request(path: string, init?: RequestInit): Promise { const text = await res.text().catch(() => res.statusText); throw new Error(`${res.status} ${text}`); } - if (res.status === 204) return undefined as T; - return res.json() as Promise; + const contentLength = res.headers.get('content-length'); + if (res.status === 204 || contentLength === '0') return undefined as T; + const text = await res.text(); + if (!text) return undefined as T; + return JSON.parse(text) as T; } // ── Environments ────────────────────────────────────────────────────────────── @@ -65,6 +68,9 @@ export const sessions = { list(page = 1, limit = 50): Promise> { return request(`/sessions?page=${page}&limit=${limit}`); }, + get(id: number): Promise { + return request(`/sessions/${id}`); + }, remove(id: number): Promise { return request(`/sessions/${id}`, { method: 'DELETE' }); }, diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 73ba3db..2effa9e 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -1,4 +1,10 @@ { + "errors": { + "not_found": "Not found", + "not_found_session": "Session {{id}} does not exist or has been deleted.", + "not_found_environment": "Environment {{id}} does not exist or has been deleted.", + "generic": "Something went wrong. Please try again." + }, "nav": { "environments": "Environments", "keys": "Keys", @@ -48,7 +54,15 @@ "col_status": "Status", "col_lastUsed": "Last Used", "empty": "No sessions yet.", - "action_delete": "Delete" + "loading": "Loading…", + "action_delete": "Delete", + "field_id": "ID", + "field_name": "Name", + "field_token": "Token", + "field_status": "Status", + "field_created": "Created", + "field_updated": "Updated", + "field_lastUsed": "Last Used" }, "scenarios": { "title": "Scenarios", diff --git a/client/src/pages/EnvironmentDetailPage.tsx b/client/src/pages/EnvironmentDetailPage.tsx index b0effb2..bc940d1 100644 --- a/client/src/pages/EnvironmentDetailPage.tsx +++ b/client/src/pages/EnvironmentDetailPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Pencil, Trash2 } from 'lucide-react'; import { environments } from '../api'; import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, DescriptionList, Timestamp } from '../ui'; +import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../ui'; import styles from './Page.module.css'; export function EnvironmentDetailPage() { @@ -53,7 +53,14 @@ export function EnvironmentDetailPage() { )} - {error &&

{error}

} + {error && ( + + {error.startsWith('404') ? t('errors.not_found_environment', { id }) : error} + + )} {loading &&

{t('environments.loading')}

} diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 42a53d6..95c51c6 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -36,6 +36,17 @@ font-size: var(--font-size-sm); } +.codeInline { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.85em; + padding: 0.15em 0.45em; + background: var(--color-code-bg, color-mix(in srgb, var(--color-bg-raised) 60%, var(--color-secondary) 40%)); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-secondary-fg); + word-break: break-all; +} + /* ── Environment cards ──────────────────────────────────────── */ .envGrid { diff --git a/client/src/pages/SessionDetailPage.tsx b/client/src/pages/SessionDetailPage.tsx new file mode 100644 index 0000000..e473400 --- /dev/null +++ b/client/src/pages/SessionDetailPage.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Trash2 } from 'lucide-react'; +import { sessions } from '../api'; +import type { Session } from '../api'; +import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../ui'; +import styles from './Page.module.css'; + +export function SessionDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + sessions + .get(Number(id)) + .then(setSession) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + const handleDelete = async () => { + if (!session) return; + await sessions.remove(session.id); + navigate('/sessions'); + }; + + return ( +
+
+ navigate('/sessions') }, + { label: session?.sessionName ?? `#${id}` }, + ]} + /> + {session && ( + + )} +
+ + {error && ( + + {error.startsWith('404') ? t('errors.not_found_session', { id }) : error} + + )} + {loading &&

{t('sessions.loading')}

} + + {session && ( + + + {session.status} + + ), + }, + { term: t('sessions.field_token'), detail: {session.token} }, + { + term: t('sessions.field_lastUsed'), + detail: session.lastUsedAt ? : '—', + }, + { + term: t('sessions.field_created'), + detail: , + }, + { + term: t('sessions.field_updated'), + detail: , + }, + ]} + /> + + )} +
+ ); +} diff --git a/client/src/pages/SessionsPage.tsx b/client/src/pages/SessionsPage.tsx index 6f4c278..a8a6a20 100644 --- a/client/src/pages/SessionsPage.tsx +++ b/client/src/pages/SessionsPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Trash2 } from 'lucide-react'; import { sessions } from '../api'; import type { Session } from '../api'; import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../ui'; @@ -7,6 +9,7 @@ import styles from './Page.module.css'; export function SessionsPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -37,7 +40,7 @@ export function SessionsPage() { header: t('sessions.col_status'), width: 100, render: (s) => ( - {s.status} + {s.status} ), }, { @@ -49,11 +52,16 @@ export function SessionsPage() { { key: 'actions', header: '', - width: 80, + width: 48, align: 'right', render: (s) => ( - ), }, @@ -71,6 +79,7 @@ export function SessionsPage() { emptyMessage={t('sessions.empty')} pageSize={10} pageSizeOptions={[10, 25, 50]} + onRowClick={(s) => navigate(`/sessions/${s.id}`)} /> ); diff --git a/client/src/ui/Notification/Notification.module.css b/client/src/ui/Notification/Notification.module.css new file mode 100644 index 0000000..e717646 --- /dev/null +++ b/client/src/ui/Notification/Notification.module.css @@ -0,0 +1,63 @@ +.notification { + display: flex; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border-left: 3px solid; + font-size: var(--font-size-sm); + margin-bottom: var(--space-4); +} + +.icon { + flex-shrink: 0; + margin-top: 1px; + display: flex; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.title { + margin: 0; + font-weight: 600; +} + +.message { + margin: 0; + line-height: 1.5; +} + +/* ── Variants ───────────────────────────────────────────────── */ + +.info { + background: var(--color-info-bg); + color: var(--color-info-fg); + border-left-color: var(--color-secondary-fg); +} + +.success { + background: var(--color-success-bg); + color: var(--color-success-fg); + border-left-color: var(--color-primary-hover); +} + +.error { + background: var(--color-error-bg); + color: var(--color-error-fg); + border-left-color: var(--color-danger); +} + +.warning { + background: var(--color-warning-bg); + color: var(--color-warning-fg); + border-left-color: #E09000; +} + +.note { + background: var(--color-neutral-bg); + color: var(--color-neutral-fg); + border-left-color: var(--color-border); +} diff --git a/client/src/ui/Notification/Notification.tsx b/client/src/ui/Notification/Notification.tsx new file mode 100644 index 0000000..9caba60 --- /dev/null +++ b/client/src/ui/Notification/Notification.tsx @@ -0,0 +1,36 @@ +import { AlertCircle, AlertTriangle, CheckCircle2, Info, StickyNote } from 'lucide-react'; +import styles from './Notification.module.css'; + +export type NotificationVariant = 'info' | 'success' | 'error' | 'warning' | 'note'; + +export interface NotificationProps { + variant?: NotificationVariant; + title?: string; + children: React.ReactNode; + className?: string; +} + +const ICONS: Record = { + info: , + success: , + error: , + warning: , + note: , +}; + +export function Notification({ + variant = 'info', + title, + children, + className, +}: NotificationProps) { + return ( +
+ {ICONS[variant]} +
+ {title &&

{title}

} +

{children}

+
+
+ ); +} diff --git a/client/src/ui/Table/Table.module.css b/client/src/ui/Table/Table.module.css index 8597bdd..92ea128 100644 --- a/client/src/ui/Table/Table.module.css +++ b/client/src/ui/Table/Table.module.css @@ -43,6 +43,10 @@ background: var(--color-secondary-hover); } +.clickable { + cursor: pointer; +} + .tr:not(:last-child) .td { border-bottom: var(--border-width) solid var(--color-border); } diff --git a/client/src/ui/Table/Table.tsx b/client/src/ui/Table/Table.tsx index af06817..da80138 100644 --- a/client/src/ui/Table/Table.tsx +++ b/client/src/ui/Table/Table.tsx @@ -21,6 +21,8 @@ export interface TableProps { pageSize?: number; /** Offer a page-size selector when pagination is enabled */ pageSizeOptions?: number[]; + /** Called when a row is clicked */ + onRowClick?: (row: T) => void; } export function Table({ @@ -32,6 +34,7 @@ export function Table({ className, pageSize: defaultPageSize, pageSizeOptions, + onRowClick, }: TableProps) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(defaultPageSize ?? 0); @@ -77,12 +80,17 @@ export function Table({ ) : ( visibleData.map((row) => ( - + onRowClick(row) : undefined} + > {columns.map((col) => ( e.stopPropagation() : undefined} > {col.render(row)} diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index 8167ac2..193803c 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -33,5 +33,8 @@ export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMen export { DescriptionList } from './DescriptionList/DescriptionList'; export type { DescriptionListProps, DescriptionListItem } from './DescriptionList/DescriptionList'; +export { Notification } from './Notification/Notification'; +export type { NotificationProps, NotificationVariant } from './Notification/Notification'; + export { Table } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table'; diff --git a/server/package.json b/server/package.json index fb2b7f3..c37546e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,5 +1,6 @@ { "name": "liquio-qa-bot", + "version": "1.2.0", "description": "", "main": "index.js", "scripts": { diff --git a/server/src/session/session.controller.ts b/server/src/session/session.controller.ts index 8e3f8f6..34cf581 100644 --- a/server/src/session/session.controller.ts +++ b/server/src/session/session.controller.ts @@ -2,6 +2,8 @@ import { Controller, Delete, Get, + HttpCode, + NotFoundException, Param, ParseIntPipe, Query, @@ -12,6 +14,11 @@ import { SessionContextService } from "./session-context.service"; import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { SessionOrderBy } from "./session.service"; +function maskToken(token: string, head = 6, tail = 4): string { + if (token.length <= head + tail + 1) return token; + return `${token.slice(0, head)}\u2026${token.slice(-tail)}`; +} + @ApiTags("sessions") @Controller("sessions") export class SessionController { @@ -27,9 +34,24 @@ export class SessionController { return this.sessionService.findAll(query); } + @Get(":id") + @ApiOperation({ summary: "Get a session by ID" }) + @ApiResponse({ status: 200, description: "Session found" }) + @ApiResponse({ status: 404, description: "Session not found" }) + async findOne(@Param("id", ParseIntPipe) id: number) { + const session = await this.sessionService.findById(id); + if (!session) throw new NotFoundException(`Session ${id} not found`); + const { token, cookies, localStorage, ...rest } = session; + return { + ...rest, + token: maskToken(token), + }; + } + @Delete(":id") + @HttpCode(204) @ApiOperation({ summary: "Delete a session by ID (closes it first if open)" }) - @ApiResponse({ status: 200, description: "Session deleted" }) + @ApiResponse({ status: 204, description: "Session deleted" }) @ApiResponse({ status: 404, description: "Session not found" }) async remove(@Param("id", ParseIntPipe) id: number): Promise { await this.sessionContextService.delete(id);