Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react'
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { ConfigProvider, theme, Layout, Button } from 'antd'
import { HomeOutlined, LogoutOutlined, KeyOutlined } from '@ant-design/icons'
import { ConfigProvider, theme, Layout, Button, Tag } from 'antd'
import { HomeOutlined, LogoutOutlined, KeyOutlined, LinkOutlined } from '@ant-design/icons'
import { useStore } from './store/useStore'
import { useAuthStore } from './store/useAuthStore'

Expand All @@ -11,7 +11,10 @@ export default function App() {
const fetchInit = useStore((s) => s.fetchInit)
const logout = useAuthStore((s) => s.logout)
const username = useAuthStore((s) => s.username)
const role = useAuthStore((s) => s.role)
const navigate = useNavigate()
const isDemo = role === 'DEMO'

useEffect(() => {
fetchInit()
}, [fetchInit])
Expand Down Expand Up @@ -42,16 +45,28 @@ export default function App() {
Home
</Button>
</Link>
<Link to="/api-keys">
<Button type="text" icon={<KeyOutlined />} className="app-header-btn">
API Keys
</Button>
</Link>
{!isDemo && (
<>
<Link to="/api-keys">
<Button type="text" icon={<KeyOutlined />} className="app-header-btn">
API Keys
</Button>
</Link>
<Link to="/demo-links">
<Button type="text" icon={<LinkOutlined />} className="app-header-btn">
Demo Links
</Button>
</Link>
</>
)}
{username && (
<span className="app-header-username">{username}</span>
<span className="app-header-username">
{username}
{isDemo && <Tag color="orange" style={{ marginLeft: 8 }}>Demo</Tag>}
</span>
)}
<Button type="text" icon={<LogoutOutlined />} className="app-header-btn" onClick={handleLogout}>
Logout
{isDemo ? 'Exit Demo' : 'Logout'}
</Button>
</div>
</Header>
Expand Down
7 changes: 7 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Dashboard from './pages/Dashboard'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import ApiKeysPage from './pages/ApiKeysPage'
import DemoLinksPage from './pages/DemoLinksPage'
import DemoAuthPage from './pages/DemoAuthPage'
import ProtectedRoute from './components/ProtectedRoute'
import ErrorBoundary from './components/ErrorBoundary'
import 'antd/dist/reset.css'
Expand All @@ -21,6 +23,10 @@ const router = createBrowserRouter([
path: '/register',
element: <RegisterPage />,
},
{
path: '/demo/:token',
element: <DemoAuthPage />,
},
{
path: '/',
element: (
Expand All @@ -41,6 +47,7 @@ const router = createBrowserRouter([
{ index: true, element: <SessionList /> },
{ path: 'dashboard/:sessionId', element: <Dashboard /> },
{ path: 'api-keys', element: <ApiKeysPage /> },
{ path: 'demo-links', element: <DemoLinksPage /> },
],
},
])
Expand Down
46 changes: 46 additions & 0 deletions src/pages/DemoAuthPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ConfigProvider, theme, Spin, Alert, Button, Typography } from 'antd'
import { useAuthStore } from '@/store/useAuthStore'

const { Title, Text } = Typography

export default function DemoAuthPage() {
const { token } = useParams<{ token: string }>()
const exchangeDemo = useAuthStore((s) => s.exchangeDemo)
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)

useEffect(() => {
if (!token) {
setError('Invalid demo link.')
return
}
exchangeDemo(token)
.then(() => navigate('/', { replace: true }))
.catch((e: any) => setError(e.message || 'Invalid or expired demo link.'))
}, [token])

return (
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorBgBase: '#0b0d10', colorTextBase: '#e5e7eb' } }}>
<div className="auth-page">
<div className="auth-card">
<Title level={3} className="auth-title">Demo Access</Title>
{!error ? (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Spin size="large" />
<Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
Signing you in…
</Text>
</div>
) : (
<>
<Alert type="error" message={error} style={{ marginBottom: 16 }} />
<Button block onClick={() => navigate('/login')}>Go to Login</Button>
</>
)}
</div>
</div>
</ConfigProvider>
)
}
257 changes: 257 additions & 0 deletions src/pages/DemoLinksPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import React, { useEffect, useState } from 'react'
import {
Typography,
Button,
Table,
Modal,
Form,
Input,
Alert,
Popconfirm,
Tag,
Space,
Tooltip,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { PlusOutlined, DeleteOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'
import { apiFetch } from '@/api'
import dayjs from 'dayjs'

const { Title, Text, Paragraph } = Typography

const FRONTEND_BASE = window.location.origin

interface DemoTokenDto {
id: string
name: string
tokenPrefix: string
revoked: boolean
createdAt: string
lastUsedAt: string | null
}

interface DemoTokenCreatedDto {
id: string
name: string
rawToken: string
}

export default function DemoLinksPage() {
const [tokens, setTokens] = useState<DemoTokenDto[]>([])
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [newToken, setNewToken] = useState<DemoTokenCreatedDto | null>(null)
const [copied, setCopied] = useState(false)
const [form] = Form.useForm()

const fetchTokens = async () => {
setLoading(true)
try {
const res = await apiFetch('/api/v1/auth/demo-tokens')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setTokens(await res.json())
} catch (e: any) {
console.error('Failed to fetch demo tokens', e)
} finally {
setLoading(false)
}
}

useEffect(() => { fetchTokens() }, [])

const handleCreate = async (values: { name: string }) => {
setCreating(true)
setCreateError(null)
try {
const res = await apiFetch('/api/v1/auth/demo-tokens', {
method: 'POST',
body: JSON.stringify({ name: values.name }),
})
if (!res.ok) throw new Error(await res.text())
setNewToken(await res.json())
form.resetFields()
fetchTokens()
} catch (e: any) {
setCreateError(e.message || 'Failed to create demo link')
} finally {
setCreating(false)
}
}

const handleRevoke = async (id: string) => {
try {
await apiFetch(`/api/v1/auth/demo-tokens/${id}`, { method: 'DELETE' })
setTokens((prev) => prev.map((t) => t.id === id ? { ...t, revoked: true } : t))
} catch (e) {
console.error('Failed to revoke demo token', e)
}
}

const demoUrl = (rawToken: string) => `${FRONTEND_BASE}/demo/${rawToken}`

const handleCopy = (text: string) => {
navigator.clipboard?.writeText(text).catch(() => {
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
})
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

const handleCloseNewToken = () => {
setNewToken(null)
setCopied(false)
setCreateOpen(false)
}

const columns: ColumnsType<DemoTokenDto> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Prefix',
dataIndex: 'tokenPrefix',
key: 'tokenPrefix',
render: (v: string) => <Tag>{v}…</Tag>,
},
{
title: 'Status',
dataIndex: 'revoked',
key: 'revoked',
render: (v: boolean) =>
v ? <Tag color="red">Revoked</Tag> : <Tag color="green">Active</Tag>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: 'Last Used',
dataIndex: 'lastUsedAt',
key: 'lastUsedAt',
render: (v: string | null) =>
v ? dayjs(v).format('YYYY-MM-DD HH:mm') : <Text type="secondary">Never</Text>,
},
{
title: '',
key: 'actions',
align: 'right',
render: (_: unknown, record: DemoTokenDto) =>
!record.revoked ? (
<Popconfirm
title="Revoke this demo link?"
description="Anyone using this link will immediately lose access."
onConfirm={() => handleRevoke(record.id)}
okText="Revoke"
okButtonProps={{ danger: true }}
>
<Button danger size="small" icon={<DeleteOutlined />}>Revoke</Button>
</Popconfirm>
) : null,
},
]

return (
<div className="api-keys-page">
<div className="api-keys-header">
<Title level={4} className="api-keys-title">
<LinkOutlined className="api-keys-title-icon" />
Demo Links
</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setCreateOpen(true); setCreateError(null) }}>
Create Link
</Button>
</div>

<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Share these links to give read-only access to your sessions. Each link logs the visitor in as a Demo viewer.
</Text>

<Table<DemoTokenDto>
rowKey="id"
columns={columns}
dataSource={tokens}
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: 'No demo links yet.' }}
/>

