diff --git a/src/App.tsx b/src/App.tsx index 61bc075..db43a2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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' @@ -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]) @@ -42,16 +45,28 @@ export default function App() { Home - - - + {!isDemo && ( + <> + + + + + + + + )} {username && ( - {username} + + {username} + {isDemo && Demo} + )} diff --git a/src/main.tsx b/src/main.tsx index bd37bc1..a6f8c07 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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' @@ -21,6 +23,10 @@ const router = createBrowserRouter([ path: '/register', element: , }, + { + path: '/demo/:token', + element: , + }, { path: '/', element: ( @@ -41,6 +47,7 @@ const router = createBrowserRouter([ { index: true, element: }, { path: 'dashboard/:sessionId', element: }, { path: 'api-keys', element: }, + { path: 'demo-links', element: }, ], }, ]) diff --git a/src/pages/DemoAuthPage.tsx b/src/pages/DemoAuthPage.tsx new file mode 100644 index 0000000..cb85c8e --- /dev/null +++ b/src/pages/DemoAuthPage.tsx @@ -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(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 ( + +
+
+ Demo Access + {!error ? ( +
+ + + Signing you in… + +
+ ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/src/pages/DemoLinksPage.tsx b/src/pages/DemoLinksPage.tsx new file mode 100644 index 0000000..2fa7cff --- /dev/null +++ b/src/pages/DemoLinksPage.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const [newToken, setNewToken] = useState(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 = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (v: string) => {v}, + }, + { + title: 'Prefix', + dataIndex: 'tokenPrefix', + key: 'tokenPrefix', + render: (v: string) => {v}…, + }, + { + title: 'Status', + dataIndex: 'revoked', + key: 'revoked', + render: (v: boolean) => + v ? Revoked : Active, + }, + { + 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') : Never, + }, + { + title: '', + key: 'actions', + align: 'right', + render: (_: unknown, record: DemoTokenDto) => + !record.revoked ? ( + handleRevoke(record.id)} + okText="Revoke" + okButtonProps={{ danger: true }} + > + + + ) : null, + }, + ] + + return ( +
+
+ + <LinkOutlined className="api-keys-title-icon" /> + Demo Links + + +
+ + + Share these links to give read-only access to your sessions. Each link logs the visitor in as a Demo viewer. + + + + rowKey="id" + columns={columns} + dataSource={tokens} + loading={loading} + pagination={false} + size="small" + locale={{ emptyText: 'No demo links yet.' }} + /> + + {/* Create modal */} + { setCreateOpen(false); setCreateError(null); form.resetFields() }} + footer={null} + destroyOnClose + > + {createError && } +
+ + + + + + + + + +
+
+ + {/* One-time link display modal */} + Done, + ]} + closable={false} + maskClosable={false} + > + + {newToken && ( + <> + Link name: + {newToken.name} +
+ + {demoUrl(newToken.rawToken)} + + +
+ + )} +
+
+ ) +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3958598..8cecfc2 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -5,6 +5,8 @@ import { useAuthStore } from '@/store/useAuthStore' const { Title } = Typography +const registerEnabled = import.meta.env.VITE_DISABLE_REGISTER !== 'true' + export default function LoginPage() { const login = useAuthStore((s) => s.login) const navigate = useNavigate() @@ -43,9 +45,11 @@ export default function LoginPage() { -
- No account? Register -
+ {registerEnabled && ( +
+ No account? Register +
+ )} diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx index 07bd6be..9b9a018 100644 --- a/src/pages/RegisterPage.tsx +++ b/src/pages/RegisterPage.tsx @@ -1,11 +1,14 @@ import React, { useState } from 'react' import { Form, Input, Button, Typography, Alert, ConfigProvider, theme } from 'antd' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, Navigate } from 'react-router-dom' import { useAuthStore } from '@/store/useAuthStore' const { Title } = Typography +const registerEnabled = import.meta.env.VITE_DISABLE_REGISTER !== 'true' + export default function RegisterPage() { + if (!registerEnabled) return const register = useAuthStore((s) => s.register) const navigate = useNavigate() const [error, setError] = useState(null) diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index 2bb36fe..9075786 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -7,10 +7,27 @@ interface AuthState { login: (emailOrUsername: string, password: string) => Promise logout: () => void register: (email: string, username: string, password: string) => Promise + exchangeDemo: (rawToken: string) => Promise } const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' +async function parseErrorMessage(res: Response, fallback: string): Promise { + const text = await res.text() + try { + const json = JSON.parse(text) + return json.message || json.error || fallback + } catch { + return text || fallback + } +} + +function saveAuth(data: { token: string; username: string; role: string }) { + localStorage.setItem('auth_token', data.token) + localStorage.setItem('auth_username', data.username) + localStorage.setItem('auth_role', data.role) +} + export const useAuthStore = create((set) => ({ token: localStorage.getItem('auth_token'), username: localStorage.getItem('auth_username'), @@ -23,13 +40,10 @@ export const useAuthStore = create((set) => ({ body: JSON.stringify({ emailOrUsername, password }), }) if (!res.ok) { - const err = await res.text() - throw new Error(err || 'Login failed') + throw new Error(await parseErrorMessage(res, 'Login failed')) } const data = await res.json() - localStorage.setItem('auth_token', data.token) - localStorage.setItem('auth_username', data.username) - localStorage.setItem('auth_role', data.role) + saveAuth(data) set({ token: data.token, username: data.username, role: data.role }) }, @@ -47,13 +61,20 @@ export const useAuthStore = create((set) => ({ body: JSON.stringify({ email, username, password }), }) if (!res.ok) { - const err = await res.text() - throw new Error(err || 'Registration failed') + throw new Error(await parseErrorMessage(res, 'Registration failed')) + } + const data = await res.json() + saveAuth(data) + set({ token: data.token, username: data.username, role: data.role }) + }, + + exchangeDemo: async (rawToken) => { + const res = await fetch(`${BASE_URL}/api/v1/auth/demo/${rawToken}`) + if (!res.ok) { + throw new Error(await parseErrorMessage(res, 'Invalid or expired demo link')) } const data = await res.json() - localStorage.setItem('auth_token', data.token) - localStorage.setItem('auth_username', data.username) - localStorage.setItem('auth_role', data.role) + saveAuth(data) set({ token: data.token, username: data.username, role: data.role }) }, }))