feat: add About page

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-05-21 09:06:42 +03:00
committed by Tangled
parent c80f504dc0
commit 210b0f463c
12 changed files with 587 additions and 2 deletions
+1
View File
@@ -78,6 +78,7 @@
'/invite-codes',
'/did-document',
'/admin',
'/about',
])
function getComponent(path: string) {
@@ -0,0 +1,247 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../../lib/i18n'
import { api } from '../../lib/api'
import { toast } from '../../lib/toast.svelte'
import type { Session, ServerDescription, ServerStats } from '../../lib/types/api'
interface Props {
session: Session
}
let { session }: Props = $props()
let serverInfo = $state<ServerDescription | null>(null)
let serverStats = $state<ServerStats | null>(null)
let loading = $state(true)
onMount(async () => {
try {
serverInfo = await api.describeServer()
} catch {
// server info is best-effort — account and environment sections still render
}
if (session.isAdmin) {
try {
serverStats = await api.getServerStats(session.accessJwt)
} catch {
// stats are best-effort
}
}
loading = false
})
const svelteVersion = __SVELTE_VERSION__
const svelteI18nVersion = __SVELTE_I18N_VERSION__
const viteVersion = __VITE_VERSION__
const buildMode = import.meta.env.MODE
const userAgent = globalThis.navigator?.userAgent ?? ''
const browserLocale = globalThis.navigator?.language ?? ''
const screenSize = $derived(
`${globalThis.innerWidth ?? 0}x${globalThis.innerHeight ?? 0}`
)
const serverUrl = globalThis.location?.origin ?? ''
async function copyDebugInfo() {
const lines = [
'Tranquil Debug Info',
'---',
`Server URL: ${serverUrl}`,
`PDS Version: ${serverInfo?.version ?? $_('about.unknown')}`,
`Server DID: ${serverInfo?.did ?? $_('about.unknown')}`,
`Available Domains: ${serverInfo?.availableUserDomains?.join(', ') ?? $_('about.unknown')}`,
`Invite Code Required: ${serverInfo?.inviteCodeRequired ? $_('about.yes') : $_('about.no')}`,
`Self-Hosted DID:web: ${serverInfo?.selfHostedDidWebEnabled ? $_('about.enabled') : $_('about.disabled')}`,
`Contact Email: ${serverInfo?.contact?.email ?? $_('about.notConfigured')}`,
`Privacy Policy: ${serverInfo?.links?.privacyPolicy ?? $_('about.notConfigured')}`,
`Terms of Service: ${serverInfo?.links?.termsOfService ?? $_('about.notConfigured')}`,
...(session.isAdmin ? [
`User Count: ${serverStats?.userCount?.toLocaleString() ?? $_('about.unknown')}`,
`Available Channels: ${serverInfo?.availableCommsChannels?.join(', ') ?? $_('about.unknown')}`,
`Discord Bot: ${serverInfo?.discordBotUsername ?? $_('about.notConfigured')}`,
`Discord App ID: ${serverInfo?.discordAppId ?? $_('about.notConfigured')}`,
`Telegram Bot: ${serverInfo?.telegramBotUsername ?? $_('about.notConfigured')}`,
`Svelte: ${svelteVersion}`,
`svelte-i18n: ${svelteI18nVersion}`,
`Vite: ${viteVersion}`,
`Build Mode: ${buildMode}`,
] : []),
`DID: ${session.did}`,
`Handle: ${session.handle}`,
`Account Status: ${session.accountKind}`,
`Admin: ${session.isAdmin ? $_('about.yes') : $_('about.no')}`,
`User Agent: ${userAgent}`,
`Locale: ${browserLocale}`,
`Screen: ${screenSize}`,
]
try {
await navigator.clipboard.writeText(lines.join('\n'))
toast.success($_('about.copied'))
} catch {
toast.error($_('about.copyFailed'))
}
}
</script>
<div class="about-page">
{#if loading}
<div class="loading">{$_('common.loading')}</div>
{:else}
<section class="about-section">
<h3>{$_('about.serverSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.serverUrl')}</dt>
<dd class="mono">{serverUrl}</dd>
<dt>{$_('about.pdsVersion')}</dt>
<dd>{serverInfo?.version ?? $_('about.unknown')}</dd>
<dt>{$_('about.serverDid')}</dt>
<dd class="mono">{serverInfo?.did ?? $_('about.unknown')}</dd>
<dt>{$_('about.availableDomains')}</dt>
<dd>{serverInfo?.availableUserDomains?.join(', ') ?? $_('about.unknown')}</dd>
<dt>{$_('about.inviteCodeRequired')}</dt>
<dd>{serverInfo?.inviteCodeRequired ? $_('about.yes') : $_('about.no')}</dd>
<dt>{$_('about.selfHostedDidWeb')}</dt>
<dd>{serverInfo?.selfHostedDidWebEnabled ? $_('about.enabled') : $_('about.disabled')}</dd>
{#if serverStats}
<dt>{$_('about.userCount')}</dt>
<dd>{serverStats.userCount.toLocaleString()}</dd>
{/if}
</dl>
</div>
</section>
<section class="about-section">
<h3>{$_('about.contactSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.contactEmail')}</dt>
<dd>{serverInfo?.contact?.email ?? $_('about.notConfigured')}</dd>
<dt>{$_('about.privacyPolicy')}</dt>
<dd>
{#if serverInfo?.links?.privacyPolicy}
<a href={serverInfo.links.privacyPolicy} target="_blank" rel="noopener noreferrer">{serverInfo.links.privacyPolicy}</a>
{:else}
{$_('about.notConfigured')}
{/if}
</dd>
<dt>{$_('about.termsOfService')}</dt>
<dd>
{#if serverInfo?.links?.termsOfService}
<a href={serverInfo.links.termsOfService} target="_blank" rel="noopener noreferrer">{serverInfo.links.termsOfService}</a>
{:else}
{$_('about.notConfigured')}
{/if}
</dd>
</dl>
</div>
</section>
{#if session.isAdmin}
<section class="about-section">
<h3>{$_('about.communicationSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.availableChannels')}</dt>
<dd>{serverInfo?.availableCommsChannels?.join(', ') ?? $_('about.unknown')}</dd>
<dt>{$_('about.discordBot')}</dt>
<dd>{serverInfo?.discordBotUsername ?? $_('about.notConfigured')}</dd>
<dt>{$_('about.discordAppId')}</dt>
<dd>{serverInfo?.discordAppId ?? $_('about.notConfigured')}</dd>
<dt>{$_('about.telegramBot')}</dt>
<dd>{serverInfo?.telegramBotUsername ?? $_('about.notConfigured')}</dd>
</dl>
</div>
</section>
{/if}
{#if session.isAdmin}
<section class="about-section">
<h3>{$_('about.frontendSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.svelteVersion')}</dt>
<dd>{svelteVersion}</dd>
<dt>{$_('about.svelteI18nVersion')}</dt>
<dd>{svelteI18nVersion}</dd>
<dt>{$_('about.viteVersion')}</dt>
<dd>{viteVersion}</dd>
<dt>{$_('about.buildMode')}</dt>
<dd>{buildMode}</dd>
</dl>
</div>
</section>
{/if}
<section class="about-section">
<h3>{$_('about.accountSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.did')}</dt>
<dd class="mono">{session.did}</dd>
<dt>{$_('about.handle')}</dt>
<dd>{session.handle}</dd>
<dt>{$_('about.accountStatus')}</dt>
<dd>{session.accountKind}</dd>
<dt>{$_('about.adminStatus')}</dt>
<dd>{session.isAdmin ? $_('about.yes') : $_('about.no')}</dd>
</dl>
</div>
</section>
<section class="about-section">
<h3>{$_('about.environmentSection')}</h3>
<div class="about-meta">
<dl>
<dt>{$_('about.userAgent')}</dt>
<dd>{userAgent}</dd>
<dt>{$_('about.locale')}</dt>
<dd>{browserLocale}</dd>
<dt>{$_('about.screenSize')}</dt>
<dd>{screenSize}</dd>
</dl>
</div>
</section>
<div class="actions">
<button type="button" onclick={copyDebugInfo}>
{$_('about.copyDebugInfo')}
</button>
</div>
{/if}
</div>
<style>
.about-page {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.about-section h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-lg);
}
.about-meta {
background: var(--bg-secondary);
padding: var(--space-4);
}
.about-meta dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2) var(--space-4);
margin: 0;
}
.about-meta dt {
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.about-meta dd {
margin: 0;
word-break: break-all;
}
</style>
+6
View File
@@ -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[];
+1
View File
@@ -14,6 +14,7 @@ export const routes = {
didDocument: "/did-document",
migrate: "/migrate",
admin: "/admin",
about: "/about",
verify: "/verify",
resetPassword: "/reset-password",
recoverPasskey: "/recover-passkey",
+46 -1
View File
@@ -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",
+20 -1
View File
@@ -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 @@
</button>
{/each}
</nav>
<div class="nav-footer">
<button
type="button"
class="nav-item"
class:active={currentSection === aboutItem.id}
onclick={() => selectSection(aboutItem.id)}
>
<span class="nav-label">{aboutItem.label}</span>
<span class="nav-chevron"></span>
</button>
</div>
</aside>
<main class="content" class:hidden-mobile={currentSection === null}>
@@ -309,6 +326,8 @@
<DidDocumentContent {session} />
{:else if currentSection === 'admin'}
<AdminContent {session} />
{:else if currentSection === 'about'}
<AboutContent {session} />
{/if}
</div>
</main>
+5
View File
@@ -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;
+230
View File
@@ -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<typeof setupAuthenticatedUser>;
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);
});
});
});
+13
View File
@@ -265,14 +265,27 @@ export const mockData = {
describeServer: (overrides?: Record<string, unknown>) => ({
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,
+3
View File
@@ -0,0 +1,3 @@
declare const __SVELTE_VERSION__: string;
declare const __SVELTE_I18N_VERSION__: string;
declare const __VITE_VERSION__: string;
+10
View File
@@ -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",
},
+5
View File
@@ -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"],
},