{/* Create modal */}
<Modal
title="Create Demo Link"
open={createOpen && !newToken}
onCancel={() => { setCreateOpen(false); setCreateError(null); form.resetFields() }}
footer={null}
destroyOnClose
>
{createError && <Alert type="error" message={createError} className="api-keys-alert" />}
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
label="Link name"
name="name"
rules={[{ required: true, message: 'Enter a name to identify this link' }]}
extra="E.g. conference-demo or colleague-review"
>
<Input placeholder="conference-demo" />
</Form.Item>
<Form.Item className="api-keys-form-actions">
<Space>
<Button onClick={() => { setCreateOpen(false); form.resetFields() }}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={creating}>Create</Button>
</Space>
</Form.Item>
</Form>
</Modal>

{/* One-time link display modal */}
<Modal
title="Demo Link Created — Copy It Now"
open={!!newToken}
onCancel={handleCloseNewToken}
footer={[
<Button key="close" type="primary" onClick={handleCloseNewToken}>Done</Button>,
]}
closable={false}
maskClosable={false}
>
<Alert
type="warning"
message="This link contains the full token and will not be shown again. Copy it now."
className="api-keys-alert"
/>
{newToken && (
<>
<Text type="secondary">Link name: </Text>
<Text strong>{newToken.name}</Text>
<div className="api-keys-key-row" style={{ marginTop: 12 }}>
<Paragraph code copyable={false} className="api-keys-key-text">
{demoUrl(newToken.rawToken)}
</Paragraph>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(demoUrl(newToken.rawToken))}
type={copied ? 'primary' : 'default'}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</>
)}
</Modal>
</div>
)
}
Loading
Loading