From 210b0f463c7b071d57feb33c66783be10b36f160 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 19 May 2026 14:06:54 -0500 Subject: [PATCH] feat: add About page Signed-off-by: Trezy --- frontend/src/App.svelte | 1 + .../components/dashboard/AboutContent.svelte | 247 ++++++++++++++++++ frontend/src/lib/types/api.ts | 6 + frontend/src/lib/types/routes.ts | 1 + frontend/src/locales/en.json | 47 +++- frontend/src/routes/Dashboard.svelte | 21 +- frontend/src/styles/dashboard.css | 5 + frontend/src/tests/About.test.ts | 230 ++++++++++++++++ frontend/src/tests/mocks.ts | 13 + frontend/src/vite-env.d.ts | 3 + frontend/vite.config.ts | 10 + frontend/vitest.config.ts | 5 + 12 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/dashboard/AboutContent.svelte create mode 100644 frontend/src/tests/About.test.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index db8e18d..31352f8 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -78,6 +78,7 @@ '/invite-codes', '/did-document', '/admin', + '/about', ]) function getComponent(path: string) { diff --git a/frontend/src/components/dashboard/AboutContent.svelte b/frontend/src/components/dashboard/AboutContent.svelte new file mode 100644 index 0000000..9334f3d --- /dev/null +++ b/frontend/src/components/dashboard/AboutContent.svelte @@ -0,0 +1,247 @@ + + +
+ {#if loading} +
{$_('common.loading')}
+ {:else} +
+

{$_('about.serverSection')}

+
+
+
{$_('about.serverUrl')}
+
{serverUrl}
+
{$_('about.pdsVersion')}
+
{serverInfo?.version ?? $_('about.unknown')}
+
{$_('about.serverDid')}
+
{serverInfo?.did ?? $_('about.unknown')}
+
{$_('about.availableDomains')}
+
{serverInfo?.availableUserDomains?.join(', ') ?? $_('about.unknown')}
+
{$_('about.inviteCodeRequired')}
+
{serverInfo?.inviteCodeRequired ? $_('about.yes') : $_('about.no')}
+
{$_('about.selfHostedDidWeb')}
+
{serverInfo?.selfHostedDidWebEnabled ? $_('about.enabled') : $_('about.disabled')}
+ {#if serverStats} +
{$_('about.userCount')}
+
{serverStats.userCount.toLocaleString()}
+ {/if} +
+
+
+ +
+

{$_('about.contactSection')}

+
+
+
{$_('about.contactEmail')}
+
{serverInfo?.contact?.email ?? $_('about.notConfigured')}
+
{$_('about.privacyPolicy')}
+
+ {#if serverInfo?.links?.privacyPolicy} + {serverInfo.links.privacyPolicy} + {:else} + {$_('about.notConfigured')} + {/if} +
+
{$_('about.termsOfService')}
+
+ {#if serverInfo?.links?.termsOfService} + {serverInfo.links.termsOfService} + {:else} + {$_('about.notConfigured')} + {/if} +
+
+
+
+ + {#if session.isAdmin} +
+

{$_('about.communicationSection')}

+
+
+
{$_('about.availableChannels')}
+
{serverInfo?.availableCommsChannels?.join(', ') ?? $_('about.unknown')}
+
{$_('about.discordBot')}
+
{serverInfo?.discordBotUsername ?? $_('about.notConfigured')}
+
{$_('about.discordAppId')}
+
{serverInfo?.discordAppId ?? $_('about.notConfigured')}
+
{$_('about.telegramBot')}
+
{serverInfo?.telegramBotUsername ?? $_('about.notConfigured')}
+
+
+
+ {/if} + + {#if session.isAdmin} +
+

{$_('about.frontendSection')}

+
+
+
{$_('about.svelteVersion')}
+
{svelteVersion}
+
{$_('about.svelteI18nVersion')}
+
{svelteI18nVersion}
+
{$_('about.viteVersion')}
+
{viteVersion}
+
{$_('about.buildMode')}
+
{buildMode}
+
+
+
+ {/if} + +
+

{$_('about.accountSection')}

+
+
+
{$_('about.did')}
+
{session.did}
+
{$_('about.handle')}
+
{session.handle}
+
{$_('about.accountStatus')}
+
{session.accountKind}
+
{$_('about.adminStatus')}
+
{session.isAdmin ? $_('about.yes') : $_('about.no')}
+
+
+
+ +
+

{$_('about.environmentSection')}

+
+
+
{$_('about.userAgent')}
+
{userAgent}
+
{$_('about.locale')}
+
{browserLocale}
+
{$_('about.screenSize')}
+
{screenSize}
+
+
+
+ +
+ +
+ {/if} +
+ + diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index f91adcb..a20c4c4 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -189,9 +189,15 @@ export interface ServerLinks { termsOfService?: string; } +export interface ServerContact { + email?: string; +} + export interface ServerDescription { availableUserDomains: string[]; inviteCodeRequired: boolean; + did: string; + contact?: ServerContact; links?: ServerLinks; version?: string; availableCommsChannels?: VerificationChannel[]; diff --git a/frontend/src/lib/types/routes.ts b/frontend/src/lib/types/routes.ts index d728875..0828036 100644 --- a/frontend/src/lib/types/routes.ts +++ b/frontend/src/lib/types/routes.ts @@ -14,6 +14,7 @@ export const routes = { didDocument: "/did-document", migrate: "/migrate", admin: "/admin", + about: "/about", verify: "/verify", resetPassword: "/reset-password", recoverPasskey: "/recover-passkey", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d33ff64..97bb2ad 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -145,7 +145,52 @@ "navDidDocument": "DID Document", "migrated": "Migrated", "migratedTitle": "Account Migrated", - "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations." + "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", + "navAbout": "About" + }, + "about": { + "title": "About", + "copyDebugInfo": "Copy Debug Info", + "copied": "Debug info copied to clipboard", + "copyFailed": "Failed to copy debug info", + "serverSection": "Server", + "frontendSection": "Frontend", + "accountSection": "Account", + "environmentSection": "Environment", + "serverName": "Server Name", + "pdsVersion": "PDS Version", + "serverUrl": "Server URL", + "availableDomains": "Available Domains", + "did": "DID", + "handle": "Handle", + "accountStatus": "Account Status", + "adminStatus": "Admin", + "userAgent": "User Agent", + "locale": "Locale", + "screenSize": "Screen Size", + "yes": "Yes", + "no": "No", + "unknown": "Unknown", + "notConfigured": "Not configured", + "serverDid": "Server DID", + "inviteCodeRequired": "Invite Code Required", + "selfHostedDidWeb": "Self-Hosted DID:web", + "enabled": "Enabled", + "disabled": "Disabled", + "contactSection": "Contact & Policies", + "contactEmail": "Contact Email", + "privacyPolicy": "Privacy Policy", + "termsOfService": "Terms of Service", + "communicationSection": "Communication", + "availableChannels": "Available Channels", + "discordBot": "Discord Bot", + "discordAppId": "Discord App ID", + "telegramBot": "Telegram Bot", + "svelteVersion": "Svelte", + "svelteI18nVersion": "svelte-i18n", + "viteVersion": "Vite", + "buildMode": "Build Mode", + "userCount": "User Count" }, "didEditor": { "preview": "Current DID Document", diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 787b4e0..dd28068 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -24,8 +24,9 @@ import InviteCodesContent from '../components/dashboard/InviteCodesContent.svelte' import DidDocumentContent from '../components/dashboard/DidDocumentContent.svelte' import AdminContent from '../components/dashboard/AdminContent.svelte' + import AboutContent from '../components/dashboard/AboutContent.svelte' - type Section = 'settings' | 'security' | 'sessions' | 'app-passwords' | 'comms' | 'repo' | 'controllers' | 'invite-codes' | 'did-document' | 'admin' + type Section = 'settings' | 'security' | 'sessions' | 'app-passwords' | 'comms' | 'repo' | 'controllers' | 'invite-codes' | 'did-document' | 'admin' | 'about' const auth = $derived(getAuthState()) let dropdownOpen = $state(false) @@ -75,6 +76,7 @@ '/invite-codes': 'invite-codes', '/did-document': 'did-document', '/admin': 'admin', + '/about': 'about', } return sectionMap[path] ?? null }) @@ -150,6 +152,7 @@ 'invite-codes': '/invite-codes', 'did-document': '/did-document', 'admin': '/admin', + 'about': '/about', } function selectSection(section: Section) { @@ -181,9 +184,11 @@ { id: 'admin', label: $_('dashboard.navAdmin'), show: session?.isAdmin ?? false, highlight: 'admin' }, ]) + const aboutItem = { id: 'about' as Section, label: $_('dashboard.navAbout') } const visibleNavItems = $derived(navItems.filter(item => item.show)) function getSectionTitle(section: Section): string { + if (section === 'about') return aboutItem.label const item = navItems.find(i => i.id === section) return item?.label ?? '' } @@ -276,6 +281,18 @@ {/each} + +
@@ -309,6 +326,8 @@ {:else if currentSection === 'admin'} + {:else if currentSection === 'about'} + {/if}
diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css index e7fd497..c08d77c 100644 --- a/frontend/src/styles/dashboard.css +++ b/frontend/src/styles/dashboard.css @@ -191,6 +191,11 @@ button.dropdown-item.logout-item { overflow-y: auto; } +.nav-footer { + padding: var(--space-2); + border-top: 1px solid var(--border); +} + .nav-item { display: flex; align-items: center; diff --git a/frontend/src/tests/About.test.ts b/frontend/src/tests/About.test.ts new file mode 100644 index 0000000..7084def --- /dev/null +++ b/frontend/src/tests/About.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import AboutContent from "../components/dashboard/AboutContent.svelte"; +import { + clearMocks, + jsonResponse, + mockData, + mockEndpoint, + setupAuthenticatedUser, + setupFetchMock, + setupIndexedDBMock, +} from "./mocks.ts"; + +describe("AboutContent", () => { + let session: ReturnType; + + beforeEach(() => { + clearMocks(); + setupFetchMock(); + setupIndexedDBMock(); + session = setupAuthenticatedUser(); + mockEndpoint("com.atproto.server.describeServer", () => + jsonResponse( + mockData.describeServer({ version: "0.4.59" }), + ), + ); + mockEndpoint("_server.getConfig", () => + jsonResponse({ + serverName: "Test PDS", + primaryColor: null, + primaryColorDark: null, + secondaryColor: null, + secondaryColorDark: null, + logoCid: null, + }), + ); + }); + + it("displays account information from session", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect( + screen.getByText("did:web:test.tranquil.dev:u:testuser"), + ).toBeInTheDocument(); + expect( + screen.getByText("testuser.test.tranquil.dev"), + ).toBeInTheDocument(); + }); + }); + + it("displays PDS version from server description", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("0.4.59")).toBeInTheDocument(); + }); + }); + + it("displays environment information", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText(/User Agent/i)).toBeInTheDocument(); + expect(screen.getByText(/Locale/i)).toBeInTheDocument(); + expect(screen.getByText(/Screen Size/i)).toBeInTheDocument(); + }); + }); + + it("shows section headings", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("Server")).toBeInTheDocument(); + expect(screen.getByText("Contact & Policies")).toBeInTheDocument(); + expect(screen.getByText("Account")).toBeInTheDocument(); + expect(screen.getByText("Environment")).toBeInTheDocument(); + }); + }); + + it("shows unknown when PDS version is not available", async () => { + mockEndpoint("com.atproto.server.describeServer", () => + jsonResponse(mockData.describeServer()), + ); + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); + }); + + it("has a copy debug info button", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect( + screen.getByRole("button", { name: /copy debug info/i }), + ).toBeInTheDocument(); + }); + }); + + it("copies debug info to clipboard on button click", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + + render(AboutContent, { props: { session } }); + + await waitFor(() => { + expect(screen.getByText("0.4.59")).toBeInTheDocument(); + }); + + await fireEvent.click( + screen.getByRole("button", { name: /copy debug info/i }), + ); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledOnce(); + const copied = writeText.mock.calls[0][0] as string; + expect(copied).toContain("Tranquil Debug Info"); + expect(copied).toContain("did:web:test.tranquil.dev:u:testuser"); + expect(copied).toContain("testuser.test.tranquil.dev"); + expect(copied).toContain("Server DID: did:web:test.tranquil.dev"); + expect(copied).toContain("Contact Email: admin@test.tranquil.dev"); + expect(copied).toContain("Privacy Policy: https://example.com/privacy"); + expect(copied).not.toContain("Discord Bot"); + }); + }); + + it("displays admin status correctly for admin users", async () => { + clearMocks(); + setupFetchMock(); + session = setupAuthenticatedUser({ isAdmin: true }); + mockEndpoint("com.atproto.server.describeServer", () => + jsonResponse(mockData.describeServer({ version: "0.4.59" })), + ); + mockEndpoint("_admin.getServerStats", () => + jsonResponse(mockData.serverStats()), + ); + + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("Yes")).toBeInTheDocument(); + }); + }); + + it("displays admin status correctly for non-admin users", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + const noElements = screen.getAllByText("No"); + expect(noElements.length).toBeGreaterThanOrEqual(1); + }); + }); + + it("displays server DID", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("did:web:test.tranquil.dev")).toBeInTheDocument(); + }); + }); + + it("displays invite code and DID:web status", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + const noElements = screen.getAllByText("No"); + expect(noElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + }); + }); + + it("displays contact email and policy links", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("admin@test.tranquil.dev")).toBeInTheDocument(); + const privacyLink = screen.getByRole("link", { name: "https://example.com/privacy" }); + expect(privacyLink).toHaveAttribute("href", "https://example.com/privacy"); + expect(privacyLink).toHaveAttribute("target", "_blank"); + const tosLink = screen.getByRole("link", { name: "https://example.com/tos" }); + expect(tosLink).toHaveAttribute("href", "https://example.com/tos"); + expect(tosLink).toHaveAttribute("target", "_blank"); + }); + }); + + it("displays communication channels and bot info for admins", async () => { + clearMocks(); + setupFetchMock(); + const adminSession = setupAuthenticatedUser({ isAdmin: true }); + mockEndpoint("com.atproto.server.describeServer", () => + jsonResponse(mockData.describeServer({ version: "0.4.59" })), + ); + mockEndpoint("_admin.getServerStats", () => + jsonResponse(mockData.serverStats()), + ); + + render(AboutContent, { props: { session: adminSession } }); + await waitFor(() => { + expect(screen.getByText("Communication")).toBeInTheDocument(); + expect(screen.getByText("email, discord, telegram, signal")).toBeInTheDocument(); + expect(screen.getByText("test-bot")).toBeInTheDocument(); + expect(screen.getByText("123456789")).toBeInTheDocument(); + expect(screen.getByText("test_tg_bot")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + }); + + it("hides admin-only sections for non-admins", async () => { + render(AboutContent, { props: { session } }); + await waitFor(() => { + expect(screen.getByText("Server")).toBeInTheDocument(); + }); + expect(screen.queryByText("Communication")).not.toBeInTheDocument(); + expect(screen.queryByText("Frontend")).not.toBeInTheDocument(); + }); + + it("shows 'Not configured' for missing optional fields", async () => { + mockEndpoint("com.atproto.server.describeServer", () => + jsonResponse( + mockData.describeServer({ + contact: {}, + links: {}, + discordBotUsername: undefined, + discordAppId: undefined, + telegramBotUsername: undefined, + }), + ), + ); + render(AboutContent, { props: { session } }); + await waitFor(() => { + const notConfigured = screen.getAllByText("Not configured"); + expect(notConfigured.length).toBe(3); + }); + }); +}); diff --git a/frontend/src/tests/mocks.ts b/frontend/src/tests/mocks.ts index 4d711d6..e46af7c 100644 --- a/frontend/src/tests/mocks.ts +++ b/frontend/src/tests/mocks.ts @@ -265,14 +265,27 @@ export const mockData = { describeServer: (overrides?: Record) => ({ availableUserDomains: ["test.tranquil.dev"], inviteCodeRequired: false, + did: "did:web:test.tranquil.dev", + contact: { + email: "admin@test.tranquil.dev", + }, links: { privacyPolicy: "https://example.com/privacy", termsOfService: "https://example.com/tos", }, selfHostedDidWebEnabled: true, availableCommsChannels: ["email", "discord", "telegram", "signal"], + discordBotUsername: "test-bot", + discordAppId: "123456789", + telegramBotUsername: "test_tg_bot", ...overrides, }), + serverStats: () => ({ + userCount: 42, + repoCount: 42, + recordCount: 1234, + blobStorageBytes: 5678, + }), describeRepo: (did: string) => ({ handle: "testuser.test.tranquil.dev", did, diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..1425451 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +declare const __SVELTE_VERSION__: string; +declare const __SVELTE_I18N_VERSION__: string; +declare const __VITE_VERSION__: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 855ab70..fe3a68d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,13 +1,23 @@ import process from "node:process"; +import { readFileSync } from "node:fs"; import { defineConfig, loadEnv } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +const sveltePkg = JSON.parse(readFileSync("./node_modules/svelte/package.json", "utf-8")); +const svelteI18nPkg = JSON.parse(readFileSync("./node_modules/svelte-i18n/package.json", "utf-8")); +const vitePkg = JSON.parse(readFileSync("./node_modules/vite/package.json", "utf-8")); + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const target = env.VITE_API_URL || "http://localhost:3000"; return { plugins: [svelte()], + define: { + __SVELTE_VERSION__: JSON.stringify(sveltePkg.version), + __SVELTE_I18N_VERSION__: JSON.stringify(svelteI18nPkg.version), + __VITE_VERSION__: JSON.stringify(vitePkg.version), + }, build: { outDir: "dist", }, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index df9f4ab..b6eda24 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -6,6 +6,11 @@ export default defineConfig({ hot: false, }), ], + define: { + __SVELTE_VERSION__: JSON.stringify("0.0.0-test"), + __SVELTE_I18N_VERSION__: JSON.stringify("0.0.0-test"), + __VITE_VERSION__: JSON.stringify("0.0.0-test"), + }, resolve: { conditions: ["browser", "development"], },