refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,356 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {ALL_ACLS} from '@fluxer/admin/src/AdminPackageConstants';
import {listApiKeys} from '@fluxer/admin/src/api/AdminApiKeys';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Caption, Heading, Label, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {CreateAdminApiKeyResponse, ListAdminApiKeyResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Alert} from '@fluxer/ui/src/components/Alert';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {FlashMessage} from '@fluxer/ui/src/components/Flash';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
export interface AdminApiKeysPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
createdKey: CreateAdminApiKeyResponse | undefined;
flashAfterAction: Flash | undefined;
csrfToken: string;
}
export async function AdminApiKeysPage({
config,
session,
currentAdmin,
flash,
assetVersion,
createdKey,
flashAfterAction,
csrfToken,
}: AdminApiKeysPageProps) {
const adminAcls = currentAdmin?.acls ?? [];
const hasPermissionToManage = hasPermission(adminAcls, AdminACLs.ADMIN_API_KEY_MANAGE);
if (!hasPermissionToManage) {
return (
<Layout
csrfToken={csrfToken}
title="Admin API Keys"
activePage="admin-api-keys"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<RenderAccessDenied />
</Layout>
);
}
const apiKeysResult = await listApiKeys(config, session);
const apiKeys = apiKeysResult.ok ? apiKeysResult.data : undefined;
return (
<Layout
csrfToken={csrfToken}
title="Admin API Keys"
activePage="admin-api-keys"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<RenderKeyManagement
config={config}
createdKey={createdKey}
flashAfterAction={flashAfterAction}
apiKeys={apiKeys}
adminAcls={adminAcls}
csrfToken={csrfToken}
/>
</Layout>
);
}
const RenderKeyManagement: FC<{
config: Config;
createdKey: CreateAdminApiKeyResponse | undefined;
flashAfterAction: Flash | undefined;
apiKeys: Array<ListAdminApiKeyResponse> | undefined;
adminAcls: Array<string>;
csrfToken: string;
}> = ({config, createdKey, flashAfterAction, apiKeys, adminAcls, csrfToken}) => {
return (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<RenderCreateForm config={config} createdKey={createdKey} adminAcls={adminAcls} csrfToken={csrfToken} />
<RenderFlashAfterAction flash={flashAfterAction} />
<RenderKeyListSection config={config} apiKeys={apiKeys} csrfToken={csrfToken} />
</VStack>
</PageLayout>
);
};
const RenderCreateForm: FC<{
config: Config;
createdKey: CreateAdminApiKeyResponse | undefined;
adminAcls: Array<string>;
csrfToken: string;
}> = ({config, createdKey, adminAcls, csrfToken}) => {
const createdKeyView = createdKey ? <RenderCreatedKey createdKey={createdKey} /> : null;
const availableAcls = ALL_ACLS.filter((acl) => hasPermission(adminAcls, acl));
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={1} size="2xl">
Create Admin API Key
</Heading>
{createdKeyView}
<form id="create-key-form" method="post" action={`${config.basePath}/admin-api-keys?action=create`}>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="Key Name" helper="A descriptive name to help you identify this API key.">
<Input id="api-key-name" type="text" name="name" required placeholder="Enter a descriptive name" />
</FormFieldGroup>
<VStack gap={3}>
<VStack gap={1}>
<Label>Permissions (ACLs)</Label>
<Caption>
Select the permissions to grant this API key. You can only grant permissions you have.
</Caption>
</VStack>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{availableAcls.map((acl) => (
<Checkbox name="acls[]" value={acl} label={acl} checked />
))}
</div>
</VStack>
<Button type="submit" variant="primary">
Create API Key
</Button>
</VStack>
</form>
</VStack>
</Card>
);
};
const RenderCreatedKey: FC<{createdKey: CreateAdminApiKeyResponse}> = ({createdKey}) => {
return (
<Alert variant="success">
<VStack gap={2}>
<HStack justify="between" align="center">
<Heading level={3} size="lg">
API Key Created Successfully
</Heading>
<Button type="button" variant="ghost" size="small" onclick="copyApiKey()">
Copy Key
</Button>
</HStack>
<Text size="sm" color="success">
Save this key now. You won't be able to see it again.
</Text>
<HStack gap={2} align="center" class="rounded-lg border border-green-200 bg-white p-3">
<code id="api-key-value" class="flex-1 break-all font-mono text-green-900 text-sm">
{createdKey.key}
</code>
<Caption variant="success">Key ID: {createdKey.key_id}</Caption>
</HStack>
<script
dangerouslySetInnerHTML={{
__html: `
function copyApiKey() {
const keyElement = document.getElementById('api-key-value');
const text = keyElement.innerText;
navigator.clipboard.writeText(text).then(() => {
alert('API key copied to clipboard!');
});
}
`,
}}
/>
</VStack>
</Alert>
);
};
const RenderFlashAfterAction: FC<{flash: Flash | undefined}> = ({flash}) => {
if (!flash) return null;
return <FlashMessage flash={flash} />;
};
const RenderKeyListSection: FC<{
config: Config;
apiKeys: Array<ListAdminApiKeyResponse> | undefined;
csrfToken: string;
}> = ({config, apiKeys, csrfToken}) => {
if (apiKeys === undefined) {
return (
<VStack gap={3}>
<EmptyState title="Loading API keys..." />
</VStack>
);
}
if (apiKeys.length === 0) {
return <RenderEmptyState />;
}
return <RenderApiKeysList config={config} keys={apiKeys} csrfToken={csrfToken} />;
};
const RenderApiKeysList: FC<{config: Config; keys: Array<ListAdminApiKeyResponse>; csrfToken: string}> = ({
config,
keys,
csrfToken,
}) => {
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Existing API Keys
</Heading>
<VStack gap={3}>
{keys.map((key) => (
<RenderApiKeyItem config={config} apiKey={key} csrfToken={csrfToken} />
))}
</VStack>
</VStack>
</Card>
);
};
const RenderApiKeyItem: FC<{config: Config; apiKey: ListAdminApiKeyResponse; csrfToken: string}> = ({
config,
apiKey,
csrfToken,
}) => {
const formattedCreated = formatTimestamp(apiKey.created_at);
const formattedLastUsed = apiKey.last_used_at ? formatTimestamp(apiKey.last_used_at) : 'Never used';
const formattedExpires = apiKey.expires_at ? formatTimestamp(apiKey.expires_at) : 'Never expires';
return (
<Card padding="sm" class="border border-neutral-200">
<HStack justify="between" align="start">
<VStack gap={1} class="flex-1">
<Heading level={3} size="lg">
{apiKey.name}
</Heading>
<Text size="sm" color="muted">
Key ID: {apiKey.key_id}
</Text>
<Text size="sm" color="muted">
Created: {formattedCreated}
</Text>
<Text size="sm" color="muted">
Last used: {formattedLastUsed}
</Text>
<Text size="sm" color="muted">
Expires: {formattedExpires}
</Text>
<RenderAclsList acls={apiKey.acls} />
</VStack>
<form method="post" action={`${config.basePath}/admin-api-keys?action=revoke`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="key_id" value={apiKey.key_id} />
<Button type="submit" variant="danger" size="small">
Revoke
</Button>
</form>
</HStack>
</Card>
);
};
const RenderAclsList: FC<{acls: Array<string>}> = ({acls}) => {
if (acls.length === 0) return null;
return (
<VStack gap={1} class="mt-2">
<Text size="xs" weight="medium" color="muted">
Permissions:
</Text>
<div class="flex flex-wrap gap-1">
{acls.map((acl) => (
<Badge size="sm" variant="neutral">
{acl}
</Badge>
))}
</div>
</VStack>
);
};
const RenderEmptyState: FC = () => {
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Existing API Keys
</Heading>
<EmptyState title="No API keys found. Create one to get started." />
</VStack>
</Card>
);
};
const RenderAccessDenied: FC = () => {
return (
<Card padding="md">
<VStack gap={2}>
<Heading level={1} size="2xl">
Admin API Keys
</Heading>
<Text size="sm" color="muted">
You do not have permission to manage admin API keys.
</Text>
</VStack>
</Card>
);
};
function formatTimestamp(timestamp: string): string {
return timestamp;
}

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {Archive} from '@fluxer/admin/src/api/Archives';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow} from '@fluxer/ui/src/components/Table';
import type {FC} from 'hono/jsx';
export interface ArchivesPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
csrfToken: string;
subjectType: string;
subjectId: string | undefined;
archives: Array<Archive>;
error: string | undefined;
assetVersion: string;
}
function formatTimestampLocal(timestamp: string): string {
try {
return formatTimestamp(timestamp, 'en-US');
} catch {
return timestamp;
}
}
function getStatusLabel(archive: Archive): string {
if (archive.failed_at) {
return 'Failed';
}
if (archive.completed_at) {
return 'Completed';
}
return 'In Progress';
}
const ArchiveTable: FC<{archives: Array<Archive>; config: Config}> = ({archives, config}) => {
return (
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<Table>
<TableHead>
<tr>
<TableHeaderCell label="Subject" />
<TableHeaderCell label="Requested By" />
<TableHeaderCell label="Requested At" />
<TableHeaderCell label="Status" />
<TableHeaderCell label="Actions" />
</tr>
</TableHead>
<TableBody>
{archives.map((archive) => (
<TableRow>
<TableCell>
<VStack gap={0} class="whitespace-nowrap">
<Text weight="semibold" size="sm">
{archive.subject_type} {archive.subject_id}
</Text>
<Text size="xs" color="muted">
Archive ID: {archive.archive_id}
</Text>
</VStack>
</TableCell>
<TableCell>
<Text size="sm">{archive.requested_by}</Text>
</TableCell>
<TableCell>
<Text size="sm">{formatTimestampLocal(archive.requested_at)}</Text>
</TableCell>
<TableCell>
<VStack gap={1}>
<div class="flex items-center gap-2">
<Badge size="sm" variant="neutral">
{getStatusLabel(archive)}
</Badge>
<Text size="xs" color="muted">
{archive.progress_percent}%
</Text>
</div>
{archive.progress_step && !archive.completed_at && !archive.failed_at && (
<Text size="xs" color="muted">
{archive.progress_step}
</Text>
)}
</VStack>
</TableCell>
<TableCell>
{archive.completed_at ? (
<Button
href={`${config.basePath}/archives/download?subject_type=${archive.subject_type}&subject_id=${archive.subject_id}&archive_id=${archive.archive_id}`}
variant="primary"
size="small"
>
Download
</Button>
) : (
<Text size="sm" color="muted">
Not ready
</Text>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
const ArchivesEmptyState: FC<{filterHint: string}> = ({filterHint}) => {
return (
<EmptyState
title={`No archives found${filterHint}.`}
message="This page lists all user and guild archives you've requested. Request an archive from a user or guild detail page."
/>
);
};
export async function ArchivesPage({
config,
session,
currentAdmin,
flash,
csrfToken,
subjectType,
subjectId,
archives,
error,
assetVersion,
}: ArchivesPageProps) {
const filterHint = subjectId ? ` for ${subjectType} ${subjectId}` : '';
return (
<Layout
csrfToken={csrfToken}
title="Archives"
activePage="archives"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<PageLayout maxWidth="7xl">
<VStack gap={4}>
<PageHeader title={`Archives${filterHint}`} />
{error ? (
<ErrorAlert error={error} />
) : archives.length === 0 ? (
<ArchivesEmptyState filterHint={filterHint} />
) : (
<ArchiveTable archives={archives} config={config} />
)}
</VStack>
</PageLayout>
</Layout>
);
}

View File

@@ -0,0 +1,212 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {
PurgeGuildAssetError,
PurgeGuildAssetResult,
PurgeGuildAssetsResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
export interface AssetPurgePageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
result?: PurgeGuildAssetsResponse;
assetVersion: string;
csrfToken: string;
}
function hasPermission(acls: Array<string>, permission: string): boolean {
return acls.includes(permission) || acls.includes('*');
}
const PurgeForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => {
return (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Purge Assets
</Heading>
<Text color="muted" size="sm" class="mb-4">
Enter the emoji or sticker IDs that should be removed from S3 and CDN caches.
</Text>
<form method="post" action={`${config.basePath}/asset-purge?action=purge-assets`}>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="IDs" helper="Separate multiple IDs with commas or line breaks.">
<Textarea
id="asset-purge-ids"
name="asset_ids"
required
placeholder={'123456789012345678\n876543210987654321'}
rows={4}
size="sm"
/>
</FormFieldGroup>
<FormFieldGroup label="Audit Log Reason (optional)">
<Input
id="asset-purge-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="DMCA takedown request"
size="sm"
/>
</FormFieldGroup>
<Button type="submit" variant="danger">
Purge Assets
</Button>
</VStack>
</form>
</Card>
);
};
const PermissionNotice: FC = () => {
return (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Permission required
</Heading>
<Text color="muted" size="sm">
You need the asset:purge ACL to use this tool.
</Text>
</Card>
);
};
const ProcessedTable: FC<{items: Array<PurgeGuildAssetResult>}> = ({items}) => {
return (
<VStack gap={0} class="overflow-x-auto rounded-lg border border-neutral-200">
<table class="min-w-full text-left text-neutral-700 text-sm">
<thead class="bg-neutral-50 text-neutral-500 text-xs uppercase">
<tr>
<th class="px-4 py-2 font-medium">ID</th>
<th class="px-4 py-2 font-medium">Type</th>
<th class="px-4 py-2 font-medium">In DB</th>
<th class="px-4 py-2 font-medium">Guild ID</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr class="border-neutral-100 border-t">
<td class="break-words px-4 py-3">{item.id}</td>
<td class="px-4 py-3">{item.asset_type}</td>
<td class="px-4 py-3">{item.found_in_db ? 'Yes' : 'No'}</td>
<td class="px-4 py-3">{item.guild_id ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</VStack>
);
};
const ErrorsList: FC<{errors: Array<PurgeGuildAssetError>}> = ({errors}) => {
return (
<VStack gap={2} class="mt-4">
{errors.map((err) => (
<Text color="danger" size="sm">
{err.id}: {err.error}
</Text>
))}
</VStack>
);
};
const PurgeResult: FC<{result: PurgeGuildAssetsResponse}> = ({result}) => {
return (
<VStack gap={4}>
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Purge Result
</Heading>
<Text color="muted" size="sm" class="mb-4">
Processed {result.processed.length} ID(s); {result.errors.length} error(s).
</Text>
<ProcessedTable items={result.processed} />
{result.errors.length > 0 && <ErrorsList errors={result.errors} />}
</Card>
</VStack>
);
};
export async function AssetPurgePage({
config,
session,
currentAdmin,
flash,
result,
assetVersion,
csrfToken,
}: AssetPurgePageProps) {
const hasAssetPurgePermission = currentAdmin ? hasPermission(currentAdmin.acls, AdminACLs.ASSET_PURGE) : false;
return (
<Layout
csrfToken={csrfToken}
title="Asset Purge"
activePage="asset-purge"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<VStack gap={6}>
<VStack gap={2}>
<Heading level={1}>Asset Purge</Heading>
<Text color="muted" size="sm">
Purge emojis or stickers from the storage and CDN. Provide one or more IDs. Separate multiple IDs with
commas.
</Text>
</VStack>
{result && <PurgeResult result={result} />}
{hasAssetPurgePermission ? <PurgeForm config={config} csrfToken={csrfToken} /> : <PermissionNotice />}
</VStack>
</Layout>
);
}
export function parseAssetIds(input: string): Array<string> {
const normalized = input.replace(/\n/g, ',').replace(/\r/g, ',');
return normalized
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0);
}

View File

@@ -0,0 +1,501 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {searchAuditLogs} from '@fluxer/admin/src/api/Audit';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {lookupUsersByIds} from '@fluxer/admin/src/api/Users';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {ResourceLink} from '@fluxer/admin/src/components/ui/ResourceLink';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {AdminAuditLogResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Pill} from '@fluxer/ui/src/components/Badge';
import {Button} from '@fluxer/ui/src/components/Button';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {Pagination} from '@fluxer/ui/src/components/Pagination';
import {type SearchField, SearchForm} from '@fluxer/ui/src/components/SearchForm';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeaderCell,
TableRow,
} from '@fluxer/ui/src/components/Table';
import type {ColorTone} from '@fluxer/ui/src/utils/ColorVariants';
import {formatUserTag} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
type AuditLog = z.infer<typeof AdminAuditLogResponseSchema>;
interface AuditLogsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
csrfToken: string;
query: string | undefined;
adminUserIdFilter: string | undefined;
targetId: string | undefined;
currentPage: number;
assetVersion: string;
}
interface UserMap {
[userId: string]: UserAdminResponse;
}
function formatTimestampLocal(timestamp: string): string {
return formatTimestamp(timestamp, 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
}
function getActionTone(action: string): ColorTone {
switch (action) {
case 'temp_ban':
case 'disable_suspicious_activity':
case 'schedule_deletion':
case 'ban_ip':
case 'ban_email':
case 'ban_phone':
return 'danger';
case 'unban':
case 'cancel_deletion':
case 'unban_ip':
case 'unban_email':
case 'unban_phone':
return 'success';
case 'update_flags':
case 'update_features':
case 'set_acls':
case 'update_settings':
return 'info';
case 'delete_message':
return 'orange';
default:
return 'neutral';
}
}
function capitalise(s: string): string {
if (s.length === 0) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
const FiltersSection: FC<{
config: Config;
query: string | undefined;
adminUserIdFilter: string | undefined;
targetId: string | undefined;
}> = ({config, query, adminUserIdFilter, targetId}) => {
const fields: Array<SearchField> = [
{
name: 'q',
type: 'text',
label: 'Search',
placeholder: 'Search by action, reason, or metadata...',
value: query ?? '',
},
{
name: 'target_id',
type: 'text',
label: 'Target ID',
placeholder: 'Filter by target ID...',
value: targetId ?? '',
},
{
name: 'admin_user_id',
type: 'text',
label: 'Admin User ID',
placeholder: 'Filter by admin user ID...',
value: adminUserIdFilter ?? '',
},
];
return (
<SearchForm
action="/audit-logs"
method="get"
fields={fields}
submitLabel="Search"
showClear={true}
clearHref="/audit-logs"
clearLabel="Clear"
layout="vertical"
padding="sm"
basePath={config.basePath}
/>
);
};
const AdminCell: FC<{config: Config; admin_user_id: string; userMap: UserMap}> = ({config, admin_user_id, userMap}) => {
if (admin_user_id === '') {
return (
<TableCell>
<Text size="sm" color="muted" class="italic">
-
</Text>
</TableCell>
);
}
const user = userMap[admin_user_id];
return (
<TableCell>
<ResourceLink config={config} resourceType="user" resourceId={admin_user_id}>
<VStack gap={0}>
<Text size="sm" weight="medium">
{user ? formatUserTag(user.username, user.discriminator) : 'Unknown User'}
</Text>
<Text size="xs" color="muted">
{admin_user_id}
</Text>
</VStack>
</ResourceLink>
</TableCell>
);
};
const TargetCell: FC<{config: Config; target_type: string; target_id: string; userMap: UserMap}> = ({
config,
target_type,
target_id,
userMap,
}) => {
if (target_type === 'user') {
const user = userMap[target_id];
return (
<TableCell>
<ResourceLink config={config} resourceType="user" resourceId={target_id}>
<VStack gap={0}>
<Text size="sm" weight="medium">
{user ? formatUserTag(user.username, user.discriminator) : 'Unknown User'}
</Text>
<Text size="xs" color="muted">
{target_id}
</Text>
</VStack>
</ResourceLink>
</TableCell>
);
}
if (target_type === 'guild') {
return (
<TableCell>
<ResourceLink config={config} resourceType="guild" resourceId={target_id}>
<VStack gap={0}>
<Text size="sm" weight="medium">
Guild
</Text>
<Text size="xs" color="muted">
{target_id}
</Text>
</VStack>
</ResourceLink>
</TableCell>
);
}
return (
<TableCell>
<VStack gap={0}>
<Text size="sm" weight="medium">
{capitalise(target_type)}
</Text>
<Text size="xs" color="muted">
{target_id}
</Text>
</VStack>
</TableCell>
);
};
const DetailsExpanded: FC<{reason: string | null; metadata: Record<string, string>}> = ({reason, metadata}) => {
const entries = Object.entries(metadata);
const hasContent = reason || entries.length > 0;
if (!hasContent) {
return (
<Text size="sm" color="muted" class="italic">
No additional details
</Text>
);
}
return (
<VStack gap={3}>
{reason && (
<HStack gap={2} align="start">
<Text size="sm" weight="semibold" class="min-w-[120px]">
Reason
</Text>
<Text size="sm" color="muted">
{reason}
</Text>
</HStack>
)}
{entries.map(([key, value]) => (
<HStack gap={2} align="start">
<Text size="sm" weight="semibold" class="min-w-[120px]">
{key}
</Text>
<Text size="sm" color="muted">
{value}
</Text>
</HStack>
))}
</VStack>
);
};
const LogRow: FC<{config: Config; log: AuditLog; userMap: UserMap}> = ({config, log, userMap}) => {
const expandedId = `expanded-${log.log_id}`;
const hasDetails = log.audit_log_reason || Object.keys(log.metadata).length > 0;
return (
<>
<TableRow>
<TableCell muted>
<Text size="sm" class="whitespace-nowrap">
{formatTimestampLocal(log.created_at)}
</Text>
</TableCell>
<TableCell>
<Pill label={formatAction(log.action)} tone={getActionTone(log.action)} />
</TableCell>
<AdminCell config={config} admin_user_id={log.admin_user_id} userMap={userMap} />
<TargetCell config={config} target_type={log.target_type} target_id={log.target_id} userMap={userMap} />
<TableCell muted>
{hasDetails ? (
<Button
type="button"
variant="ghost"
size="small"
onclick={`document.getElementById('${expandedId}').classList.toggle('hidden')`}
>
Show details
</Button>
) : (
<Text size="sm" color="muted" class="italic">
-
</Text>
)}
</TableCell>
</TableRow>
{hasDetails && (
<tr id={expandedId} class="hidden bg-neutral-50">
<td colspan={5} class="px-6 py-4">
<DetailsExpanded reason={log.audit_log_reason} metadata={log.metadata} />
</td>
</tr>
)}
</>
);
};
const LogsTable: FC<{config: Config; logs: Array<AuditLog>; userMap: UserMap}> = ({config, logs, userMap}) => (
<TableContainer>
<Table>
<TableHead>
<tr>
<TableHeaderCell label="Timestamp" />
<TableHeaderCell label="Action" />
<TableHeaderCell label="Admin" />
<TableHeaderCell label="Target" />
<TableHeaderCell label="Details" />
</tr>
</TableHead>
<TableBody>
{logs.map((log) => (
<LogRow config={config} log={log} userMap={userMap} />
))}
</TableBody>
</Table>
</TableContainer>
);
const AuditLogsPagination: FC<{
config: Config;
currentPage: number;
totalPages: number;
query: string | undefined;
adminUserIdFilter: string | undefined;
targetId: string | undefined;
}> = ({config, currentPage, totalPages, query, adminUserIdFilter, targetId}) => {
const buildUrl = (page: number) => {
return `/audit-logs${buildPaginationUrl(page, {
q: query,
admin_user_id: adminUserIdFilter,
target_id: targetId,
})}`;
};
return (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
buildUrlFn={buildUrl}
basePath={config.basePath}
previousLabel="Previous"
nextLabel="Next"
pageInfo={`Page ${currentPage + 1} of ${totalPages}`}
/>
);
};
const AuditLogsEmptyState: FC = () => (
<EmptyState title="No audit logs found" message="Try adjusting your filters or check back later" />
);
function collectUserIds(logs: Array<AuditLog>): Array<string> {
const ids = new Set<string>();
for (const log of logs) {
if (log.admin_user_id !== '') {
ids.add(log.admin_user_id);
}
if (log.target_type === 'user' && log.target_id !== '') {
ids.add(log.target_id);
}
}
return Array.from(ids);
}
export async function AuditLogsPage({
config,
session,
currentAdmin,
flash,
csrfToken,
query,
adminUserIdFilter,
targetId,
currentPage,
assetVersion,
}: AuditLogsPageProps) {
const limit = 50;
const offset = currentPage * limit;
const result = await searchAuditLogs(config, session, {
query,
admin_user_id: adminUserIdFilter,
target_id: targetId,
limit,
offset,
});
const userMap: UserMap = {};
if (result.ok && result.data.logs.length > 0) {
const userIds = collectUserIds(result.data.logs);
if (userIds.length > 0) {
const usersResult = await lookupUsersByIds(config, session, userIds);
if (usersResult.ok) {
for (const user of usersResult.data) {
userMap[user.id] = user;
}
}
}
}
const content = result.ok ? (
(() => {
const totalPages = Math.ceil(result.data.total / limit);
return (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<PageHeader
title="Audit Logs"
actions={
<Text size="sm" color="muted">
Showing {result.data.logs.length} of {result.data.total} entries
</Text>
}
/>
<FiltersSection config={config} query={query} adminUserIdFilter={adminUserIdFilter} targetId={targetId} />
{result.data.logs.length === 0 ? (
<AuditLogsEmptyState />
) : (
<LogsTable config={config} logs={result.data.logs} userMap={userMap} />
)}
{result.data.total > limit && (
<AuditLogsPagination
config={config}
currentPage={currentPage}
totalPages={totalPages}
query={query}
adminUserIdFilter={adminUserIdFilter}
targetId={targetId}
/>
)}
</VStack>
</PageLayout>
);
})()
) : (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<PageHeader title="Audit Logs" />
<ErrorAlert error={getErrorMessage(result.error)} />
</VStack>
</PageLayout>
);
return (
<Layout
csrfToken={csrfToken}
title="Audit Logs"
activePage="audit-logs"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{content}
</Layout>
);
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Input} from '@fluxer/ui/src/components/Form';
import {Grid, Stack} from '@fluxer/ui/src/components/Layout';
import type {FC} from 'hono/jsx';
export type BanType = 'ip' | 'email' | 'phone';
interface BanConfig {
title: string;
route: string;
inputLabel: string;
inputName: string;
inputType: 'text' | 'email' | 'tel';
placeholder: string;
entityName: string;
activePage: string;
}
export function getBanConfig(banType: BanType): BanConfig {
switch (banType) {
case 'ip':
return {
title: 'IP Bans',
route: '/ip-bans',
inputLabel: 'IP Address or CIDR',
inputName: 'ip',
inputType: 'text',
placeholder: '192.168.1.1 or 192.168.0.0/16',
entityName: 'IP/CIDR',
activePage: 'ip-bans',
};
case 'email':
return {
title: 'Email Bans',
route: '/email-bans',
inputLabel: 'Email Address',
inputName: 'email',
inputType: 'email',
placeholder: 'user@example.com',
entityName: 'Email',
activePage: 'email-bans',
};
case 'phone':
return {
title: 'Phone Bans',
route: '/phone-bans',
inputLabel: 'Phone Number',
inputName: 'phone',
inputType: 'tel',
placeholder: '+1234567890',
entityName: 'Phone',
activePage: 'phone-bans',
};
}
}
export interface BanManagementPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
banType: BanType;
csrfToken: string;
listResult?: {ok: true; bans: Array<{value: string; reverseDns?: string | null}>} | {ok: false; errorMessage: string};
}
const BanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({config, banConfig, csrfToken}) => {
return (
<Card padding="md">
<Stack gap="4">
<Heading level={3} size="base">
Ban {banConfig.inputLabel}
</Heading>
<form method="post" action={`${config.basePath}${banConfig.route}?action=ban`}>
<CsrfInput token={csrfToken} />
<Stack gap="4">
<Input
label={banConfig.inputLabel}
name={banConfig.inputName}
type={banConfig.inputType}
required={true}
placeholder={banConfig.placeholder}
/>
<Input
label="Private reason (audit log, optional)"
name="audit_log_reason"
type="text"
required={false}
placeholder="Why is this ban being applied?"
/>
<Button type="submit" variant="primary">
Ban {banConfig.entityName}
</Button>
</Stack>
</form>
</Stack>
</Card>
);
};
const CheckBanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({
config,
banConfig,
csrfToken,
}) => {
return (
<Card padding="md">
<Stack gap="4">
<Heading level={3} size="base">
Check {banConfig.inputLabel} Ban Status
</Heading>
<form method="post" action={`${config.basePath}${banConfig.route}?action=check`}>
<CsrfInput token={csrfToken} />
<Stack gap="4">
<Input
label={banConfig.inputLabel}
name={banConfig.inputName}
type={banConfig.inputType}
required={true}
placeholder={banConfig.placeholder}
/>
<Button type="submit" variant="primary">
Check Status
</Button>
</Stack>
</form>
</Stack>
</Card>
);
};
const UnbanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({config, banConfig, csrfToken}) => {
return (
<Card padding="md">
<Stack gap="4">
<Heading level={3} size="base">
Remove {banConfig.inputLabel} Ban
</Heading>
<form method="post" action={`${config.basePath}${banConfig.route}?action=unban`}>
<CsrfInput token={csrfToken} />
<Stack gap="4">
<Input
label={banConfig.inputLabel}
name={banConfig.inputName}
type={banConfig.inputType}
required={true}
placeholder={banConfig.placeholder}
/>
<Input
label="Private reason (audit log, optional)"
name="audit_log_reason"
type="text"
required={false}
placeholder="Why is this ban being removed?"
/>
<Button type="submit" variant="danger">
Unban {banConfig.entityName}
</Button>
</Stack>
</form>
</Stack>
</Card>
);
};
const BanListCard: FC<{
config: Config;
banType: BanType;
banConfig: BanConfig;
listResult: BanManagementPageProps['listResult'];
csrfToken: string;
}> = ({config, banType, banConfig, listResult, csrfToken}) => {
if (!listResult) return null;
return (
<Card padding="md">
<Stack gap="4">
<Heading level={3} size="base">
Current bans
</Heading>
{!listResult.ok ? (
<Text size="sm" color="muted">
Failed to load bans list: {listResult.errorMessage}
</Text>
) : listResult.bans.length === 0 ? (
<Text size="sm" color="muted">
No {banConfig.entityName.toLowerCase()} bans found
</Text>
) : (
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-neutral-200 border-b text-left text-neutral-600">
<th class="px-3 py-2">{banConfig.inputLabel}</th>
{banType === 'ip' && <th class="px-3 py-2">Reverse DNS</th>}
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{listResult.bans.map((ban) => (
<tr class="border-neutral-200 border-b">
<td class="px-3 py-2">
<span class="font-mono">{ban.value}</span>
<a
href={`${config.basePath}/users?q=${encodeURIComponent(ban.value)}`}
class="ml-2 text-blue-600 text-xs no-underline hover:underline"
>
Search users
</a>
</td>
{banType === 'ip' && <td class="px-3 py-2 text-neutral-700">{ban.reverseDns ?? '—'}</td>}
<td class="px-3 py-2">
<form
method="post"
action={`${config.basePath}${banConfig.route}?action=unban`}
onsubmit={`return confirm('Unban ${banConfig.entityName} ${ban.value}?')`}
>
<CsrfInput token={csrfToken} />
<input type="hidden" name={banConfig.inputName} value={ban.value} />
<Button type="submit" variant="danger" size="small">
Unban
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Stack>
</Card>
);
};
export const BanManagementPage: FC<BanManagementPageProps> = ({
config,
session,
currentAdmin,
flash,
assetVersion,
banType,
csrfToken,
listResult,
}) => {
const banConfig = getBanConfig(banType);
return (
<Layout
csrfToken={csrfToken}
title={banConfig.title}
activePage={banConfig.activePage}
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<Stack gap="6">
<Heading level={1}>{banConfig.title}</Heading>
<Grid cols="2" gap="6">
<BanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
<CheckBanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
</Grid>
<UnbanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
<BanListCard
config={config}
banType={banType}
banConfig={banConfig}
listResult={listResult}
csrfToken={csrfToken}
/>
</Stack>
</Layout>
);
};

View File

@@ -0,0 +1,426 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {PATCHABLE_FLAGS} from '@fluxer/admin/src/AdminPackageConstants';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Select} from '@fluxer/admin/src/components/ui/Select';
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {BulkOperationResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
type BulkOperationResponseType = z.infer<typeof BulkOperationResponse>;
interface GuildFeature {
value: string;
}
const GUILD_FEATURES: Array<GuildFeature> = [
{value: 'ANIMATED_ICON'},
{value: 'ANIMATED_BANNER'},
{value: 'BANNER'},
{value: 'INVITE_SPLASH'},
{value: 'INVITES_DISABLED'},
{value: 'MORE_EMOJI'},
{value: 'MORE_STICKERS'},
{value: 'UNLIMITED_EMOJI'},
{value: 'UNLIMITED_STICKERS'},
{value: 'TEXT_CHANNEL_FLEXIBLE_NAMES'},
{value: 'UNAVAILABLE_FOR_EVERYONE'},
{value: 'UNAVAILABLE_FOR_EVERYONE_BUT_STAFF'},
{value: 'VANITY_URL'},
{value: 'VERIFIED'},
{value: 'VIP_VOICE'},
{value: 'DETACHED_BANNER'},
{value: 'EXPRESSION_PURGE_ALLOWED'},
{value: 'LARGE_GUILD_OVERRIDE'},
{value: 'VERY_LARGE_GUILD'},
];
const DELETION_REASONS: Array<[number, string]> = [
[1, 'User requested'],
[2, 'Other'],
[3, 'Spam'],
[4, 'Cheating or exploitation'],
[5, 'Coordinated raiding or manipulation'],
[6, 'Automation or self-bot usage'],
[7, 'Nonconsensual sexual content'],
[8, 'Scam or social engineering'],
[9, 'Child sexual content'],
[10, 'Privacy violation or doxxing'],
[11, 'Harassment or bullying'],
[12, 'Payment fraud'],
[13, 'Child safety violation'],
[14, 'Billing dispute or abuse'],
[15, 'Unsolicited explicit content'],
[16, 'Graphic violence'],
[17, 'Ban evasion'],
[18, 'Token or credential scam'],
[19, 'Inactivity'],
[20, 'Hate speech or extremist content'],
[21, 'Malicious links or malware distribution'],
[22, 'Impersonation or fake identity'],
];
export interface BulkActionsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
adminAcls: Array<string>;
result?: BulkOperationResponseType;
csrfToken: string;
}
function hasPermission(acls: Array<string>, permission: string): boolean {
return acls.includes('*') || acls.includes(permission);
}
const OperationResult: FC<{response: BulkOperationResponseType}> = ({response}) => {
const successCount = response.successful.length;
const failCount = response.failed.length;
return (
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-6">
<Heading level={3} size="base" class="mb-4">
Operation Result
</Heading>
<VStack gap={3}>
<Text size="sm">
<span class="font-medium text-green-600 text-sm">Successful: </span>
{successCount}
</Text>
<Text size="sm">
<span class="font-medium text-red-600 text-sm">Failed: </span>
{failCount}
</Text>
{response.failed.length > 0 && (
<div class="mt-4">
<Heading level={3} size="sm" class="mb-2">
Errors:
</Heading>
<ul>
<VStack gap={1}>
{response.failed.map((error) => (
<li>
<Text color="danger" size="sm">
{error.id}: {error.error}
</Text>
</li>
))}
</VStack>
</ul>
</div>
)}
</VStack>
</div>
);
};
const BulkUpdateUserFlags: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Bulk Update User Flags
</Heading>
<form method="post" action={`${basePath}/bulk-actions?action=bulk-update-user-flags`}>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="User IDs (one per line)">
<Textarea
id="bulk-user-flags-user-ids"
name="user_ids"
placeholder={'123456789\n987654321'}
required
rows={5}
/>
</FormFieldGroup>
<div>
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
Flags to Add
</Text>
<div class="grid grid-cols-2 gap-3">
{PATCHABLE_FLAGS.map((flag) => (
<Checkbox name="add_flags[]" value={flag.value.toString()} label={flag.name} />
))}
</div>
</div>
<div>
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
Flags to Remove
</Text>
<div class="grid grid-cols-2 gap-3">
{PATCHABLE_FLAGS.map((flag) => (
<Checkbox name="remove_flags[]" value={flag.value.toString()} label={flag.name} />
))}
</div>
</div>
<FormFieldGroup label="Audit Log Reason (optional)">
<Input
id="bulk-user-flags-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="Reason for this bulk operation"
/>
</FormFieldGroup>
<Button type="submit" variant="primary" size="medium">
Update User Flags
</Button>
</VStack>
</form>
</Card>
);
const BulkUpdateGuildFeatures: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Bulk Update Guild Features
</Heading>
<form method="post" action={`${basePath}/bulk-actions?action=bulk-update-guild-features`}>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="Guild IDs (one per line)">
<Textarea
id="bulk-guild-features-guild-ids"
name="guild_ids"
placeholder={'123456789\n987654321'}
required
rows={5}
/>
</FormFieldGroup>
<div>
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
Features to Add
</Text>
<div class="grid grid-cols-2 gap-3">
{GUILD_FEATURES.map((feature) => (
<Checkbox name="add_features[]" value={feature.value} label={feature.value} />
))}
</div>
<FormFieldGroup label="Custom features" htmlFor="bulk-guild-features-custom-add-features" class="mt-3">
<Input
id="bulk-guild-features-custom-add-features"
type="text"
name="custom_add_features"
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
/>
</FormFieldGroup>
</div>
<div>
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
Features to Remove
</Text>
<div class="grid grid-cols-2 gap-3">
{GUILD_FEATURES.map((feature) => (
<Checkbox name="remove_features[]" value={feature.value} label={feature.value} />
))}
</div>
<FormFieldGroup label="Custom features" htmlFor="bulk-guild-features-custom-remove-features" class="mt-3">
<Input
id="bulk-guild-features-custom-remove-features"
type="text"
name="custom_remove_features"
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
/>
</FormFieldGroup>
</div>
<FormFieldGroup label="Audit Log Reason (optional)">
<Input
id="bulk-guild-features-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="Reason for this bulk operation"
/>
</FormFieldGroup>
<Button type="submit" variant="primary" size="medium">
Update Guild Features
</Button>
</VStack>
</form>
</Card>
);
const BulkAddGuildMembers: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Bulk Add Guild Members
</Heading>
<form method="post" action={`${basePath}/bulk-actions?action=bulk-add-guild-members`}>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="Guild ID">
<Input id="bulk-add-guild-members-guild-id" type="text" name="guild_id" placeholder="123456789" required />
</FormFieldGroup>
<FormFieldGroup label="User IDs (one per line)">
<Textarea
id="bulk-add-guild-members-user-ids"
name="user_ids"
placeholder={'123456789\n987654321'}
required
rows={5}
/>
</FormFieldGroup>
<FormFieldGroup label="Audit Log Reason (optional)">
<Input
id="bulk-add-guild-members-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="Reason for this bulk operation"
/>
</FormFieldGroup>
<Button type="submit" variant="primary" size="medium">
Add Members
</Button>
</VStack>
</form>
</Card>
);
const BULK_DELETION_DAYS_SCRIPT = `
(function () {
var reasonSelect = document.getElementById('bulk-deletion-reason');
var daysInput = document.getElementById('bulk-deletion-days');
if (!reasonSelect || !daysInput) return;
reasonSelect.addEventListener('change', function () {
var reason = parseInt(this.value, 10);
if (reason === 9 || reason === 13) {
daysInput.value = '0';
daysInput.min = '0';
} else {
if (parseInt(daysInput.value, 10) < 14) {
daysInput.value = '14';
}
daysInput.min = '14';
}
});
})();
`;
const BulkScheduleUserDeletion: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
Bulk Schedule User Deletion
</Heading>
<form
method="post"
action={`${basePath}/bulk-actions?action=bulk-schedule-user-deletion`}
onsubmit="return confirm('Are you sure you want to schedule these users for deletion?')"
>
<VStack gap={4}>
<CsrfInput token={csrfToken} />
<FormFieldGroup label="User IDs (one per line)">
<Textarea
id="bulk-user-deletion-user-ids"
name="user_ids"
placeholder={'123456789\n987654321'}
required
rows={5}
/>
</FormFieldGroup>
<FormFieldGroup label="Deletion Reason">
<Select
id="bulk-deletion-reason"
name="reason_code"
required
options={DELETION_REASONS.map(([code, label]) => ({value: String(code), label}))}
/>
</FormFieldGroup>
<FormFieldGroup label="Public Reason (optional)">
<Input
id="bulk-user-deletion-public-reason"
type="text"
name="public_reason"
placeholder="Terms of service violation"
/>
</FormFieldGroup>
<FormFieldGroup label="Days Until Deletion">
<Input type="number" id="bulk-deletion-days" name="days_until_deletion" value="14" min="14" required />
</FormFieldGroup>
<FormFieldGroup label="Audit Log Reason (optional)">
<Input
id="bulk-user-deletion-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="Reason for this bulk operation"
/>
</FormFieldGroup>
<Button type="submit" variant="danger" size="medium">
Schedule Deletion
</Button>
<script defer dangerouslySetInnerHTML={{__html: BULK_DELETION_DAYS_SCRIPT}} />
</VStack>
</form>
</Card>
);
export function BulkActionsPage({
config,
session,
currentAdmin,
flash,
assetVersion,
adminAcls,
result,
csrfToken,
}: BulkActionsPageProps) {
const canUpdateUserFlags = hasPermission(adminAcls, 'bulk:update_user_flags');
const canUpdateGuildFeatures = hasPermission(adminAcls, 'bulk:update_guild_features');
const canAddGuildMembers = hasPermission(adminAcls, 'bulk:add_guild_members');
const canDeleteUsers = hasPermission(adminAcls, 'bulk:delete_users');
return (
<Layout
csrfToken={csrfToken}
title="Bulk Actions"
activePage="bulk-actions"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<VStack gap={6}>
<Heading level={1}>Bulk Actions</Heading>
{result && <OperationResult response={result} />}
{canUpdateUserFlags && <BulkUpdateUserFlags basePath={config.basePath} csrfToken={csrfToken} />}
{canUpdateGuildFeatures && <BulkUpdateGuildFeatures basePath={config.basePath} csrfToken={csrfToken} />}
{canAddGuildMembers && <BulkAddGuildMembers basePath={config.basePath} csrfToken={csrfToken} />}
{canDeleteUsers && <BulkScheduleUserDeletion basePath={config.basePath} csrfToken={csrfToken} />}
</VStack>
</Layout>
);
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Table} from '@fluxer/admin/src/components/ui/Table';
import {TableBody} from '@fluxer/admin/src/components/ui/TableBody';
import {TableCell} from '@fluxer/admin/src/components/ui/TableCell';
import {TableContainer} from '@fluxer/admin/src/components/ui/TableContainer';
import {TableHeader} from '@fluxer/admin/src/components/ui/TableHeader';
import {TableHeaderCell} from '@fluxer/admin/src/components/ui/TableHeaderCell';
import {TableRow} from '@fluxer/admin/src/components/ui/TableRow';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {type DiscoveryCategory, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
type Application = z.infer<typeof DiscoveryApplicationResponse>;
interface StatusTabProps {
currentStatus: string;
basePath: string;
}
function StatusTabs({currentStatus, basePath}: StatusTabProps) {
const statuses = ['pending', 'approved', 'rejected', 'removed'];
return (
<div class="flex gap-2">
{statuses.map((status) => {
const isActive = currentStatus === status;
const classes = isActive
? 'px-4 py-2 rounded-md text-sm font-medium bg-neutral-800 text-white'
: 'px-4 py-2 rounded-md text-sm font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200';
return (
<a key={status} href={`${basePath}/discovery?status=${status}`} class={classes}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</a>
);
})}
</div>
);
}
function getStatusBadgeVariant(status: string): 'success' | 'danger' | 'warning' | 'neutral' {
switch (status) {
case 'pending':
return 'warning';
case 'approved':
return 'success';
case 'rejected':
return 'danger';
case 'removed':
return 'danger';
default:
return 'neutral';
}
}
function getCategoryLabel(categoryId: number): string {
return DiscoveryCategoryLabels[categoryId as DiscoveryCategory] ?? 'Unknown';
}
function formatDate(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export interface DiscoveryPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
assetVersion: string;
csrfToken: string;
applications: Array<Application>;
currentStatus: string;
}
export const DiscoveryPage: FC<DiscoveryPageProps> = ({
config,
session,
currentAdmin,
flash,
adminAcls,
assetVersion,
csrfToken,
applications,
currentStatus,
}) => {
const hasReviewPermission = hasPermission(adminAcls, AdminACLs.DISCOVERY_REVIEW);
const hasRemovePermission = hasPermission(adminAcls, AdminACLs.DISCOVERY_REMOVE);
const canTakeAction =
(currentStatus === 'pending' && hasReviewPermission) || (currentStatus === 'approved' && hasRemovePermission);
return (
<Layout
csrfToken={csrfToken}
title="Discovery"
activePage="discovery"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{hasReviewPermission ? (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<Card padding="md">
<VStack gap={4}>
<Heading level={1} size="2xl">
Discovery Management
</Heading>
<Text size="sm" color="muted">
Review discovery applications and manage listed communities.
</Text>
<StatusTabs currentStatus={currentStatus} basePath={config.basePath} />
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
{currentStatus.charAt(0).toUpperCase() + currentStatus.slice(1)} Applications ({applications.length})
</Heading>
{applications.length === 0 ? (
<Text color="muted">No {currentStatus} applications found.</Text>
) : (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Guild ID</TableHeaderCell>
<TableHeaderCell>Category</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Applied</TableHeaderCell>
{currentStatus !== 'pending' && <TableHeaderCell>Reviewed</TableHeaderCell>}
{currentStatus !== 'pending' && <TableHeaderCell>Reason</TableHeaderCell>}
{canTakeAction && <TableHeaderCell>Actions</TableHeaderCell>}
</TableRow>
</TableHeader>
<TableBody>
{applications.map((app) => (
<TableRow key={app.guild_id}>
<TableCell>
<a
href={`${config.basePath}/guilds?query=${app.guild_id}`}
class="font-mono text-blue-600 text-sm hover:underline"
>
{app.guild_id}
</a>
</TableCell>
<TableCell>{getCategoryLabel(app.category_id)}</TableCell>
<TableCell>
<span class="block max-w-xs truncate" title={app.description}>
{app.description}
</span>
</TableCell>
<TableCell>
<Badge variant={getStatusBadgeVariant(app.status)} size="sm">
{app.status.charAt(0).toUpperCase() + app.status.slice(1)}
</Badge>
</TableCell>
<TableCell>{formatDate(app.applied_at)}</TableCell>
{currentStatus !== 'pending' && (
<TableCell>{app.reviewed_at ? formatDate(app.reviewed_at) : '—'}</TableCell>
)}
{currentStatus !== 'pending' && (
<TableCell>
<span class="block max-w-xs truncate" title={app.review_reason ?? undefined}>
{app.review_reason ?? '—'}
</span>
</TableCell>
)}
{canTakeAction && (
<TableCell>
<div class="flex gap-2">
{currentStatus === 'pending' && hasReviewPermission && (
<>
<form
method="post"
action={`${config.basePath}/discovery/approve`}
class="inline"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="guild_id" value={app.guild_id} />
<Button type="submit" variant="primary" size="small">
Approve
</Button>
</form>
<form
method="post"
action={`${config.basePath}/discovery/reject`}
class="inline"
onclick={`return promptReason(this, 'Reject this application?')`}
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="guild_id" value={app.guild_id} />
<input type="hidden" name="reason" value="" />
<Button type="submit" variant="danger" size="small">
Reject
</Button>
</form>
</>
)}
{currentStatus === 'approved' && hasRemovePermission && (
<form
method="post"
action={`${config.basePath}/discovery/remove`}
class="inline"
onclick={`return promptReason(this, 'Remove this guild from discovery?')`}
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="guild_id" value={app.guild_id} />
<input type="hidden" name="reason" value="" />
<Button type="submit" variant="danger" size="small">
Remove
</Button>
</form>
)}
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</VStack>
</Card>
</VStack>
</PageLayout>
) : (
<Card padding="md">
<Heading level={1} size="2xl">
Discovery
</Heading>
<Text color="muted" size="sm" class="mt-2">
You do not have permission to view discovery applications.
</Text>
</Card>
)}
<script
defer
dangerouslySetInnerHTML={{
__html: PROMPT_REASON_SCRIPT,
}}
/>
</Layout>
);
};
const PROMPT_REASON_SCRIPT = `
function promptReason(form, message) {
var reason = prompt(message + '\\n\\nPlease provide a reason:');
if (reason === null) return false;
if (reason.trim() === '') {
alert('A reason is required.');
return false;
}
var reasonInput = form.querySelector('input[name="reason"]');
if (reasonInput) {
reasonInput.value = reason.trim();
}
return true;
}
`;

View File

@@ -0,0 +1,281 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {getGuildMemoryStats, getNodeStats} from '@fluxer/admin/src/api/System';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import type {Flash} from '@fluxer/hono/src/Flash';
import {formatNumber} from '@fluxer/number_utils/src/NumberFormatting';
import type {GuildMemoryStatsResponse, NodeStatsResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Alert} from '@fluxer/ui/src/components/Alert';
import {Button} from '@fluxer/ui/src/components/Button';
import {CardElevated} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface GatewayPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
reloadResult?: number | undefined;
assetVersion: string;
csrfToken: string;
}
function formatNumberLocal(n: number): string {
return formatNumber(n, {locale: 'en-US'});
}
function formatMemory(memoryMb: number): string {
if (memoryMb < 1.0) {
const kb = memoryMb * 1024.0;
return `${kb.toFixed(2)} KB`;
}
if (memoryMb < 1024.0) {
return `${memoryMb.toFixed(2)} MB`;
}
const gb = memoryMb / 1024.0;
return `${gb.toFixed(2)} GB`;
}
function formatMemoryFromBytes(bytesStr: string): string {
const bytes = BigInt(bytesStr);
const mbWith2Decimals = Number((bytes * 100n) / 1_048_576n) / 100;
return formatMemory(mbWith2Decimals);
}
function getFirstChar(s: string): string {
if (s === '') return '?';
return s.charAt(0);
}
function getGuildIconUrl(
mediaEndpoint: string,
guildId: string,
guildIcon: string | null,
forceStatic: boolean,
): string | null {
if (!guildIcon) return null;
const isAnimated = guildIcon.startsWith('a_');
const extension = isAnimated && !forceStatic ? 'gif' : 'webp';
return `${mediaEndpoint}/icons/${guildId}/${guildIcon}.${extension}?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}`;
}
const StatCard: FC<{label: string; value: string}> = ({label, value}) => (
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<div class="mb-1 text-neutral-600 text-xs uppercase tracking-wider">{label}</div>
<div class="font-semibold text-base text-neutral-900">{value}</div>
</div>
);
type ProcessMemoryStats = GuildMemoryStatsResponse['guilds'][number];
const NodeStatsSection: FC<{stats: NodeStatsResponse}> = ({stats}) => (
<CardElevated padding="md">
<VStack gap={4}>
<Heading level={2}>Gateway Statistics</Heading>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-5">
<StatCard label="Sessions" value={formatNumberLocal(stats.sessions)} />
<StatCard label="Guilds" value={formatNumberLocal(stats.guilds)} />
<StatCard label="Presences" value={formatNumberLocal(stats.presences)} />
<StatCard label="Calls" value={formatNumberLocal(stats.calls)} />
<StatCard label="Total RAM" value={formatMemoryFromBytes(stats.memory.total)} />
</div>
</VStack>
</CardElevated>
);
const GuildRow: FC<{config: Config; guild: ProcessMemoryStats; rank: number}> = ({config, guild, rank}) => {
const iconUrl = guild.guild_id ? getGuildIconUrl(config.mediaEndpoint, guild.guild_id, guild.guild_icon, true) : null;
return (
<tr class="transition-colors hover:bg-neutral-50">
<td class="whitespace-nowrap px-6 py-4 font-medium text-neutral-900 text-sm">#{rank}</td>
<td class="whitespace-nowrap px-6 py-4">
{guild.guild_id ? (
<a href={`${config.basePath}/guilds/${guild.guild_id}`} class="flex items-center gap-2">
{iconUrl ? (
<img src={iconUrl} alt={guild.guild_name} class="h-10 w-10 rounded-full" />
) : (
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-200 font-medium text-neutral-600 text-sm">
{getFirstChar(guild.guild_name)}
</div>
)}
<div>
<div class="font-medium text-neutral-900 text-sm">{guild.guild_name}</div>
<div class="text-neutral-500 text-xs">{guild.guild_id}</div>
</div>
</a>
) : (
<div class="flex items-center gap-2">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-200 font-medium text-neutral-600 text-sm">
?
</div>
<span class="text-neutral-600 text-sm">{guild.guild_name}</span>
</div>
)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right font-medium text-neutral-900 text-sm">
{formatMemoryFromBytes(guild.memory)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
{formatNumberLocal(guild.member_count)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
{formatNumberLocal(guild.session_count)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
{formatNumberLocal(guild.presence_count)}
</td>
</tr>
);
};
const GuildTable: FC<{config: Config; guilds: Array<ProcessMemoryStats>}> = ({config, guilds}) => {
if (guilds.length === 0) {
return <div class="p-6 text-center text-neutral-600">No guilds in memory</div>;
}
return (
<div class="overflow-x-auto">
<table class="w-full">
<thead class="border-neutral-200 border-b bg-neutral-50">
<tr>
<th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider">Rank</th>
<th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider">Guild</th>
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">RAM Usage</th>
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Members</th>
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Sessions</th>
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Presences</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200">
{guilds.map((guild, index) => (
<GuildRow config={config} guild={guild} rank={index + 1} />
))}
</tbody>
</table>
</div>
);
};
const SuccessView: FC<{
config: Config;
adminAcls: Array<string>;
nodeStats: NodeStatsResponse | null;
guilds: Array<ProcessMemoryStats>;
reloadResult: number | undefined;
csrfToken: string;
}> = ({config, adminAcls, nodeStats, guilds, reloadResult, csrfToken}) => {
const canReloadAll = adminAcls.includes('gateway:reload_all') || adminAcls.includes('*');
return (
<VStack gap={6}>
<PageHeader
title="Gateway"
actions={
canReloadAll && (
<form method="post" action={`${config.basePath}/gateway?action=reload_all`}>
<CsrfInput token={csrfToken} />
<Button
type="submit"
variant="primary"
onclick="return confirm('Are you sure you want to reload all guilds in memory? This may take several minutes.');"
>
Reload All Guilds
</Button>
</form>
)
}
/>
{reloadResult !== undefined && <Alert variant="success">Successfully reloaded {reloadResult} guilds!</Alert>}
{nodeStats && <NodeStatsSection stats={nodeStats} />}
<div class="rounded-lg border border-neutral-200 bg-white shadow-sm">
<VStack gap={2} class="border-neutral-200 border-b p-6">
<Heading level={2}>Guild Memory Leaderboard (Top 100)</Heading>
<Text size="sm" color="muted">
Guilds ranked by memory usage, showing the top 100 consumers
</Text>
</VStack>
<GuildTable config={config} guilds={guilds} />
</div>
</VStack>
);
};
export async function GatewayPage({
config,
session,
currentAdmin,
flash,
adminAcls,
reloadResult,
assetVersion,
csrfToken,
}: GatewayPageProps) {
const nodeStatsResult = await getNodeStats(config, session);
const guildStatsResult = await getGuildMemoryStats(config, session, 100);
const content = guildStatsResult.ok ? (
<SuccessView
config={config}
adminAcls={adminAcls}
nodeStats={nodeStatsResult.ok ? nodeStatsResult.data : null}
guilds={guildStatsResult.data.guilds}
reloadResult={reloadResult}
csrfToken={csrfToken}
/>
) : (
<>
<Heading level={1}>Gateway</Heading>
<ErrorAlert error={getErrorMessage(guildStatsResult.error)} />
</>
);
return (
<Layout
csrfToken={csrfToken}
title="Gateway"
activePage="gateway"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{content}
</Layout>
);
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Select} from '@fluxer/admin/src/components/ui/Select';
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {SliderInput} from '@fluxer/ui/src/components/SliderInput';
import type {FC} from 'hono/jsx';
const MAX_GIFT_CODES = 100;
const DEFAULT_GIFT_COUNT = 10;
const GIFT_PRODUCT_OPTIONS: Array<{value: string; label: string}> = [
{value: 'gift_1_month', label: 'Gift - 1 Month subscription'},
{value: 'gift_1_year', label: 'Gift - 1 Year subscription'},
{value: 'gift_visionary', label: 'Gift - Visionary lifetime'},
];
export interface GiftCodesPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
assetVersion: string;
csrfToken: string;
generatedCodes?: Array<string>;
}
export const GiftCodesPage: FC<GiftCodesPageProps> = ({
config,
session,
currentAdmin,
flash,
adminAcls,
assetVersion,
csrfToken,
generatedCodes,
}) => {
const hasGeneratePermission = hasPermission(adminAcls, AdminACLs.GIFT_CODES_GENERATE);
const codesValue = generatedCodes ? generatedCodes.join('\n') : '';
return (
<Layout
csrfToken={csrfToken}
title="Gift Codes"
activePage="gift-codes"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{hasGeneratePermission ? (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<Card padding="md">
<VStack gap={2}>
<Heading level={1} size="2xl">
Generate Gift Codes
</Heading>
</VStack>
<form id="gift-form" class="mt-4" method="post" action={`${config.basePath}/gift-codes`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<VStack gap={4}>
<VStack gap={1}>
<SliderInput
id="gift-count-slider"
name="count"
label="How many codes"
min={1}
max={MAX_GIFT_CODES}
value={DEFAULT_GIFT_COUNT}
rangeText={`Range: 1-${MAX_GIFT_CODES}`}
/>
<Text size="xs" color="muted">
Select the number of gift codes to generate.
</Text>
</VStack>
<FormFieldGroup
label="Product"
helper="Generated codes are rendered as https://fluxer.gift/<code>."
>
<Select id="gift-product-type" name="product_type" options={GIFT_PRODUCT_OPTIONS} />
</FormFieldGroup>
<Button type="submit" variant="primary">
Generate Gift Codes
</Button>
</VStack>
</VStack>
<VStack gap={2} class="mt-4">
<FormFieldGroup label="Generated URLs" helper="Copy one URL per line when sharing codes.">
<Textarea
id="gift-generated-urls"
name="generated_urls"
readonly
rows={10}
placeholder="Full gift URLs will appear here after generation."
value={codesValue}
/>
</FormFieldGroup>
</VStack>
</form>
</Card>
</VStack>
</PageLayout>
) : (
<Card padding="md">
<Heading level={1} size="2xl">
Gift Codes
</Heading>
<Text color="muted" size="sm" class="mt-2">
You do not have permission to generate gift codes.
</Text>
</Card>
)}
</Layout>
);
};

View File

@@ -0,0 +1,521 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {Archive} from '@fluxer/admin/src/api/Archives';
import {listArchives} from '@fluxer/admin/src/api/Archives';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {lookupGuild} from '@fluxer/admin/src/api/Guilds';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {EmojisTab} from '@fluxer/admin/src/pages/guild_detail/tabs/EmojisTab';
import {FeaturesTab} from '@fluxer/admin/src/pages/guild_detail/tabs/FeaturesTab';
import {MembersTab} from '@fluxer/admin/src/pages/guild_detail/tabs/MembersTab';
import {ModerationTab} from '@fluxer/admin/src/pages/guild_detail/tabs/ModerationTab';
import {OverviewTab} from '@fluxer/admin/src/pages/guild_detail/tabs/OverviewTab';
import {SettingsTab} from '@fluxer/admin/src/pages/guild_detail/tabs/SettingsTab';
import {StickersTab} from '@fluxer/admin/src/pages/guild_detail/tabs/StickersTab';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {getGuildIconUrl, getInitials as getInitialsFromName} from '@fluxer/ui/src/utils/FormatUser';
import type {Child, FC} from 'hono/jsx';
interface GuildDetailPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
guildId: string;
referrer?: string | undefined;
tab?: string | undefined;
page?: string | undefined;
assetVersion: string;
csrfToken: string;
}
interface Tab {
label: string;
path: string;
active: boolean;
}
function canViewArchives(adminAcls: Array<string>): boolean {
return adminAcls.some(
(acl) =>
acl === AdminACLs.ARCHIVE_VIEW_ALL || acl === AdminACLs.ARCHIVE_TRIGGER_GUILD || acl === AdminACLs.WILDCARD,
);
}
function canManageAssets(adminAcls: Array<string>): boolean {
return hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
}
function getStatusText(archive: Archive): string {
if (archive.failed_at) {
return 'Failed';
}
if (archive.completed_at) {
return 'Completed';
}
return archive.progress_step ?? 'In Progress';
}
const RenderTabs: FC<{config: Config; tabs: Array<Tab>}> = ({config, tabs}) => {
return (
<VStack gap={0} class="mb-6 border-neutral-200 border-b">
<nav class="-mb-px flex flex-wrap gap-x-4 sm:gap-x-6">
{tabs.map((tab) => (
<a
href={`${config.basePath}${tab.path}`}
class={
tab.active
? 'border-neutral-900 border-b-2 px-1 py-3 font-medium text-neutral-900 text-sm'
: 'px-1 py-3 font-medium text-neutral-500 text-sm hover:border-neutral-300 hover:border-b-2 hover:text-neutral-700'
}
>
{tab.label}
</a>
))}
</nav>
</VStack>
);
};
const RenderArchiveTable: FC<{config: Config; archives: Array<Archive>}> = ({config, archives}) => {
if (archives.length === 0) {
return (
<VStack gap={0}>
<EmptyState title="No archives yet for this guild." />
</VStack>
);
}
return (
<VStack gap={0} class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table class="min-w-full divide-y divide-neutral-200">
<thead class="bg-neutral-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">
Requested At
</th>
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200">
{archives.map((archive) => (
<tr>
<td class="px-4 py-3 text-neutral-900 text-sm">{formatTimestamp(archive.requested_at)}</td>
<td class="px-4 py-3 text-neutral-900 text-sm">
{getStatusText(archive)} ({archive.progress_percent}%)
</td>
<td class="px-4 py-3 text-sm">
{archive.completed_at ? (
<Button
variant="primary"
size="small"
href={`${config.basePath}/archives/download?subject_type=guild&subject_id=${archive.subject_id}&archive_id=${archive.archive_id}`}
>
Download
</Button>
) : (
<Text size="sm" color="muted">
Pending
</Text>
)}
</td>
</tr>
))}
</tbody>
</table>
</VStack>
);
};
const ArchivesTab: FC<{
config: Config;
session: Session;
guildId: string;
csrfToken: string;
}> = async ({config, session, guildId, csrfToken}) => {
const result = await listArchives(config, session, 'guild', guildId, false);
return (
<VStack gap={6}>
<HStack gap={3} class="flex-wrap items-center justify-between">
<Heading level={2}>Guild Archives</Heading>
<form method="post" action={`${config.basePath}/guilds/${guildId}?tab=archives&action=trigger_archive`}>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Trigger Archive
</Button>
</form>
</HStack>
{result.ok ? (
<RenderArchiveTable config={config} archives={result.data.archives} />
) : (
<ErrorCard title="Failed to load archives" message={getErrorMessage(result.error)} />
)}
</VStack>
);
};
const RenderGuildHeader: FC<{
config: Config;
guild: GuildLookupResult;
}> = ({config, guild}) => {
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
return (
<VStack gap={0} class="mb-6 rounded-lg border border-neutral-200 bg-white p-6">
<HStack gap={6} class="flex-col items-start sm:flex-row">
{iconUrl ? (
<VStack gap={0} class="flex flex-shrink-0 items-center justify-center sm:block">
<img src={iconUrl} alt={guild.name} class="h-24 w-24 rounded-full" />
</VStack>
) : (
<VStack gap={0} class="flex flex-shrink-0 items-center justify-center sm:block">
<VStack
gap={0}
align="center"
class="h-24 w-24 justify-center rounded-full bg-neutral-200 text-center font-semibold text-base text-neutral-600"
>
{getInitialsFromName(guild.name)}
</VStack>
</VStack>
)}
<VStack gap={3} class="min-w-0 flex-1">
<Heading level={1} size="xl">
{guild.name}
</Heading>
<VStack gap={2} class="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
<VStack gap={1}>
<Text size="sm" class="font-medium" color="muted">
Guild ID:
</Text>
<Text size="sm" class="break-all">
{guild.id}
</Text>
</VStack>
<VStack gap={1}>
<Text size="sm" class="font-medium" color="muted">
Owner ID:
</Text>
<a
href={`${config.basePath}/users/${guild.owner_id}`}
class="block text-neutral-900 text-sm hover:text-blue-600 hover:underline"
>
{guild.owner_id}
</a>
</VStack>
</VStack>
</VStack>
</HStack>
</VStack>
);
};
interface RenderTabContentProps {
config: Config;
session: Session;
guild: GuildLookupResult;
adminAcls: Array<string>;
guildId: string;
activeTab: string;
currentPage: number;
assetVersion: string;
csrfToken: string;
}
async function renderTabContent({
config,
session,
guild,
adminAcls,
guildId,
activeTab,
currentPage,
assetVersion,
csrfToken,
}: RenderTabContentProps) {
switch (activeTab) {
case 'members':
return await MembersTab({
config,
session,
guildId,
adminAcls,
page: currentPage,
assetVersion,
csrfToken,
});
case 'settings':
return (
<SettingsTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
);
case 'features':
return (
<FeaturesTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
);
case 'moderation':
return (
<ModerationTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
);
case 'archives':
return <ArchivesTab config={config} session={session} guildId={guildId} csrfToken={csrfToken} />;
case 'emojis':
return await EmojisTab({config, session, guildId, adminAcls, csrfToken});
case 'stickers':
return await StickersTab({config, session, guildId, adminAcls, csrfToken});
default:
return <OverviewTab config={config} guild={guild} csrfToken={csrfToken} />;
}
}
const RenderGuildContent: FC<{
config: Config;
guild: GuildLookupResult;
adminAcls: Array<string>;
guildId: string;
referrer: string | undefined;
activeTab: string;
tabContent: Child | null;
}> = ({config, guild, adminAcls, guildId, referrer, activeTab, tabContent}) => {
const tabList: Array<Tab> = [
{
label: 'Overview',
path: `/guilds/${guildId}?tab=overview`,
active: activeTab === 'overview',
},
{
label: 'Members',
path: `/guilds/${guildId}?tab=members`,
active: activeTab === 'members',
},
{
label: 'Settings',
path: `/guilds/${guildId}?tab=settings`,
active: activeTab === 'settings',
},
{
label: 'Features',
path: `/guilds/${guildId}?tab=features`,
active: activeTab === 'features',
},
{
label: 'Moderation',
path: `/guilds/${guildId}?tab=moderation`,
active: activeTab === 'moderation',
},
];
if (canViewArchives(adminAcls)) {
tabList.push({
label: 'Archives',
path: `/guilds/${guildId}?tab=archives`,
active: activeTab === 'archives',
});
}
if (canManageAssets(adminAcls)) {
tabList.push({
label: 'Emojis',
path: `/guilds/${guildId}?tab=emojis`,
active: activeTab === 'emojis',
});
tabList.push({
label: 'Stickers',
path: `/guilds/${guildId}?tab=stickers`,
active: activeTab === 'stickers',
});
}
return (
<VStack gap={6} class="mx-auto max-w-7xl">
<VStack gap={0} class="mb-6">
<a
href={`${config.basePath}${referrer ?? '/guilds'}`}
class="inline-flex items-center gap-2 text-neutral-600 transition-colors hover:text-neutral-900"
>
<span class="text-lg">&larr;</span>
Back to Guilds
</a>
</VStack>
<RenderGuildHeader config={config} guild={guild} />
<RenderTabs config={config} tabs={tabList} />
{tabContent}
</VStack>
);
};
const RenderNotFoundContent: FC<{config: Config}> = ({config}) => {
return (
<VStack gap={0} class="mx-auto max-w-4xl">
<VStack gap={6} class="rounded-lg border border-neutral-200 bg-white p-12 text-center">
<Heading level={2} size="base">
Guild Not Found
</Heading>
<Text color="muted">The requested guild could not be found.</Text>
<Button variant="primary" size="small" href={`${config.basePath}/guilds`}>
<span class="text-lg">&larr;</span>
Back to Guilds
</Button>
</VStack>
</VStack>
);
};
const RenderApiError: FC<{config: Config; errorMessage: string}> = ({config, errorMessage}) => {
return (
<VStack gap={0} class="mx-auto max-w-4xl">
<VStack gap={6} class="rounded-lg border border-neutral-200 bg-white p-12 text-center">
<Heading level={2} size="base">
Error
</Heading>
<Text color="muted">{errorMessage}</Text>
<Button variant="primary" size="small" href={`${config.basePath}/guilds`}>
<span class="text-lg">&larr;</span>
Back to Guilds
</Button>
</VStack>
</VStack>
);
};
export async function GuildDetailPage({
config,
session,
currentAdmin,
flash,
guildId,
referrer,
tab,
page,
assetVersion,
csrfToken,
}: GuildDetailPageProps) {
const result = await lookupGuild(config, session, guildId);
const adminAcls = currentAdmin?.acls ?? [];
let activeTab = tab ?? 'overview';
const validTabs = ['overview', 'settings', 'features', 'moderation', 'members', 'archives', 'emojis', 'stickers'];
if (!validTabs.includes(activeTab)) {
activeTab = 'overview';
}
if (activeTab === 'archives' && !canViewArchives(adminAcls)) {
activeTab = 'overview';
}
if ((activeTab === 'emojis' || activeTab === 'stickers') && !canManageAssets(adminAcls)) {
activeTab = 'overview';
}
let currentPage = 0;
if (page) {
const parsed = parseInt(page, 10);
if (!Number.isNaN(parsed) && parsed >= 0) {
currentPage = parsed;
}
}
if (!result.ok) {
return (
<Layout
csrfToken={csrfToken}
title="Guild Details"
activePage="guilds"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<RenderApiError config={config} errorMessage={getErrorMessage(result.error)} />
</Layout>
);
}
if (!result.data) {
return (
<Layout
csrfToken={csrfToken}
title="Guild Not Found"
activePage="guilds"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<RenderNotFoundContent config={config} />
</Layout>
);
}
const guild = result.data;
const tabContent = await renderTabContent({
config,
session,
guild,
adminAcls,
guildId,
activeTab,
currentPage,
assetVersion,
csrfToken,
});
return (
<Layout
csrfToken={csrfToken}
title="Guild Details"
activePage="guilds"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<RenderGuildContent
config={config}
guild={guild}
adminAcls={adminAcls}
guildId={guildId}
referrer={referrer}
activeTab={activeTab}
tabContent={tabContent}
/>
</Layout>
);
}

View File

@@ -0,0 +1,251 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {searchGuilds} from '@fluxer/admin/src/api/Guilds';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {Pagination} from '@fluxer/ui/src/components/Pagination';
import {SearchForm} from '@fluxer/ui/src/components/SearchForm';
import {getGuildIconUrl, getInitials as getInitialsFromName} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
interface GuildsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
csrfToken: string;
searchQuery: string | undefined;
page: number;
assetVersion: string;
}
const GuildCard: FC<{config: Config; guild: z.infer<typeof GuildAdminResponse>}> = ({config, guild}) => {
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
return (
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-colors hover:border-neutral-300">
<div class="p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
{iconUrl ? (
<div class="flex flex-shrink-0 items-center justify-center sm:block">
<img src={iconUrl} alt={guild.name} class="h-16 w-16 rounded-full" />
</div>
) : (
<div class="flex flex-shrink-0 items-center justify-center sm:block">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-200 font-medium text-base text-neutral-600">
{getInitialsFromName(guild.name)}
</div>
</div>
)}
<div class="min-w-0 flex-1">
<div class="mb-2 flex flex-wrap items-center gap-2">
<Heading level={2} size="base">
{guild.name}
</Heading>
{guild.features.length > 0 && (
<span class="rounded bg-purple-100 px-2 py-0.5 text-purple-700 text-xs uppercase">Featured</span>
)}
</div>
<div class="space-y-0.5">
<Text size="sm" color="muted" class="break-all">
ID: {guild.id}
</Text>
<Text size="sm" color="muted">
Members: {guild.member_count}
</Text>
<Text size="sm" color="muted">
Owner:{' '}
<a
href={`${config.basePath}/users/${guild.owner_id}`}
class="transition-colors hover:text-blue-600 hover:underline"
>
{guild.owner_id}
</a>
</Text>
</div>
</div>
<Button variant="primary" size="small" href={`${config.basePath}/guilds/${guild.id}`}>
View Details
</Button>
</div>
</div>
</div>
);
};
const GuildsGrid: FC<{config: Config; guilds: Array<z.infer<typeof GuildAdminResponse>>}> = ({config, guilds}) => {
return (
<Grid cols={1} gap="md">
{guilds.map((guild) => (
<GuildCard config={config} guild={guild} />
))}
</Grid>
);
};
const GuildsEmptyState: FC = () => {
return (
<EmptyState
title="Enter a search query to find guilds"
message="Search by Guild ID, Guild Name, Vanity URL, or other attributes"
/>
);
};
const EmptySearchResults: FC = () => {
return <EmptyState title="No guilds found" message="Try adjusting your search query" />;
};
export async function GuildsPage({
config,
session,
currentAdmin,
flash,
csrfToken,
searchQuery,
page,
assetVersion,
}: GuildsPageProps) {
const limit = 50;
const offset = page * limit;
let content = <div />;
if (searchQuery && searchQuery.trim() !== '') {
const result = await searchGuilds(config, session, searchQuery.trim(), limit, offset);
if (result.ok) {
const {guilds, total} = result.data;
content = (
<div class="mx-auto max-w-7xl space-y-6">
<PageHeader
title="Guilds"
actions={
<Text size="sm" color="muted">
Found {total} results (showing {guilds.length})
</Text>
}
/>
<SearchForm
action="/guilds"
basePath={config.basePath}
fields={[
{
name: 'q',
type: 'text',
value: searchQuery,
placeholder: 'Search by ID, guild name, or vanity URL...',
autocomplete: 'off',
},
]}
layout="horizontal"
/>
{guilds.length === 0 ? (
<EmptySearchResults />
) : (
<>
<GuildsGrid config={config} guilds={guilds} />
<Pagination
basePath={config.basePath}
currentPage={page}
totalPages={Math.ceil(total / limit)}
buildUrlFn={(p) => `/guilds${buildPaginationUrl(p, {q: searchQuery})}`}
/>
</>
)}
</div>
);
} else {
content = (
<div class="mx-auto max-w-7xl space-y-6">
<PageHeader title="Guilds" />
<SearchForm
action="/guilds"
basePath={config.basePath}
fields={[
{
name: 'q',
type: 'text',
value: searchQuery,
placeholder: 'Search by ID, guild name, or vanity URL...',
autocomplete: 'off',
},
]}
layout="horizontal"
/>
<ErrorAlert error={getErrorMessage(result.error)} />
</div>
);
}
} else {
content = (
<div class="mx-auto max-w-7xl space-y-6">
<PageHeader title="Guilds" />
<SearchForm
action="/guilds"
basePath={config.basePath}
fields={[
{
name: 'q',
type: 'text',
value: searchQuery,
placeholder: 'Search by ID, guild name, or vanity URL...',
autocomplete: 'off',
},
]}
layout="horizontal"
/>
<GuildsEmptyState />
</div>
);
}
return (
<Layout
csrfToken={csrfToken}
title="Guilds"
activePage="guilds"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{content}
</Layout>
);
}

View File

@@ -0,0 +1,514 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {getInstanceConfig, listSnowflakeReservations} from '@fluxer/admin/src/api/InstanceConfig';
import {getLimitConfig} from '@fluxer/admin/src/api/LimitConfig';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {
InstanceConfigResponse,
LimitConfigGetResponse,
SnowflakeReservationEntry,
SsoConfigResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox, Input, Select} from '@fluxer/ui/src/components/Form';
import {FlexRowBetween} from '@fluxer/ui/src/components/Layout';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeaderCell,
TableRow,
} from '@fluxer/ui/src/components/Table';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
export interface InstanceConfigPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
csrfToken: string;
}
export async function InstanceConfigPage({
config,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
}: InstanceConfigPageProps) {
const configResult = await getInstanceConfig(config, session);
const limitResult = await getLimitConfig(config, session);
const adminAcls = currentAdmin?.acls ?? [];
const reservationViewAcl = hasPermission(adminAcls, AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_VIEW);
const reservationManageAcl = hasPermission(adminAcls, AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_MANAGE);
let reservations: Array<SnowflakeReservationEntry> = [];
if (reservationViewAcl && !config.selfHosted) {
const reservationResult = await listSnowflakeReservations(config, session);
if (reservationResult.ok) {
reservations = reservationResult.data.sort((a, b) => a.email.localeCompare(b.email));
}
}
if (!configResult.ok) {
return (
<Layout
csrfToken={csrfToken}
title="Instance Configuration"
activePage="instance-config"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<ErrorAlert error={getErrorMessage(configResult.error)} />
</Layout>
);
}
if (!limitResult.ok) {
return (
<Layout
csrfToken={csrfToken}
title="Instance Configuration"
activePage="instance-config"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<ErrorAlert error={getErrorMessage(limitResult.error)} />
</Layout>
);
}
const instanceConfig = configResult.data;
const limitInfo = limitResult.data;
return (
<Layout
csrfToken={csrfToken}
title="Instance Configuration"
activePage="instance-config"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<Stack gap="lg">
<Heading level={1}>Instance Configuration</Heading>
<ConfigForm config={config} instanceConfig={instanceConfig} csrfToken={csrfToken} />
<SsoConfigForm config={config} sso={instanceConfig.sso} csrfToken={csrfToken} />
<LimitConfigSection config={config} limitInfo={limitInfo} />
{reservationViewAcl && !config.selfHosted && (
<SnowflakeReservationSection
config={config}
reservations={reservations}
canManage={reservationManageAcl}
csrfToken={csrfToken}
/>
)}
</Stack>
</Layout>
);
}
const ConfigForm: FC<{config: Config; instanceConfig: InstanceConfigResponse; csrfToken: string}> = ({
config,
instanceConfig,
csrfToken,
}) => {
const hours = Array.from({length: 24}, (_, i) => i);
const hourOptions = hours.map((hour) => ({value: hour.toString(), label: `${hour}:00`}));
return (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Manual Review Settings
</Heading>
<Text size="sm" color="muted">
Configure whether new registrations require manual review before the account is activated.
</Text>
<form method="post" action={`${config.basePath}/instance-config?action=update`}>
<CsrfInput token={csrfToken} />
<Stack gap="lg">
<Stack gap="sm">
<Checkbox
name="manual_review_enabled"
value="true"
label="Enable manual review for new registrations"
checked={instanceConfig.manual_review_enabled}
/>
<Caption>When enabled, new accounts will require approval before they can use the platform.</Caption>
</Stack>
<Stack gap="md">
<Stack gap="sm">
<Checkbox
name="manual_review_schedule_enabled"
value="true"
label="Enable schedule-based activation"
checked={instanceConfig.manual_review_schedule_enabled}
/>
<Caption>When enabled, manual review will only be active during the specified hours (UTC).</Caption>
</Stack>
<Grid cols={2} gap="md">
<Select
label="Start Hour (UTC)"
name="manual_review_schedule_start_hour_utc"
value={instanceConfig.manual_review_schedule_start_hour_utc.toString()}
options={hourOptions}
/>
<Select
label="End Hour (UTC)"
name="manual_review_schedule_end_hour_utc"
value={instanceConfig.manual_review_schedule_end_hour_utc.toString()}
options={hourOptions}
/>
</Grid>
</Stack>
<Stack gap="md">
<Input
label="Registration Alerts Webhook URL"
name="registration_alerts_webhook_url"
type="url"
value={instanceConfig.registration_alerts_webhook_url ?? ''}
placeholder="https://hooks.example.com/webhook"
helper="Webhook URL for receiving alerts about new user registrations."
/>
<Input
label="System Alerts Webhook URL"
name="system_alerts_webhook_url"
type="url"
value={instanceConfig.system_alerts_webhook_url ?? ''}
placeholder="https://hooks.example.com/webhook"
helper="Webhook URL for receiving system alerts (virus scan failures, etc.)."
/>
</Stack>
<Button type="submit" variant="primary">
Save Configuration
</Button>
</Stack>
</form>
</Stack>
</Card>
);
};
const SsoConfigForm: FC<{config: Config; sso: SsoConfigResponse; csrfToken: string}> = ({config, sso, csrfToken}) => {
const allowedDomainsText = sso.allowed_domains.join('\n');
return (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Single Sign-On (SSO)
</Heading>
<Text size="sm" color="muted">
Configure OIDC-style SSO for the admin and client apps. When enabled, password logins will be blocked and
users will be directed through your SSO provider.
</Text>
<form method="post" action={`${config.basePath}/instance-config?action=update_sso`}>
<CsrfInput token={csrfToken} />
<Stack gap="lg">
<Stack gap="sm">
<Checkbox
name="sso_enabled"
value="true"
label="Enable SSO (disables local password login)"
checked={sso.enabled}
/>
<Checkbox
name="sso_auto_provision"
value="true"
label="Automatically provision users on first SSO login"
checked={sso.auto_provision}
/>
</Stack>
<Grid cols={2} gap="md">
<Input
label="Display Name"
name="sso_display_name"
type="text"
value={sso.display_name ?? ''}
placeholder="Example Identity Provider"
/>
<Input
label="Issuer"
name="sso_issuer"
type="url"
value={sso.issuer ?? ''}
placeholder="https://idp.example.com"
/>
<Input
label="Authorization URL"
name="sso_authorization_url"
type="url"
value={sso.authorization_url ?? ''}
placeholder="https://idp.example.com/oauth/authorize"
/>
<Input
label="Token URL"
name="sso_token_url"
type="url"
value={sso.token_url ?? ''}
placeholder="https://idp.example.com/oauth/token"
/>
<Input
label="User Info URL"
name="sso_userinfo_url"
type="url"
value={sso.userinfo_url ?? ''}
placeholder="https://idp.example.com/oauth/userinfo"
/>
<Input
label="JWKS URL"
name="sso_jwks_url"
type="url"
value={sso.jwks_url ?? ''}
placeholder="https://idp.example.com/.well-known/jwks.json"
/>
</Grid>
<Grid cols={2} gap="md">
<Input
label="Client ID"
name="sso_client_id"
type="text"
value={sso.client_id ?? ''}
placeholder="client-id"
/>
<Stack gap="sm">
<Input
label="Client Secret"
name="sso_client_secret"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
<Stack gap="sm">
<Checkbox name="sso_clear_client_secret" value="true" label="Clear secret" />
<Caption>
{sso.client_secret_set
? 'A secret is set. Check to clear, or enter a new value to rotate.'
: 'No secret set yet.'}
</Caption>
</Stack>
</Stack>
</Grid>
<Grid cols={2} gap="md">
<Input
label="Scope"
name="sso_scope"
type="text"
value={sso.scope ?? ''}
placeholder="openid email profile"
/>
<Input
label="Redirect URI"
name="sso_redirect_uri"
type="url"
value={sso.redirect_uri ?? ''}
disabled={true}
helper="Configure this exact URI in your IdP. It is derived from the public gateway URL."
/>
</Grid>
<Input
label="Allowed Email Domains"
name="sso_allowed_domains"
type="text"
value={allowedDomainsText}
placeholder="example.com"
helper="Limit SSO logins to these domains (one per line). Leave empty to allow any verified email."
/>
<Button type="submit" variant="primary">
Save SSO Settings
</Button>
</Stack>
</form>
</Stack>
</Card>
);
};
const LimitConfigSection: FC<{config: Config; limitInfo: LimitConfigResponse}> = ({config, limitInfo}) => {
const description = limitInfo.self_hosted
? 'Self-hosted instance with all premium features enabled. Configure user and guild limits.'
: 'Configure limit rules that control user and guild restrictions based on traits and features.';
return (
<Card padding="md">
<FlexRowBetween>
<Stack gap="sm">
<Heading level={2} size="base">
Limit Configuration
</Heading>
<Caption>{description}</Caption>
</Stack>
<Button href={`${config.basePath}/limit-config`} variant="primary">
Configure Limits
</Button>
</FlexRowBetween>
</Card>
);
};
const SnowflakeReservationSection: FC<{
config: Config;
reservations: Array<SnowflakeReservationEntry>;
canManage: boolean;
csrfToken: string;
}> = ({config, reservations, canManage, csrfToken}) => {
return (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Snowflake Reservations
</Heading>
<Caption>
Reserve specific snowflake IDs for trusted testers. Every reservation maps a normalized email to a hard ID.
</Caption>
<SnowflakeReservationTable
config={config}
reservations={reservations}
canManage={canManage}
csrfToken={csrfToken}
/>
{canManage ? (
<AddSnowflakeReservationForm config={config} csrfToken={csrfToken} />
) : (
<Caption>You need additional permissions to modify reservations.</Caption>
)}
</Stack>
</Card>
);
};
const SnowflakeReservationTable: FC<{
config: Config;
reservations: Array<SnowflakeReservationEntry>;
canManage: boolean;
csrfToken: string;
}> = ({config, reservations, canManage, csrfToken}) => {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell label="Email" />
<TableHeaderCell label="Snowflake" />
<TableHeaderCell label="Updated At" />
<TableHeaderCell label="Actions" />
</TableRow>
</TableHead>
<TableBody>
{reservations.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>
<Caption>No reservations configured</Caption>
</TableCell>
</TableRow>
) : (
reservations.map((entry) => (
<TableRow>
<TableCell>
<Text size="sm">{entry.email}</Text>
</TableCell>
<TableCell muted>{entry.snowflake}</TableCell>
<TableCell muted>{entry.updated_at ? entry.updated_at : <Caption>-</Caption>}</TableCell>
<TableCell>
{canManage ? (
<form method="post" action={`${config.basePath}/instance-config?action=delete_reservation`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="reservation_email" value={entry.email} />
<Button type="submit" variant="danger" size="small">
Remove
</Button>
</form>
) : (
<Caption>-</Caption>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
};
const AddSnowflakeReservationForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => {
return (
<form method="post" action={`${config.basePath}/instance-config?action=add_reservation`}>
<CsrfInput token={csrfToken} />
<Stack gap="md">
<FormFieldGroup
label="Email (normalized)"
helper="Use normalized email addresses (lowercase) when reserving snowflake IDs."
htmlFor="reservation-email"
>
<Input id="reservation-email" name="reservation_email" type="email" placeholder="user@example.com" />
</FormFieldGroup>
<FormFieldGroup label="Snowflake ID" htmlFor="reservation-snowflake">
<Input id="reservation-snowflake" name="reservation_snowflake" type="text" placeholder="123456789012345678" />
</FormFieldGroup>
<Button type="submit" variant="primary">
Reserve Snowflake
</Button>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,840 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {getDefaultValue, getKeysByCategory, getLimitConfig, isModified} from '@fluxer/admin/src/api/LimitConfig';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Caption, Heading, Label, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {LIMIT_KEY_BOUNDS} from '@fluxer/constants/src/LimitBounds';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {FlexRowBetween} from '@fluxer/ui/src/components/Layout';
import type {Child, FC} from 'hono/jsx';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
type LimitRule = LimitConfigResponse['limit_config']['rules'][number];
type LimitKeyMetadata = LimitConfigResponse['metadata'][string];
const CATEGORY_ORDER = ['features', 'messages', 'guilds', 'channels', 'expressions', 'files', 'social'];
function orderRulesDefaultFirst(rules: Array<LimitRule>): Array<LimitRule> {
const defaultIndex = rules.findIndex((rule) => rule.id === 'default');
if (defaultIndex <= 0) {
return [...rules];
}
const sorted = [...rules];
const [defaultRule] = sorted.splice(defaultIndex, 1);
return [defaultRule, ...sorted];
}
export interface LimitConfigPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
selectedRule?: string;
csrfToken: string;
}
export async function LimitConfigPage({
config,
session,
currentAdmin,
flash,
assetVersion,
selectedRule,
csrfToken,
}: LimitConfigPageProps) {
const result = await getLimitConfig(config, session);
const adminAcls = currentAdmin?.acls ?? [];
const canUpdate = hasPermission(adminAcls, AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE);
if (!result.ok) {
return (
<Layout
csrfToken={csrfToken}
title="Limit Configuration"
activePage="limit-config"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<ErrorAlert error={getErrorMessage(result.error)} />
</Layout>
);
}
const response = result.data;
const sortedRules = orderRulesDefaultFirst(response.limit_config.rules);
const activeRuleId = selectedRule ?? sortedRules[0]?.id ?? 'default';
return (
<Layout
csrfToken={csrfToken}
title="Limit Configuration"
activePage="limit-config"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<VStack gap={6}>
<RenderHeader response={response} />
<RenderRuleTabs config={config} rules={sortedRules} activeRuleId={activeRuleId} />
<RenderRuleEditor
config={config}
response={response}
ruleId={activeRuleId}
canUpdate={canUpdate}
csrfToken={csrfToken}
/>
{canUpdate && <RenderCreateRuleModal config={config} response={response} csrfToken={csrfToken} />}
</VStack>
</Layout>
);
}
const RenderHeader: FC<{response: LimitConfigResponse}> = ({response}) => {
const description = response.self_hosted
? 'Self-hosted instance with all premium features enabled by default. Configure limits to customize user and guild restrictions.'
: 'Configure limit rules that control user and guild restrictions. Different rules apply based on user traits (like premium) or guild features.';
return (
<Card padding="md">
<VStack gap={2}>
<Heading level={3} size="base">
Limit Configuration
</Heading>
<Text color="muted">{description}</Text>
</VStack>
</Card>
);
};
const RenderRuleTabs: FC<{config: Config; rules: Array<LimitRule>; activeRuleId: string}> = ({
config,
rules,
activeRuleId,
}) => {
return (
<HStack gap={2}>
<HStack gap={2}>
{rules.map((rule) => {
const isActive = rule.id === activeRuleId;
const modifiedCount = rule.modifiedFields?.length ?? 0;
const modifiedBadge =
modifiedCount > 0 ? <RuleModifiedBadge count={modifiedCount} isActive={isActive} /> : null;
return (
<Button
href={`${config.basePath}/limit-config?rule=${rule.id}`}
variant={isActive ? 'primary' : 'secondary'}
size="small"
>
<HStack gap={2}>
{formatRuleName(rule)}
{modifiedBadge}
</HStack>
</Button>
);
})}
</HStack>
<Button
type="button"
variant="secondary"
size="small"
onclick="document.getElementById('create-rule-modal').classList.remove('hidden')"
>
<Text size="sm">+ Create New Rule</Text>
</Button>
</HStack>
);
};
function formatRuleName(rule: LimitRule): string {
return rule.id.charAt(0).toUpperCase() + rule.id.slice(1);
}
const RuleModifiedBadge: FC<{count: number; isActive: boolean}> = ({count, isActive}) => {
if (isActive) {
return <ActiveModifiedBadge count={count} />;
}
return <InactiveModifiedBadge count={count} />;
};
const ActiveModifiedBadge: FC<{count: number}> = ({count}) => {
return (
<Text size="xs" class="rounded-full bg-white/20 px-1.5 py-0.5 text-white">
{count} modified
</Text>
);
};
const InactiveModifiedBadge: FC<{count: number}> = ({count}) => {
return (
<Text size="xs" class="rounded-full bg-neutral-100 px-1.5 py-0.5 text-neutral-700">
{count} modified
</Text>
);
};
const RenderRuleHeader: FC<{config: Config; rule: LimitRule; canDelete: boolean; csrfToken: string}> = ({
config,
rule,
canDelete,
csrfToken,
}) => {
return (
<Card padding="md">
<FlexRowBetween>
<VStack gap={2}>
<HStack gap={2}>
<RuleTitle rule={rule} />
<RuleId ruleId={rule.id} />
</HStack>
{rule.filters && <RuleFilters filters={rule.filters} />}
</VStack>
{canDelete && (
<form
method="post"
action={`${config.basePath}/limit-config?action=delete&rule=${rule.id}`}
onsubmit="return confirm('Are you sure you want to delete this rule? This action cannot be undone.');"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="danger" size="small">
Delete Rule
</Button>
</form>
)}
</FlexRowBetween>
</Card>
);
};
const RuleTitle: FC<{rule: LimitRule}> = ({rule}) => {
return (
<Heading level={3} size="lg">
{formatRuleName(rule)}
</Heading>
);
};
const RuleId: FC<{ruleId: string}> = ({ruleId}) => {
return (
<Text size="sm" color="muted">
ID: {ruleId}
</Text>
);
};
const RuleFilters: FC<{filters: {traits?: Array<string>; guildFeatures?: Array<string>}}> = ({filters}) => {
return (
<VStack gap={1}>
{filters.traits && filters.traits.length > 0 && (
<Text size="sm" color="muted">
Traits: {filters.traits.join(', ')}
</Text>
)}
{filters.guildFeatures && filters.guildFeatures.length > 0 && (
<Text size="sm" color="muted">
Guild Features: {filters.guildFeatures.join(', ')}
</Text>
)}
</VStack>
);
};
const RenderFilterInputs: FC<{rule: LimitRule; editable: boolean}> = ({rule, editable}) => {
if (!editable) return null;
const traitValues = rule.filters?.traits ?? [];
const featureValues = rule.filters?.guildFeatures ?? [];
const traitValueText = traitValues.join(', ');
const featureValueText = featureValues.join(', ');
return (
<Card padding="md">
<VStack gap={4}>
<VStack gap={2}>
<VStack gap={1}>
<Label>User Traits (Optional)</Label>
<Text size="xs" color="muted">
Separate values with commas; leave blank to disable this filter.
</Text>
</VStack>
<Input type="text" name="traits" value={traitValueText} placeholder="e.g., premium, supporter" />
</VStack>
<VStack gap={2}>
<VStack gap={1}>
<Label>Guild Features (Optional)</Label>
<Text size="xs" color="muted">
Separate values with commas; leave blank to disable this filter.
</Text>
</VStack>
<Input
type="text"
name="guild_features"
value={featureValueText}
placeholder="e.g., VIP_SERVERS, BOOSTER_LEVEL_2"
/>
</VStack>
</VStack>
</Card>
);
};
const RenderRuleEditor: FC<{
config: Config;
response: LimitConfigResponse;
ruleId: string;
canUpdate: boolean;
csrfToken: string;
}> = ({config, response, ruleId, canUpdate, csrfToken}) => {
const rule = response.limit_config.rules.find((r) => r.id === ruleId);
if (!rule) {
return (
<Card padding="md">
<Text color="muted">Rule not found: {ruleId}</Text>
</Card>
);
}
const keysByCategory = getKeysByCategory(response);
const canDelete = canUpdate && ruleId !== 'default';
const formId = `limit-config-form-${ruleId}`;
const ruleHeader = canUpdate ? (
<RenderRuleHeader config={config} rule={rule} canDelete={canDelete} csrfToken={csrfToken} />
) : null;
if (canUpdate) {
return (
<VStack gap={6}>
{ruleHeader}
<form id={formId} method="post" action={`${config.basePath}/limit-config?action=update&rule=${ruleId}`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="rule_id" value={ruleId} />
<VStack gap={6}>
<RenderFilterInputs rule={rule} editable={true} />
{CATEGORY_ORDER.map((category) => {
const keys = keysByCategory[category];
if (!keys) return null;
return (
<RenderCategorySection
response={response}
rule={rule}
category={category}
keys={keys}
editable={true}
formId={formId}
/>
);
})}
<RenderSubmitSection />
</VStack>
</form>
</VStack>
);
}
return (
<VStack gap={6}>
{ruleHeader}
{CATEGORY_ORDER.map((category) => {
const keys = keysByCategory[category];
if (!keys) return null;
return (
<RenderCategorySection
response={response}
rule={rule}
category={category}
keys={keys}
editable={false}
formId={formId}
/>
);
})}
</VStack>
);
};
const RenderCategorySection: FC<{
response: LimitConfigResponse;
rule: LimitRule;
category: string;
keys: Array<string>;
editable: boolean;
formId: string;
}> = ({response, rule, category, keys, editable, formId}) => {
const categoryLabel = response.categories[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
return (
<Card padding="md">
<Heading level={3} size="base" class="mb-4">
{categoryLabel}
</Heading>
<VStack gap={4}>
{keys.map((key) => {
const metadata = response.metadata[key];
if (!metadata) return null;
return (
<RenderLimitField
response={response}
rule={rule}
limitKey={key}
metadata={metadata}
editable={editable}
formId={formId}
/>
);
})}
</VStack>
</Card>
);
};
const RenderLimitField: FC<{
response: LimitConfigResponse;
rule: LimitRule;
limitKey: string;
metadata: LimitKeyMetadata;
editable: boolean;
formId: string;
}> = ({response, rule, limitKey, metadata, editable, formId}) => {
const currentValue = rule.limits[limitKey] ?? null;
const defaultValue = getDefaultValue(response, rule.id, limitKey);
const modified = isModified(rule, limitKey);
if (metadata.isToggle) {
return (
<RenderToggleField
limitKey={limitKey}
metadata={metadata}
currentValue={currentValue}
isModified={modified}
editable={editable}
/>
);
}
return (
<RenderNumericField
limitKey={limitKey}
metadata={metadata}
currentValue={currentValue}
defaultValue={defaultValue}
isModified={modified}
editable={editable}
formId={formId}
/>
);
};
const RenderToggleField: FC<{
limitKey: string;
metadata: LimitKeyMetadata;
currentValue: number | null;
isModified: boolean;
editable: boolean;
}> = ({limitKey, metadata, currentValue, isModified, editable}) => {
const isEnabled = currentValue !== null && currentValue > 0;
return (
<ToggleFieldContainer isModified={isModified}>
<ToggleFieldLabel limitKey={limitKey} metadata={metadata} isModified={isModified} />
<HStack gap={2}>
{editable ? <ToggleSwitch limitKey={limitKey} isEnabled={isEnabled} /> : <ToggleStatus isEnabled={isEnabled} />}
</HStack>
</ToggleFieldContainer>
);
};
const RenderNumericField: FC<{
limitKey: string;
metadata: LimitKeyMetadata;
currentValue: number | null;
defaultValue: number | null;
isModified: boolean;
editable: boolean;
formId: string;
}> = ({limitKey, metadata, currentValue, defaultValue, isModified, editable, formId}) => {
const valueStr = currentValue !== null ? currentValue.toString() : '';
const placeholder = defaultValue !== null ? formatValueWithUnit(defaultValue, metadata.unit) : '';
return (
<NumericFieldContainer isModified={isModified}>
<NumericFieldHeader
limitKey={limitKey}
metadata={metadata}
isModified={isModified}
editable={editable}
defaultValue={defaultValue}
formId={formId}
/>
<NumericFieldDescription description={metadata.description} limitKey={limitKey} />
<HStack gap={3}>
{editable ? (
<NumericInput limitKey={limitKey} valueStr={valueStr} placeholder={placeholder} />
) : (
<NumericValue currentValue={currentValue} unit={metadata.unit} />
)}
{defaultValue !== null && <DefaultValueLabel defaultValue={defaultValue} unit={metadata.unit} />}
</HStack>
</NumericFieldContainer>
);
};
const NumericFieldDescription: FC<{description: string; limitKey: string}> = ({description, limitKey}) => {
const bounds = LIMIT_KEY_BOUNDS[limitKey as keyof typeof LIMIT_KEY_BOUNDS];
return (
<Caption variant="default" class="mb-2">
{description}
{bounds && ` (Allowed: ${bounds.min}\u2013${bounds.max})`}
</Caption>
);
};
const NumericInput: FC<{limitKey: string; valueStr: string; placeholder: string}> = ({
limitKey,
valueStr,
placeholder,
}) => {
const bounds = LIMIT_KEY_BOUNDS[limitKey as keyof typeof LIMIT_KEY_BOUNDS];
return (
<Input
type="number"
name={limitKey}
id={limitKey}
value={valueStr}
placeholder={placeholder}
min={bounds ? String(bounds.min) : '0'}
max={bounds ? String(bounds.max) : undefined}
class="max-w-xs"
/>
);
};
const NumericValue: FC<{currentValue: number | null; unit: string | null | undefined}> = ({currentValue, unit}) => {
return (
<Text size="sm" class="font-mono">
{currentValue !== null ? formatValueWithUnit(currentValue, unit) : '-'}
</Text>
);
};
const DefaultValueLabel: FC<{defaultValue: number; unit: string | null | undefined}> = ({defaultValue, unit}) => {
return <Caption variant="default">Default: {formatValueWithUnit(defaultValue, unit)}</Caption>;
};
const RenderScopeBadge: FC<{scope: string}> = ({scope}) => {
let label: string;
let color: string;
switch (scope) {
case 'user':
label = 'User';
color = 'bg-blue-100 text-blue-700';
break;
case 'guild':
label = 'Guild';
color = 'bg-purple-100 text-purple-700';
break;
case 'both':
label = 'Both';
color = 'bg-neutral-100 text-neutral-600';
break;
default:
label = scope;
color = 'bg-neutral-100 text-neutral-600';
}
return (
<Text size="xs" class={`rounded px-1.5 py-0.5 ${color}`}>
{label}
</Text>
);
};
const ToggleFieldContainer: FC<{isModified: boolean; children: Child}> = ({isModified, children}) => {
return (
<HStack
gap={2}
justify="between"
class={`rounded-lg border p-3 ${isModified ? 'border-neutral-200 bg-neutral-50' : 'border-neutral-200 bg-white'}`}
>
{children}
</HStack>
);
};
const ToggleFieldLabel: FC<{limitKey: string; metadata: LimitKeyMetadata; isModified: boolean}> = ({
limitKey,
metadata,
isModified,
}) => {
return (
<VStack gap={1} class="flex-1">
<HStack gap={2}>
<FieldLabel limitKey={limitKey} label={metadata.label} />
<RenderScopeBadge scope={metadata.scope} />
{isModified && <ModifiedIndicator />}
</HStack>
<FieldDescription description={metadata.description} />
</VStack>
);
};
const FieldLabel: FC<{limitKey: string; label: string}> = ({limitKey, label}) => {
return (
<Label htmlFor={limitKey} class="font-medium text-neutral-900 text-sm">
{label}
</Label>
);
};
const ModifiedIndicator: FC = () => {
return (
<Text size="xs" class="rounded bg-neutral-100 px-1.5 py-0.5 text-neutral-700">
Modified
</Text>
);
};
const FieldDescription: FC<{description: string}> = ({description}) => {
return (
<Caption variant="default" class="mt-0.5">
{description}
</Caption>
);
};
const ToggleSwitch: FC<{limitKey: string; isEnabled: boolean}> = ({limitKey, isEnabled}) => {
return (
<label class="relative inline-flex cursor-pointer items-center">
<input type="checkbox" name={limitKey} id={limitKey} value="1" checked={isEnabled} class="peer sr-only" />
<div class="peer h-6 w-11 rounded-full bg-neutral-200 after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-neutral-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300" />
</label>
);
};
const ToggleStatus: FC<{isEnabled: boolean}> = ({isEnabled}) => {
return (
<Text size="sm" weight="medium" color={isEnabled ? 'success' : 'muted'}>
{isEnabled ? 'Enabled' : 'Disabled'}
</Text>
);
};
const NumericFieldContainer: FC<{isModified: boolean; children: Child}> = ({isModified, children}) => {
return (
<VStack
gap={2}
class={`rounded-lg border p-3 ${isModified ? 'border-neutral-200 bg-neutral-50' : 'border-neutral-200 bg-white'}`}
>
{children}
</VStack>
);
};
const NumericFieldHeader: FC<{
limitKey: string;
metadata: LimitKeyMetadata;
isModified: boolean;
editable: boolean;
defaultValue: number | null;
formId: string;
}> = ({limitKey, metadata, isModified, editable, defaultValue, formId}) => {
return (
<HStack gap={2} justify="between" class="mb-2">
<HStack gap={2}>
<FieldLabel limitKey={limitKey} label={metadata.label} />
<RenderScopeBadge scope={metadata.scope} />
{isModified && <ModifiedIndicator />}
</HStack>
{isModified && editable && <ResetButton limitKey={limitKey} defaultValue={defaultValue} formId={formId} />}
</HStack>
);
};
const ResetButton: FC<{limitKey: string; defaultValue: number | null; formId: string}> = ({
limitKey,
defaultValue,
formId,
}) => {
return (
<Button
type="button"
variant="ghost"
size="small"
class="text-neutral-700 text-xs hover:text-neutral-900"
onclick={buildResetScript(limitKey, defaultValue, formId)}
>
Reset to default
</Button>
);
};
function buildResetScript(limitKey: string, defaultValue: number | null, formId: string): string {
const valueLiteral = JSON.stringify(defaultValue !== null ? defaultValue.toString() : '');
const selectorLiteral = JSON.stringify(`#${formId} input[name="${limitKey}"]`);
return `(function(){const field=document.querySelector(${selectorLiteral});if(!field)return;field.value=${valueLiteral};field.dispatchEvent(new Event('input',{bubbles:true}));})();`;
}
const RenderSubmitSection: FC = () => {
return (
<VStack class="sticky bottom-0 -mx-4 -mb-4 border-neutral-200 border-t bg-neutral-50 px-4 py-4">
<HStack justify="end">
<Button type="submit" variant="primary">
Save Changes
</Button>
</HStack>
</VStack>
);
};
const RenderCreateRuleModal: FC<{config: Config; response: LimitConfigResponse; csrfToken: string}> = ({
config,
response,
csrfToken,
}) => {
const existingIds = response.limit_config.rules.map((r) => r.id).sort();
return (
<div id="create-rule-modal" class="fixed inset-0 z-50 flex hidden items-center justify-center bg-black/50">
<VStack class="mx-4 w-full max-w-md rounded-lg bg-white shadow-xl">
<VStack gap={6} class="p-6">
<HStack gap={2} justify="between">
<Heading level={3} size="lg">
Create New Limit Rule
</Heading>
<Button
type="button"
variant="ghost"
class="text-neutral-400 hover:text-neutral-600"
onclick="document.getElementById('create-rule-modal').classList.add('hidden')"
>
x
</Button>
</HStack>
<form method="post" action={`${config.basePath}/limit-config?action=create`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<VStack gap={2}>
<VStack gap={1}>
<Label htmlFor="new-rule-id">Rule ID</Label>
<Text size="xs" color="muted">
Unique identifier for this rule (e.g., 'supporter', 'vip')
</Text>
</VStack>
<Input
type="text"
id="new-rule-id"
name="rule_id"
required
placeholder="e.g., supporter, vip, custom"
/>
</VStack>
<VStack gap={2}>
<VStack gap={1}>
<Label htmlFor="new-rule-traits">User Traits (Optional)</Label>
<Text size="xs" color="muted">
Users with these traits will match this rule
</Text>
</VStack>
<Input type="text" id="new-rule-traits" name="traits" placeholder="e.g., premium, supporter" />
</VStack>
<VStack gap={2}>
<VStack gap={1}>
<Label htmlFor="new-rule-features">Guild Features (Optional)</Label>
<Text size="xs" color="muted">
Guilds with these features will match this rule
</Text>
</VStack>
<Input
type="text"
id="new-rule-features"
name="guild_features"
placeholder="e.g., VIP_SERVERS, BOOSTER_LEVEL_2"
/>
</VStack>
<VStack class="rounded bg-neutral-50 p-3">
<Caption variant="default">Existing rule IDs: {existingIds.join(', ')}</Caption>
</VStack>
<HStack gap={2} justify="end" class="pt-4">
<Button
type="button"
variant="secondary"
size="small"
onclick="document.getElementById('create-rule-modal').classList.add('hidden')"
>
Cancel
</Button>
<Button type="submit" variant="primary" size="small">
Create Rule
</Button>
</HStack>
</VStack>
</form>
</VStack>
</VStack>
</div>
);
};
function formatValueWithUnit(value: number, unit: string | null | undefined): string {
if (unit === 'bytes') {
return formatBytes(value);
}
return value.toString();
}
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) {
return `${Math.floor(bytes / 1_073_741_824)} GB`;
}
if (bytes >= 1_048_576) {
return `${Math.floor(bytes / 1_048_576)} MB`;
}
if (bytes >= 1024) {
return `${Math.floor(bytes / 1024)} KB`;
}
return `${bytes} B`;
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
export interface LoginPageProps {
config: Config;
errorMessage: string | undefined;
}
export function LoginPage({config, errorMessage}: LoginPageProps) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login ~ Fluxer Admin</title>
<link rel="stylesheet" href={`${config.basePath}/static/app.css`} />
</head>
<body class="flex min-h-screen items-center justify-center bg-neutral-50 p-4">
<VStack gap={4} class="w-full max-w-sm">
<Card padding="lg">
<VStack gap={8}>
<VStack gap={2} align="center">
<Heading level={1} size="xl">
Fluxer Admin
</Heading>
</VStack>
{errorMessage && <ErrorAlert error={errorMessage} />}
<Button href={`${config.basePath}/auth/start`} variant="primary" fullWidth>
Sign in with Fluxer
</Button>
</VStack>
</Card>
</VStack>
</body>
</html>
);
}

View File

@@ -0,0 +1,383 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {type LookupMessageResponse, lookupMessage, lookupMessageByAttachment} from '@fluxer/admin/src/api/Messages';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {EmptyState} from '@fluxer/admin/src/components/ui/EmptyState';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {CSRF_FORM_FIELD} from '@fluxer/constants/src/Cookies';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {formatUserTag} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
interface MessagesPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
lookupResult?: LookupMessageResponse | undefined;
prefillChannelId?: string | undefined;
assetVersion: string;
csrfToken: string;
}
function hasPermission(acls: Array<string>, permission: string): boolean {
return acls.includes(permission) || acls.includes('*');
}
const MessageList: FC<{config: Config; messages: LookupMessageResponse['messages']; showDelete: boolean}> = ({
messages,
showDelete,
}) => {
return (
<VStack gap={4}>
{messages.map((msg) => (
<Card padding="md" class="bg-neutral-50" data-message-id={msg.id} data-channel-id={msg.channel_id}>
<HStack gap={4} align="start" justify="between">
<VStack gap={2} class="min-w-0 flex-1">
<HStack gap={2} align="center">
<Text size="sm" weight="medium">
{formatUserTag(msg.author_username, msg.author_discriminator)}
</Text>
<Text size="xs" color="muted">
({msg.author_id})
</Text>
<Text size="xs" color="muted">
{msg.timestamp}
</Text>
</HStack>
{msg.content && (
<Text size="sm" class="whitespace-pre-wrap break-words">
{msg.content}
</Text>
)}
{msg.attachments.length > 0 && (
<VStack gap={1}>
{msg.attachments.map((att) => (
<Text size="xs" color="muted">
<a href={att.url} target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">
{att.filename}
</a>
</Text>
))}
</VStack>
)}
</VStack>
{showDelete && (
<Button
type="button"
variant="danger"
size="small"
class="delete-message-btn"
data-channel-id={msg.channel_id}
data-message-id={msg.id}
>
Delete
</Button>
)}
</HStack>
<Caption class="mt-2">
Channel: {msg.channel_id} | Message: {msg.id}
</Caption>
</Card>
))}
</VStack>
);
};
const LookupResult: FC<{config: Config; result: LookupMessageResponse}> = ({config, result}) => {
return (
<Card padding="md">
<Heading level={3} class="mb-4">
Lookup Result
</Heading>
<VStack gap={4} class="border-neutral-200 border-b pb-4">
<HStack gap={1}>
<Text size="sm" color="muted">
Searched for:
</Text>
<Text size="sm">{result.message_id}</Text>
</HStack>
</VStack>
{result.messages.length === 0 ? (
<EmptyState variant="empty">No messages found.</EmptyState>
) : (
<MessageList config={config} messages={result.messages} showDelete={true} />
)}
</Card>
);
};
const LookupMessageForm: FC<{config: Config; prefillChannelId: string | undefined; csrfToken: string}> = ({
config,
prefillChannelId,
csrfToken,
}) => {
return (
<Card padding="md">
<Heading level={3} class="mb-4">
Lookup Message
</Heading>
<form method="post" action={`${config.basePath}/messages?action=lookup`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<FormFieldGroup label="Channel ID" htmlFor="lookup-message-channel-id">
<Input
id="lookup-message-channel-id"
type="text"
name="channel_id"
placeholder="123456789"
required
value={prefillChannelId}
/>
</FormFieldGroup>
<FormFieldGroup label="Message ID" htmlFor="lookup-message-message-id">
<Input id="lookup-message-message-id" type="text" name="message_id" placeholder="123456789" required />
</FormFieldGroup>
<FormFieldGroup label="Context Limit (messages before and after)" htmlFor="lookup-message-context-limit">
<Input id="lookup-message-context-limit" type="number" name="context_limit" value="50" required />
</FormFieldGroup>
<Button type="submit" variant="primary">
Lookup Message
</Button>
</VStack>
</form>
</Card>
);
};
const LookupByAttachmentForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => {
return (
<Card padding="md">
<Heading level={3} class="mb-4">
Lookup Message by Attachment
</Heading>
<form method="post" action={`${config.basePath}/messages?action=lookup-by-attachment`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<FormFieldGroup label="Channel ID" htmlFor="lookup-by-attachment-channel-id">
<Input
id="lookup-by-attachment-channel-id"
type="text"
name="channel_id"
placeholder="123456789"
required
/>
</FormFieldGroup>
<FormFieldGroup label="Attachment ID" htmlFor="lookup-by-attachment-attachment-id">
<Input
id="lookup-by-attachment-attachment-id"
type="text"
name="attachment_id"
placeholder="123456789"
required
/>
</FormFieldGroup>
<FormFieldGroup label="Filename" htmlFor="lookup-by-attachment-filename">
<Input id="lookup-by-attachment-filename" type="text" name="filename" placeholder="image.png" required />
</FormFieldGroup>
<FormFieldGroup
label="Context Limit (messages before and after)"
htmlFor="lookup-by-attachment-context-limit"
>
<Input id="lookup-by-attachment-context-limit" type="number" name="context_limit" value="50" required />
</FormFieldGroup>
<Button type="submit" variant="primary">
Lookup by Attachment
</Button>
</VStack>
</form>
</Card>
);
};
const DeleteMessageForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => {
return (
<Card padding="md">
<Heading level={3} class="mb-4">
Delete Message
</Heading>
<form
method="post"
action={`${config.basePath}/messages?action=delete`}
onsubmit="return confirm('Are you sure you want to delete this message?')"
>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<FormFieldGroup label="Channel ID" htmlFor="delete-message-channel-id">
<Input id="delete-message-channel-id" type="text" name="channel_id" placeholder="123456789" required />
</FormFieldGroup>
<FormFieldGroup label="Message ID" htmlFor="delete-message-message-id">
<Input id="delete-message-message-id" type="text" name="message_id" placeholder="123456789" required />
</FormFieldGroup>
<FormFieldGroup label="Audit Log Reason (optional)" htmlFor="delete-message-audit-log-reason">
<Input
id="delete-message-audit-log-reason"
type="text"
name="audit_log_reason"
placeholder="Reason for deletion"
/>
</FormFieldGroup>
<Button type="submit" variant="danger">
Delete Message
</Button>
</VStack>
</form>
</Card>
);
};
function createDeletionScript(csrfToken: string): string {
return `
document.addEventListener('click', async function(e) {
if (!e.target.classList.contains('delete-message-btn')) return;
const btn = e.target;
const channelId = btn.dataset.channelId;
const messageId = btn.dataset.messageId;
const csrfToken = ${JSON.stringify(csrfToken)};
if (!confirm('Are you sure you want to delete this message?')) return;
btn.disabled = true;
btn.textContent = 'Deleting...';
try {
const form = new FormData();
form.append('channel_id', channelId);
form.append('message_id', messageId);
form.append('${CSRF_FORM_FIELD}', csrfToken);
const response = await fetch(window.location.pathname + '?action=delete', {
method: 'POST',
body: form
});
const result = await response.json();
if (result.success) {
const messageDiv = btn.closest('[data-message-id]');
if (messageDiv) {
messageDiv.style.opacity = '0.5';
messageDiv.style.pointerEvents = 'none';
}
btn.textContent = 'Deleted';
} else {
btn.textContent = 'Failed';
btn.disabled = false;
}
} catch (err) {
btn.textContent = 'Error';
btn.disabled = false;
}
});
`;
}
export async function MessagesPage({
config,
session,
currentAdmin,
flash,
adminAcls,
lookupResult,
prefillChannelId,
assetVersion,
csrfToken,
}: MessagesPageProps) {
return (
<Layout
csrfToken={csrfToken}
title="Message Tools"
activePage="message-tools"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
extraScripts={createDeletionScript(csrfToken)}
>
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<Heading level={1}>Message Tools</Heading>
{lookupResult && <LookupResult config={config} result={lookupResult} />}
{hasPermission(adminAcls, 'message:lookup') && (
<LookupMessageForm config={config} prefillChannelId={prefillChannelId} csrfToken={csrfToken} />
)}
{hasPermission(adminAcls, 'message:lookup') && (
<LookupByAttachmentForm config={config} csrfToken={csrfToken} />
)}
{hasPermission(adminAcls, 'message:delete') && <DeleteMessageForm config={config} csrfToken={csrfToken} />}
</VStack>
</PageLayout>
</Layout>
);
}
export async function handleMessagesGet(
config: Config,
session: Session,
_currentAdmin: UserAdminResponse | undefined,
_flash: Flash | undefined,
_adminAcls: Array<string>,
_assetVersion: string,
query: Record<string, string>,
): Promise<{lookupResult?: LookupMessageResponse; prefillChannelId: string | undefined}> {
const channelId = query['channel_id'];
const messageId = query['message_id'];
const attachmentId = query['attachment_id'];
const filename = query['filename'];
const contextLimit = parseInt(query['context_limit'] || '50', 10) || 50;
if (channelId && attachmentId && filename) {
const result = await lookupMessageByAttachment(config, session, channelId, attachmentId, filename, contextLimit);
if (result.ok) {
return {lookupResult: result.data, prefillChannelId: channelId};
}
return {prefillChannelId: channelId};
}
if (channelId && messageId) {
const result = await lookupMessage(config, session, channelId, messageId, contextLimit);
if (result.ok) {
return {lookupResult: result.data, prefillChannelId: channelId};
}
return {prefillChannelId: channelId};
}
return {prefillChannelId: channelId};
}

View File

@@ -0,0 +1,497 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {Report} from '@fluxer/admin/src/api/Reports';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {createMessageDeletionScriptBody, MessageList} from '@fluxer/admin/src/components/MessageList';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {NavLink} from '@fluxer/admin/src/components/ui/NavLink';
import {ResourceLink} from '@fluxer/admin/src/components/ui/ResourceLink';
import {TextLink} from '@fluxer/admin/src/components/ui/TextLink';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {FlexRow} from '@fluxer/ui/src/components/Layout';
import type {FC} from 'hono/jsx';
interface MessageContext {
id: string;
channel_id: string;
guild_id: string | null;
content: string;
timestamp: string;
attachments: Array<{filename: string; url: string}>;
author_id: string;
author_username: string;
author_discriminator: string;
}
export interface ReportDetailPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
report: Report;
csrfToken: string;
}
function formatTimestampLocal(timestamp: string): string {
return formatTimestamp(timestamp, 'en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function formatReportType(reportType: number): string {
switch (reportType) {
case 0:
return 'Message';
case 1:
return 'User';
case 2:
return 'Guild';
default:
return 'Unknown';
}
}
function buildMessageLookupHref(config: Config, channelId: string, messageId: string | null): string {
const params = new URLSearchParams();
params.set('channel_id', channelId);
params.set('context_limit', '50');
if (messageId) {
params.set('message_id', messageId);
}
return `${config.basePath}/messages?${params.toString()}`;
}
function formatReportedUserLabel(report: Report): string {
if (report.reported_user_tag) {
return report.reported_user_tag;
}
if (report.reported_user_username) {
const discriminator = report.reported_user_discriminator ?? '0000';
return `${report.reported_user_username}#${discriminator}`;
}
return `User ${report.reported_user_id ?? 'unknown'}`;
}
const InfoRow: FC<{label: string; value: string; mono?: boolean}> = ({label, value, mono}) => (
<VStack gap={1}>
<Caption>{label}</Caption>
<Text size="sm" class={mono ? 'font-mono' : ''}>
{value}
</Text>
</VStack>
);
const InfoRowWithLink: FC<{
label: string;
value: string;
href: string;
mono?: boolean;
}> = ({label, value, href, mono}) => (
<VStack gap={1}>
<Caption>{label}</Caption>
<TextLink href={href} mono={mono} class="body-sm">
{value}
</TextLink>
</VStack>
);
const InfoRowOpt: FC<{label: string; value: string | null; mono?: boolean}> = ({label, value, mono}) => (
<VStack gap={1}>
<Caption>{label}</Caption>
<Text size="sm" class={mono ? 'font-mono' : ''}>
{value ?? '\u2014'}
</Text>
</VStack>
);
const InfoRowOptWithLink: FC<{
config: Config;
label: string;
id: string | null;
name: string | null;
pathFn: (id: string) => string;
mono?: boolean;
}> = ({config, label, id, name, pathFn, mono}) => (
<VStack gap={1}>
<Caption>{label}</Caption>
{id ? (
<TextLink href={`${config.basePath}${pathFn(id)}`} mono={mono} class="body-sm">
{name ?? id}
</TextLink>
) : (
<Text size="sm" color="muted" class="italic">
{'\u2014'}
</Text>
)}
</VStack>
);
const BasicInfo: FC<{config: Config; report: Report}> = ({config, report}) => {
const reporterPrimary = report.reporter_tag ?? report.reporter_email ?? 'Anonymous';
return (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Basic Information</h2>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoRow label="Report ID" value={report.report_id} mono />
<InfoRow label="Reported At" value={formatTimestampLocal(report.reported_at)} />
<InfoRow label="Type" value={formatReportType(report.report_type)} />
<InfoRow label="Category" value={report.category ?? ''} />
{report.reporter_id ? (
<InfoRowWithLink
label="Reporter"
value={reporterPrimary}
href={`${config.basePath}/users/${report.reporter_id}`}
mono
/>
) : (
<InfoRow label="Reporter" value={reporterPrimary} />
)}
<InfoRowOpt label="Reporter Email" value={report.reporter_email} />
<InfoRowOpt label="Full Legal Name" value={report.reporter_full_legal_name} />
<InfoRowOpt label="Country of Residence" value={report.reporter_country_of_residence} />
<InfoRow
label="Status"
value={report.status === 0 ? 'Pending' : report.status === 1 ? 'Resolved' : 'Unknown'}
/>
</dl>
</div>
);
};
const ReportedEntity: FC<{config: Config; report: Report}> = ({config, report}) => {
const renderMessageReportEntity = () => (
<>
<InfoRowOptWithLink
config={config}
label="User"
id={report.reported_user_id}
name={formatReportedUserLabel(report)}
pathFn={(id) => `/users/${id}`}
mono
/>
{report.reported_message_id && report.reported_channel_id ? (
<InfoRowWithLink
label="Message ID"
value={report.reported_message_id}
href={buildMessageLookupHref(config, report.reported_channel_id, report.reported_message_id)}
mono
/>
) : (
<InfoRowOpt label="Message ID" value={report.reported_message_id} mono />
)}
{report.reported_channel_id ? (
<InfoRowWithLink
label="Channel ID"
value={report.reported_channel_id}
href={buildMessageLookupHref(config, report.reported_channel_id, report.reported_message_id)}
mono
/>
) : (
<InfoRowOpt label="Channel ID" value={report.reported_channel_id} mono />
)}
<InfoRowOpt label="Channel Name" value={report.reported_channel_name} />
<InfoRowOptWithLink
config={config}
label="Guild ID"
id={report.reported_guild_id}
name={report.reported_guild_id}
pathFn={(id) => `/guilds/${id}`}
mono
/>
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
</>
);
const renderUserReportEntity = () => (
<>
<InfoRowOptWithLink
config={config}
label="User"
id={report.reported_user_id}
name={formatReportedUserLabel(report)}
pathFn={(id) => `/users/${id}`}
mono
/>
<InfoRowOpt label="Guild Name" value={report.reported_guild_name} />
<InfoRowOptWithLink
config={config}
label="Guild ID"
id={report.reported_guild_id}
name={report.reported_guild_id}
pathFn={(id) => `/guilds/${id}`}
mono
/>
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
</>
);
const renderGuildReportEntity = () => (
<>
<InfoRowOptWithLink
config={config}
label="Guild"
id={report.reported_guild_id}
name={report.reported_guild_name}
pathFn={(id) => `/guilds/${id}`}
mono
/>
<InfoRowOpt label="Guild Invite Code" value={report.reported_guild_invite_code} />
</>
);
return (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Reported Entity</h2>
<dl class="grid grid-cols-1 gap-4">
{report.report_type === 0 && renderMessageReportEntity()}
{report.report_type === 1 && renderUserReportEntity()}
{report.report_type === 2 && renderGuildReportEntity()}
</dl>
</div>
);
};
const MessageContextList: FC<{config: Config; messages: Array<MessageContext>}> = ({config, messages}) => {
if (messages.length === 0) return null;
const mappedMessages = messages.map((msg) => ({
id: msg.id,
content: msg.content || '',
timestamp: formatTimestampLocal(msg.timestamp),
author_id: msg.author_id,
author_username: msg.author_username,
author_discriminator: msg.author_discriminator,
channel_id: msg.channel_id,
guild_id: msg.guild_id,
attachments: msg.attachments,
}));
return (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Message Context</h2>
<MessageList basePath={config.basePath} messages={mappedMessages} includeDeleteButton />
</div>
);
};
const AdditionalInfo: FC<{info: string}> = ({info}) => (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Additional Information</h2>
<p class="whitespace-pre-wrap text-neutral-700">{info}</p>
</div>
);
const StatusCard: FC<{config: Config; report: Report}> = ({config, report}) => (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Status</h2>
<div class="space-y-3">
<div class="text-center">
{report.status === 0 && (
<span class="subtitle rounded-lg bg-neutral-100 px-4 py-2 text-neutral-700">Pending</span>
)}
{report.status === 1 && <span class="subtitle rounded-lg bg-green-100 px-4 py-2 text-green-700">Resolved</span>}
{report.status !== 0 && report.status !== 1 && (
<span class="subtitle rounded-lg bg-neutral-100 px-4 py-2 text-neutral-700">Unknown</span>
)}
</div>
{report.resolved_at && (
<div class="body-sm text-neutral-600">
<span class="label">Resolved At: </span>
{formatTimestampLocal(report.resolved_at)}
</div>
)}
{report.resolved_by_admin_id && (
<div class="body-sm text-neutral-600">
<span class="label">Resolved By: </span>
<ResourceLink config={config} resourceType="user" resourceId={report.resolved_by_admin_id}>
{report.resolved_by_admin_id}
</ResourceLink>
</div>
)}
{report.public_comment && (
<div class="border-neutral-200 border-t pt-3">
<p class="body-sm mb-2 text-neutral-700">Public Comment:</p>
<p class="body-sm whitespace-pre-wrap text-neutral-600">{report.public_comment}</p>
</div>
)}
</div>
</div>
);
const ActionsCard: FC<{config: Config; report: Report; csrfToken: string}> = ({config, report, csrfToken}) => {
const renderResolveButton = () => {
if (report.status !== 0) return null;
return (
<form
method="post"
action={`${config.basePath}/reports/${report.report_id}/resolve`}
onsubmit="return confirm('Resolve this report?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="public_comment" value="" />
<Button type="submit" variant="primary">
Resolve Report
</Button>
</form>
);
};
const renderViewReportedEntityButton = () => {
if (report.report_type === 0 || report.report_type === 1) {
if (report.reported_user_id) {
return (
<NavLink href={`${config.basePath}/users/${report.reported_user_id}`} class="block w-full text-center">
View Reported User
</NavLink>
);
}
}
if (report.report_type === 2) {
if (report.reported_guild_id) {
return (
<NavLink href={`${config.basePath}/guilds/${report.reported_guild_id}`} class="block w-full text-center">
View Reported Guild
</NavLink>
);
}
}
return null;
};
const renderViewReporterButton = () => {
if (!report.reporter_id) return null;
return (
<NavLink href={`${config.basePath}/users/${report.reporter_id}`} class="block w-full text-center">
View Reporter
</NavLink>
);
};
const renderViewMutualDmButton = () => {
if (report.report_type !== 1) {
return null;
}
if (!report.mutual_dm_channel_id) {
return null;
}
return (
<NavLink
href={buildMessageLookupHref(config, report.mutual_dm_channel_id, null)}
class="block w-full text-center"
>
View Mutual DM Channel
</NavLink>
);
};
return (
<div class="rounded-lg border border-neutral-200 bg-white p-6">
<h2 class="title-sm mb-4 text-neutral-900">Actions</h2>
<div class="space-y-3">
{renderResolveButton()}
{renderViewReportedEntityButton()}
{renderViewReporterButton()}
{renderViewMutualDmButton()}
</div>
</div>
);
};
export function ReportDetailPage({
config,
session,
currentAdmin,
flash,
assetVersion,
report,
csrfToken,
}: ReportDetailPageProps) {
const messageContext = report.message_context ?? [];
const hasMessageContext = report.report_type === 0 && messageContext.length > 0;
return (
<Layout
csrfToken={csrfToken}
title="Report Details"
activePage="reports"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
extraScripts={hasMessageContext ? createMessageDeletionScriptBody(csrfToken) : undefined}
>
<div class="mx-auto max-w-5xl">
<div class="mb-6">
<FlexRow gap="4">
<NavLink href={`${config.basePath}/reports`}>{'\u2190'} Back to Reports</NavLink>
<Heading level={1}>Report Details</Heading>
</FlexRow>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<BasicInfo config={config} report={report} />
<ReportedEntity config={config} report={report} />
{hasMessageContext && <MessageContextList config={config} messages={messageContext} />}
{report.additional_info && <AdditionalInfo info={report.additional_info} />}
</div>
<div class="space-y-6">
<StatusCard config={config} report={report} />
<ActionsCard config={config} report={report} csrfToken={csrfToken} />
</div>
</div>
</div>
</Layout>
);
}
export function ReportDetailFragment({config, report}: {config: Config; report: Report}) {
const messageContext = report.message_context ?? [];
const hasMessageContext = report.report_type === 0 && messageContext.length > 0;
return (
<div data-report-fragment="" class="space-y-4">
<BasicInfo config={config} report={report} />
<ReportedEntity config={config} report={report} />
{hasMessageContext && <MessageContextList config={config} messages={messageContext} />}
{report.additional_info && <AdditionalInfo info={report.additional_info} />}
</div>
);
}

View File

@@ -0,0 +1,838 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {type Report, searchReports} from '@fluxer/admin/src/api/Reports';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {ResourceLink} from '@fluxer/admin/src/components/ui/ResourceLink';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Pill} from '@fluxer/ui/src/components/Badge';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card, CardEmpty} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox, Input, Select} from '@fluxer/ui/src/components/Form';
import {FlexRow, Stack} from '@fluxer/ui/src/components/Layout';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeaderCell,
TableRow,
} from '@fluxer/ui/src/components/Table';
import type {FC} from 'hono/jsx';
const REPORT_CATEGORY_OPTIONS: Array<{value: string; label: string}> = [
{value: 'harassment', label: 'Harassment or Bullying'},
{value: 'hate_speech', label: 'Hate Speech'},
{value: 'spam', label: 'Spam or Scam'},
{value: 'illegal_activity', label: 'Illegal Activity'},
{value: 'impersonation', label: 'Impersonation'},
{value: 'child_safety', label: 'Child Safety Concerns'},
{value: 'other', label: 'Other'},
{value: 'violent_content', label: 'Violent or Graphic Content'},
{value: 'nsfw_violation', label: 'NSFW Policy Violation'},
{value: 'doxxing', label: 'Sharing Personal Information'},
{value: 'self_harm', label: 'Self-Harm or Suicide'},
{value: 'malicious_links', label: 'Malicious Links'},
{value: 'spam_account', label: 'Spam Account'},
{value: 'underage_user', label: 'Underage User'},
{value: 'inappropriate_profile', label: 'Inappropriate Profile'},
{value: 'raid_coordination', label: 'Raid Coordination'},
{value: 'malware_distribution', label: 'Malware Distribution'},
{value: 'extremist_community', label: 'Extremist Community'},
];
interface ReportsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
query: string | undefined;
statusFilter: number | undefined;
typeFilter: number | undefined;
categoryFilter: string | undefined;
page: number;
limit: number;
sort: string | undefined;
csrfToken: string;
}
const QuickFilterChip: FC<{
config: Config;
label: string;
statusFilter: number | undefined;
typeFilter: number | undefined;
categoryFilter: string | undefined;
query: string | undefined;
sort: string | undefined;
limit: number;
}> = ({config, label, statusFilter, typeFilter, categoryFilter, query, sort, limit}) => {
const url = `/reports${buildPaginationUrl(0, {
q: query,
status: statusFilter,
type: typeFilter,
category: categoryFilter,
sort,
limit,
})}`;
return (
<Button href={`${config.basePath}${url}`} variant="ghost" size="small">
{label}
</Button>
);
};
const SelectionToolbar: FC = () => {
return (
<Card padding="sm" data-report-toolbar="true">
<div class="flex flex-wrap items-center justify-between gap-3">
<div data-report-select-all="true">
<Checkbox name="select-all" value="all" label="Select all on this page" />
</div>
<FlexRow gap="2">
<span data-report-selected-count="true" class="text-neutral-600 text-sm">
0 selected
</span>
<Button type="button" variant="success" size="small" data-report-bulk-resolve="true" disabled>
Resolve selected
</Button>
</FlexRow>
</div>
</Card>
);
};
const Filters: FC<{
config: Config;
query: string | undefined;
statusFilter: number | undefined;
typeFilter: number | undefined;
categoryFilter: string | undefined;
sort: string | undefined;
limit: number;
}> = ({config, query, statusFilter, typeFilter, categoryFilter, sort, limit}) => {
const statusOptions = [
{value: '', label: 'All'},
{value: '0', label: 'Pending'},
{value: '1', label: 'Resolved'},
];
const typeOptions = [
{value: '', label: 'All'},
{value: '0', label: 'Message'},
{value: '1', label: 'User'},
{value: '2', label: 'Guild'},
];
const categoryOptions = [{value: '', label: 'All'}, ...REPORT_CATEGORY_OPTIONS];
const sortOptions = [
{value: 'reported_at_desc', label: 'Newest first'},
{value: 'reported_at_asc', label: 'Oldest first'},
{value: 'status_asc', label: 'Status ascending'},
{value: 'status_desc', label: 'Status descending'},
];
const limitOptions = [
{value: '25', label: '25'},
{value: '50', label: '50'},
{value: '100', label: '100'},
{value: '150', label: '150'},
];
return (
<Card padding="md">
<form method="get">
<Stack gap="4">
<FlexRow gap="2">
<QuickFilterChip
config={config}
label="Pending"
statusFilter={0}
typeFilter={typeFilter}
categoryFilter={categoryFilter}
query={query}
sort={sort}
limit={limit}
/>
<QuickFilterChip
config={config}
label="Resolved"
statusFilter={1}
typeFilter={typeFilter}
categoryFilter={categoryFilter}
query={query}
sort={sort}
limit={limit}
/>
<QuickFilterChip
config={config}
label="Message"
statusFilter={statusFilter}
typeFilter={0}
categoryFilter={categoryFilter}
query={query}
sort={sort}
limit={limit}
/>
<QuickFilterChip
config={config}
label="User"
statusFilter={statusFilter}
typeFilter={1}
categoryFilter={categoryFilter}
query={query}
sort={sort}
limit={limit}
/>
<QuickFilterChip
config={config}
label="Guild"
statusFilter={statusFilter}
typeFilter={2}
categoryFilter={categoryFilter}
query={query}
sort={sort}
limit={limit}
/>
</FlexRow>
<Input
label="Search"
name="q"
id="reports-search"
type="text"
value={query ?? ''}
placeholder="Search by ID, reporter, category, or description..."
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<Select
label="Status"
name="status"
id="reports-status"
value={statusFilter?.toString() ?? ''}
options={statusOptions}
/>
<Select
label="Type"
name="type"
id="reports-type"
value={typeFilter?.toString() ?? ''}
options={typeOptions}
/>
<Select
label="Category"
name="category"
id="reports-category"
value={categoryFilter ?? ''}
options={categoryOptions}
/>
<Select
label="Sort"
name="sort"
id="reports-sort"
value={sort ?? 'reported_at_desc'}
options={sortOptions}
/>
<Select label="Page size" name="limit" id="reports-limit" value={limit.toString()} options={limitOptions} />
</div>
<FlexRow gap="2">
<Button type="submit" variant="primary">
Search & Filter
</Button>
<Button href={`${config.basePath}/reports`} variant="secondary">
Clear
</Button>
</FlexRow>
</Stack>
</form>
</Card>
);
};
function formatReportType(reportType: number): string {
switch (reportType) {
case 0:
return 'Message';
case 1:
return 'User';
case 2:
return 'Guild';
default:
return 'Unknown';
}
}
function getReportTypeTone(reportType: number): 'info' | 'purple' | 'orange' | 'neutral' {
switch (reportType) {
case 0:
return 'info';
case 1:
return 'purple';
case 2:
return 'orange';
default:
return 'neutral';
}
}
function formatUserTag(report: Report): string {
if (report.reported_user_tag) {
return report.reported_user_tag;
}
if (report.reported_user_username) {
const discriminator = report.reported_user_discriminator ?? '0000';
return `${report.reported_user_username}#${discriminator}`;
}
return `User ${report.reported_user_id ?? 'unknown'}`;
}
const ReporterCell: FC<{config: Config; report: Report}> = ({config, report}) => {
const primary = report.reporter_tag ?? report.reporter_email ?? 'Anonymous';
const detailValues: Array<string> = [];
if (report.reporter_full_legal_name) detailValues.push(report.reporter_full_legal_name);
if (report.reporter_country_of_residence) detailValues.push(report.reporter_country_of_residence);
return (
<div class="flex flex-col gap-1">
{report.reporter_id ? (
<ResourceLink config={config} resourceType="user" resourceId={report.reporter_id}>
{primary}
</ResourceLink>
) : (
<span class="text-neutral-900 text-sm">{primary}</span>
)}
{detailValues.length > 0 && (
<div class="flex flex-col gap-1 text-neutral-500 text-xs">
{detailValues.map((value) => (
<div>{value}</div>
))}
</div>
)}
</div>
);
};
const ReportedUserCell: FC<{config: Config; report: Report}> = ({config, report}) => {
const primaryText = formatUserTag(report);
if (report.reported_user_id) {
return (
<ResourceLink config={config} resourceType="user" resourceId={report.reported_user_id}>
{primaryText}
</ResourceLink>
);
}
return <span class="text-neutral-900 text-sm">{primaryText}</span>;
};
const ReportedGuildCell: FC<{config: Config; report: Report}> = ({config, report}) => {
if (!report.reported_guild_id) {
return <span class="text-neutral-400 text-sm italic">{'\u2014'}</span>;
}
const primaryName = report.reported_guild_name ?? `Guild ${report.reported_guild_id}`;
return (
<div class="flex flex-col gap-1">
<ResourceLink config={config} resourceType="guild" resourceId={report.reported_guild_id}>
{primaryName}
</ResourceLink>
{report.reported_guild_invite_code && (
<div class="text-neutral-500 text-xs">Invite: {report.reported_guild_invite_code}</div>
)}
</div>
);
};
const ReportedCell: FC<{config: Config; report: Report}> = ({config, report}) => {
switch (report.report_type) {
case 0:
case 1:
return <ReportedUserCell config={config} report={report} />;
case 2:
return <ReportedGuildCell config={config} report={report} />;
default:
return <span class="text-neutral-400 text-sm italic">Unknown</span>;
}
};
const StatusPill: FC<{reportId: string; status: number}> = ({reportId, status}) => {
const label = status === 0 ? 'Pending' : status === 1 ? 'Resolved' : 'Unknown';
const tone = status === 0 ? 'warning' : status === 1 ? 'success' : 'neutral';
return (
<span data-status-pill={reportId}>
<Pill label={label} tone={tone} />
</span>
);
};
const ActionsCell: FC<{config: Config; report: Report; csrfToken: string}> = ({config, report, csrfToken}) => {
return (
<Stack gap="2">
<FlexRow gap="2">
<Button href={`${config.basePath}/reports/${report.report_id}`} variant="primary" size="small">
View Details
</Button>
{report.status === 0 && (
<form
method="post"
action={`${config.basePath}/reports/${report.report_id}/resolve`}
data-report-action="resolve"
data-report-id={report.report_id}
data-confirm="Resolve this report?"
data-async="true"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="_method" value="post" />
<input type="hidden" name="public_comment" value="Resolved via reports table" />
<Button type="submit" variant="success" size="small">
Resolve
</Button>
</form>
)}
</FlexRow>
</Stack>
);
};
const ReportsTable: FC<{config: Config; reports: Array<Report>; csrfToken: string}> = ({
config,
reports,
csrfToken,
}) => {
return (
<div data-report-table="true">
<TableContainer>
<Table>
<TableHead>
<tr>
<th class="w-10 px-4 py-3"></th>
<TableHeaderCell label="Reported At" />
<TableHeaderCell label="Type" />
<TableHeaderCell label="Category" />
<TableHeaderCell label="Reporter" />
<TableHeaderCell label="Reported" />
<TableHeaderCell label="Status" />
<TableHeaderCell label="Actions" />
</tr>
</TableHead>
<TableBody>
{reports.map((report) => (
<TableRow>
<td class="w-10 px-4 py-4" data-report-select={report.report_id}>
<Checkbox name={`select-${report.report_id}`} value={report.report_id} label="" />
</td>
<TableCell>
<span class="whitespace-nowrap">{formatTimestamp(report.reported_at)}</span>
</TableCell>
<TableCell>
<Pill label={formatReportType(report.report_type)} tone={getReportTypeTone(report.report_type)} />
</TableCell>
<TableCell>{report.category}</TableCell>
<TableCell>
<ReporterCell config={config} report={report} />
</TableCell>
<TableCell>
<ReportedCell config={config} report={report} />
</TableCell>
<TableCell>
<StatusPill reportId={report.report_id} status={report.status} />
</TableCell>
<TableCell>
<ActionsCell config={config} report={report} csrfToken={csrfToken} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
const ReportsPagination: FC<{
config: Config;
total: number;
limit: number;
currentPage: number;
query: string | undefined;
statusFilter: number | undefined;
typeFilter: number | undefined;
categoryFilter: string | undefined;
sort: string | undefined;
}> = ({config, total, limit, currentPage, query, statusFilter, typeFilter, categoryFilter, sort}) => {
const totalPages = Math.ceil(total / limit);
const hasPrevious = currentPage > 0;
const hasNext = currentPage < totalPages - 1;
return (
<Stack gap="6">
<FlexRow gap="3">
{hasPrevious ? (
<Button
href={`${config.basePath}/reports${buildPaginationUrl(currentPage - 1, {
q: query,
status: statusFilter,
type: typeFilter,
category: categoryFilter,
sort,
limit,
})}`}
variant="secondary"
>
{'\u2190'} Previous
</Button>
) : (
<Button variant="secondary" disabled>
{'\u2190'} Previous
</Button>
)}
<span class="text-neutral-600 text-sm">
Page {currentPage + 1} of {totalPages}
</span>
{hasNext ? (
<Button
href={`${config.basePath}/reports${buildPaginationUrl(currentPage + 1, {
q: query,
status: statusFilter,
type: typeFilter,
category: categoryFilter,
sort,
limit,
})}`}
variant="primary"
>
Next {'\u2192'}
</Button>
) : (
<Button variant="primary" disabled>
Next {'\u2192'}
</Button>
)}
</FlexRow>
</Stack>
);
};
const EmptyState: FC = () => {
return (
<CardEmpty>
<Text color="muted">No reports found</Text>
<Text size="sm" color="muted">
Try adjusting your filters or check back later
</Text>
</CardEmpty>
);
};
function sortReports(reports: Array<Report>, sort?: string): Array<Report> {
const sortKey = sort ?? 'reported_at_desc';
return [...reports].sort((a, b) => {
switch (sortKey) {
case 'reported_at_asc':
return a.reported_at.localeCompare(b.reported_at);
case 'status_asc':
if (a.status !== b.status) return a.status - b.status;
return a.reported_at.localeCompare(b.reported_at);
case 'status_desc':
if (a.status !== b.status) return b.status - a.status;
return a.reported_at.localeCompare(b.reported_at);
default:
return b.reported_at.localeCompare(a.reported_at);
}
});
}
const REPORTS_SCRIPT = `
(function () {
const table = document.querySelector('[data-report-table]');
if (!table) return;
const toolbar = document.querySelector('[data-report-toolbar]');
const selectAllWrapper = toolbar?.querySelector('[data-report-select-all]') || null;
const selectAll = selectAllWrapper?.querySelector('input[type="checkbox"]') || null;
const countEl = toolbar?.querySelector('[data-report-selected-count]') || null;
const bulkBtn = toolbar?.querySelector('[data-report-bulk-resolve]') || null;
function showToast(message, ok) {
const box = document.createElement('div');
box.className = 'fixed left-4 right-4 bottom-4 z-50';
box.innerHTML =
'<div class="max-w-xl mx-auto">' +
'<div class="px-4 py-3 rounded-lg shadow border ' +
(ok ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800') +
'">' +
'<div class="text-sm font-semibold">' + (ok ? 'Success' : 'Action failed') + '</div>' +
'<div class="text-sm mt-1 break-words" data-toast-message=""></div>' +
'</div></div>';
const msgEl = box.querySelector('[data-toast-message]');
if (msgEl) msgEl.textContent = message || (ok ? 'Done' : 'Action failed');
document.body.appendChild(box);
setTimeout(() => box.remove(), 4000);
}
function selectionBoxes() {
return Array.from(table.querySelectorAll('[data-report-select] input[type="checkbox"]'));
}
function updateSelection() {
const boxes = selectionBoxes();
const selected = boxes.filter((b) => b.checked);
if (countEl) countEl.textContent = selected.length + ' selected';
if (bulkBtn) bulkBtn.disabled = selected.length === 0;
if (selectAll) {
selectAll.checked = selected.length > 0 && selected.length === boxes.length;
selectAll.indeterminate = selected.length > 0 && selected.length < boxes.length;
}
}
function setLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) {
btn.dataset.originalText = btn.textContent;
btn.textContent = 'Working...';
} else if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
async function submitForm(form) {
const actionUrl = new URL(form.action, window.location.origin);
actionUrl.searchParams.set('background', '1');
const fd = new FormData(form);
const body = new URLSearchParams();
fd.forEach((v, k) => body.append(k, v));
const resp = await fetch(actionUrl.toString(), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body.toString(),
credentials: 'same-origin',
});
if (!resp.ok && resp.status !== 204) {
let t = '';
try { t = await resp.text(); } catch (_) {}
throw new Error(t || 'Request failed (' + resp.status + ')');
}
}
function markResolved(reportId) {
const pill = table.querySelector('[data-status-pill="' + reportId + '"]');
if (pill) {
const inner = pill.querySelector('span');
if (inner) {
inner.textContent = 'Resolved';
inner.classList.remove('bg-yellow-100', 'text-yellow-700');
inner.classList.add('bg-green-100', 'text-green-700');
}
}
const form = table.querySelector('form[data-report-id="' + reportId + '"]');
if (form) form.remove();
}
async function resolveOne(reportId) {
const form = table.querySelector('form[data-report-id="' + reportId + '"][data-report-action="resolve"]');
if (!form) throw new Error('Missing resolve form');
await submitForm(form);
markResolved(reportId);
}
async function handleBulkResolve() {
const boxes = selectionBoxes().filter((b) => b.checked);
if (boxes.length === 0) return;
if (!window.confirm('Resolve ' + boxes.length + ' report(s)?')) return;
setLoading(bulkBtn, true);
try {
for (const box of boxes) {
const wrapper = box.closest('[data-report-select]');
const id = wrapper?.getAttribute('data-report-select');
if (!id) continue;
await resolveOne(id);
box.checked = false;
}
showToast('Resolved ' + boxes.length + ' report(s)', true);
} catch (err) {
showToast(err && err.message ? err.message : String(err), false);
} finally {
setLoading(bulkBtn, false);
updateSelection();
}
}
function wireSelection() {
if (selectAll) {
selectAll.addEventListener('change', (e) => {
selectionBoxes().forEach((b) => (b.checked = e.target.checked));
updateSelection();
});
}
table.addEventListener('change', (e) => {
const t = e.target;
if (t && t.matches('[data-report-select] input[type="checkbox"]')) updateSelection();
});
if (bulkBtn) {
bulkBtn.addEventListener('click', (e) => {
e.preventDefault();
handleBulkResolve();
});
}
updateSelection();
}
function wireAsyncForms() {
table.querySelectorAll('form[data-async]').forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
const confirmMsg = form.getAttribute('data-confirm');
if (confirmMsg && !window.confirm(confirmMsg)) return;
const btn = form.querySelector('button[type="submit"]');
const id = form.getAttribute('data-report-id') || form.querySelector('[name="report_id"]')?.value;
setLoading(btn, true);
submitForm(form)
.then(() => {
if (id) markResolved(id);
showToast('Resolved report', true);
})
.catch((err) => showToast(err && err.message ? err.message : String(err), false))
.finally(() => setLoading(btn, false));
});
});
}
wireSelection();
wireAsyncForms();
})();
`;
const ReportsScript: FC = () => {
return <script defer dangerouslySetInnerHTML={{__html: REPORTS_SCRIPT}} />;
};
export async function ReportsPage({
config,
session,
currentAdmin,
flash,
assetVersion,
query,
statusFilter,
typeFilter,
categoryFilter,
page,
limit,
sort,
csrfToken,
}: ReportsPageProps) {
const offset = page * limit;
const result = await searchReports(config, session, query, statusFilter, typeFilter, categoryFilter, limit, offset);
const content = result.ok ? (
(() => {
const sortedReports = sortReports(result.data.reports, sort);
return (
<div class="mx-auto max-w-7xl">
<PageHeader
title="Reports"
actions={
<Text size="sm" color="muted">
Found {result.data.total} results (showing {sortedReports.length})
</Text>
}
/>
<Stack gap="6">
<Filters
config={config}
query={query}
statusFilter={statusFilter}
typeFilter={typeFilter}
categoryFilter={categoryFilter}
sort={sort}
limit={limit}
/>
{sortedReports.length === 0 ? (
<EmptyState />
) : (
<Stack gap="4">
<SelectionToolbar />
<ReportsTable config={config} reports={sortedReports} csrfToken={csrfToken} />
<ReportsPagination
config={config}
total={result.data.total}
limit={limit}
currentPage={page}
query={query}
statusFilter={statusFilter}
typeFilter={typeFilter}
categoryFilter={categoryFilter}
sort={sort}
/>
</Stack>
)}
</Stack>
<ReportsScript />
</div>
);
})()
) : (
<div class="mx-auto max-w-7xl">
<PageHeader title="Reports" />
<Stack gap="6">
<Filters
config={config}
query={query}
statusFilter={statusFilter}
typeFilter={typeFilter}
categoryFilter={categoryFilter}
sort={sort}
limit={limit}
/>
<ErrorAlert error={getErrorMessage(result.error)} />
</Stack>
</div>
);
return (
<Layout
csrfToken={csrfToken}
title="Reports"
activePage="reports"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{content}
</Layout>
);
}

View File

@@ -0,0 +1,284 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {type ApiError, isNotFound} from '@fluxer/admin/src/api/Errors';
import {getIndexRefreshStatus} from '@fluxer/admin/src/api/Search';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {IndexRefreshStatusResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
export interface SearchIndexPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
jobId?: string;
assetVersion: string;
csrfToken: string;
}
function formatStatusLabel(status: string): string {
switch (status) {
case 'in_progress':
return 'In progress';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
}
function formatTimestampLocal(timestamp: string): string {
return formatTimestamp(timestamp, 'en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
const ReindexButton: FC<{config: Config; title: string; indexType: string; csrfToken: string}> = ({
config,
title,
indexType,
csrfToken,
}) => (
<form method="post" action={`${config.basePath}/search-index?action=reindex`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="index_type" value={indexType} />
<Button type="submit" variant="secondary" fullWidth>
Reindex {title}
</Button>
</form>
);
const DisabledReindexButton: FC<{title: string}> = ({title}) => (
<VStack>
<Button type="button" variant="secondary" fullWidth disabled>
Reindex {title}
</Button>
</VStack>
);
const ReindexControls: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => (
<Card padding="md">
<VStack gap={3}>
<Heading level={3} class="subtitle text-neutral-900">
Global Search Indexes
</Heading>
<ReindexButton config={config} title="Users" indexType="users" csrfToken={csrfToken} />
<ReindexButton config={config} title="Guilds" indexType="guilds" csrfToken={csrfToken} />
<ReindexButton config={config} title="Reports" indexType="reports" csrfToken={csrfToken} />
<ReindexButton config={config} title="Audit Logs" indexType="audit_logs" csrfToken={csrfToken} />
<Heading level={3} class="subtitle mt-6 text-neutral-900">
Guild-specific Search Indexes
</Heading>
<Text color="muted" size="sm" class="mb-3">
These indexes require a guild ID and can only be triggered from the guild detail page.
</Text>
<DisabledReindexButton title="Channel Messages" />
</VStack>
</Card>
);
interface IndexRefreshStatusWithDetails {
status: string;
total?: number;
indexed?: number;
started_at?: string;
completed_at?: string;
error?: string;
}
const StatusContent: FC<{status: IndexRefreshStatusWithDetails}> = ({status}) => {
const percentage =
status.status === 'in_progress' && status.total !== undefined && status.indexed !== undefined
? status.total === 0
? 0
: Math.floor((status.indexed * 100) / status.total)
: 0;
return (
<VStack gap={3}>
<Text size="sm" class="text-neutral-700">
Status: {formatStatusLabel(status.status)}
</Text>
{status.status === 'in_progress' && status.total !== undefined && status.indexed !== undefined && (
<VStack gap={2}>
<HStack justify="between">
<Text size="sm" class="text-neutral-700">
{status.indexed} / {status.total} ({percentage}%)
</Text>
</HStack>
<div class="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
<div class="h-2 bg-neutral-900 transition-[width] duration-300" style={`width: ${percentage}%`} />
</div>
</VStack>
)}
{status.status === 'completed' && status.total !== undefined && status.indexed !== undefined && (
<Text size="sm" class="text-neutral-700">
Indexed {status.indexed} / {status.total} items
</Text>
)}
{status.started_at && (
<Text size="xs" class="caption text-neutral-500">
Started {formatTimestampLocal(status.started_at)}
</Text>
)}
{status.completed_at && (
<Text size="xs" class="caption text-neutral-500">
Completed {formatTimestampLocal(status.completed_at)}
</Text>
)}
{status.error && (
<Text size="sm" color="danger">
{status.error}
</Text>
)}
</VStack>
);
};
function getErrorDetails(error: ApiError): {title: string; message: string} {
switch (error.type) {
case 'unauthorized':
return {title: 'Authentication Required', message: 'Your session has expired. Please log in again.'};
case 'forbidden':
return {title: 'Permission Denied', message: error.message};
case 'notFound':
return {title: 'Not Found', message: 'Status information not found.'};
case 'serverError':
return {title: 'Server Error', message: 'An internal server error occurred. Please try again later.'};
case 'networkError':
return {title: 'Network Error', message: 'Could not connect to the API. Please try again later.'};
case 'badRequest':
return {title: 'Validation Error', message: error.message};
case 'clientError':
return {title: 'Client Error', message: error.message};
case 'parseError':
return {title: 'Response Error', message: 'The server returned an invalid response.'};
case 'rateLimited':
return {title: 'Rate Limited', message: `Rate limited. Retry after ${error.retryAfter} seconds.`};
}
}
const StatusError: FC<{error: ApiError}> = ({error}) => {
const {title, message} = getErrorDetails(error);
return <ErrorCard title={title} message={message} />;
};
const StatusSection: FC<{
config: Config;
jobId: string;
statusResult: {ok: true; data: IndexRefreshStatusResponse} | {ok: false; error: ApiError};
}> = ({config, jobId: _jobId, statusResult}) => (
<Card padding="md">
<VStack gap={3}>
<HStack align="center" justify="between">
<Heading level={2} class="subtitle text-neutral-900">
Reindex progress
</Heading>
<a
href={`${config.basePath}/search-index`}
class="text-brand-primary text-sm hover:text-[color-mix(in_srgb,var(--brand-primary)_80%,black)] hover:underline"
>
Clear
</a>
</HStack>
{statusResult.ok ? (
statusResult.data.status === 'not_found' ? (
<Text size="sm" class="text-neutral-700">
Preparing job... check back in a moment.
</Text>
) : (
<StatusContent status={statusResult.data} />
)
) : isNotFound(statusResult.error) ? (
<Text size="sm" class="text-neutral-700">
Preparing job... check back in a moment.
</Text>
) : (
<StatusError error={statusResult.error} />
)}
</VStack>
</Card>
);
export async function SearchIndexPage({
config,
session,
currentAdmin,
flash,
jobId,
assetVersion,
csrfToken,
}: SearchIndexPageProps) {
let shouldAutoRefresh = false;
let statusResult: {ok: true; data: IndexRefreshStatusResponse} | {ok: false; error: ApiError} | null = null;
if (jobId) {
statusResult = await getIndexRefreshStatus(config, session, jobId);
if (statusResult.ok) {
shouldAutoRefresh = statusResult.data.status === 'in_progress' || statusResult.data.status === 'not_found';
} else if (isNotFound(statusResult.error)) {
shouldAutoRefresh = true;
}
}
return (
<Layout
csrfToken={csrfToken}
title="Search Management"
activePage="search-index"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
autoRefresh={shouldAutoRefresh}
assetVersion={assetVersion}
>
<PageLayout maxWidth="3xl">
<VStack gap={6}>
<Heading level={1}>Search Index Management</Heading>
<ReindexControls config={config} csrfToken={csrfToken} />
{jobId && statusResult && <StatusSection config={config} jobId={jobId} statusResult={statusResult} />}
</VStack>
</PageLayout>
</Layout>
);
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Layout} from '@fluxer/admin/src/components/Layout';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Card} from '@fluxer/ui/src/components/Card';
import type {FC} from 'hono/jsx';
export interface StrangePlacePageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
csrfToken: string;
pageName?: string;
}
export const StrangePlacePage: FC<StrangePlacePageProps> = ({
config,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
pageName,
}) => {
return (
<Layout
csrfToken={csrfToken}
title={pageName ?? 'Strange Place'}
activePage=""
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<VStack class="mx-auto max-w-2xl">
<Card padding="lg">
<Stack gap="md">
<HStack align="center" justify="center" class="mx-auto h-16 w-16 rounded-full bg-neutral-100">
<Text size="lg">?</Text>
</HStack>
<Heading level={2} size="lg" class="text-center">
{pageName ?? "You've reached a strange place"}
</Heading>
<Text class="text-center">
{pageName
? 'This page is under construction.'
: "You don't have access to any admin features. Contact an administrator to request access."}
</Text>
</Stack>
</Card>
</VStack>
</Layout>
);
};

View File

@@ -0,0 +1,296 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {InlineStack} from '@fluxer/admin/src/components/ui/InlineStack';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {SystemDmJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Pill} from '@fluxer/ui/src/components/Badge';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeaderCell,
TableRow,
} from '@fluxer/ui/src/components/Table';
import type {ColorTone} from '@fluxer/ui/src/utils/ColorVariants';
import type {FC} from 'hono/jsx';
const PAGE_PATH = '/system-dms';
interface SystemDmPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
jobs: Array<SystemDmJobResponse>;
nextCursor?: string | undefined;
loadError?: string | undefined;
formError?: string | undefined;
assetVersion: string;
csrfToken: string;
}
function getStatusTone(status: string): ColorTone {
switch (status) {
case 'pending':
return 'neutral';
case 'approved':
return 'info';
case 'running':
return 'success';
case 'completed':
return 'success';
case 'failed':
return 'danger';
default:
return 'neutral';
}
}
const JobFilters: FC<{job: SystemDmJobResponse}> = ({job}) => {
let registration: string;
if (job.registration_start && job.registration_end) {
registration = `${job.registration_start} -> ${job.registration_end}`;
} else if (job.registration_start) {
registration = `From ${job.registration_start}`;
} else if (job.registration_end) {
registration = `Until ${job.registration_end}`;
} else {
registration = 'Any time';
}
return (
<Stack gap="sm">
<Caption>Registration: {registration}</Caption>
{job.excluded_guild_ids.length > 0 && <Caption>Excluded guilds: {job.excluded_guild_ids.join(', ')}</Caption>}
</Stack>
);
};
const JobTimestamps: FC<{job: SystemDmJobResponse}> = ({job}) => {
return (
<Stack gap="sm">
<Caption>Created: {job.created_at}</Caption>
{job.approved_at ? <Caption>Approved: {job.approved_at}</Caption> : <Caption>Awaiting approval</Caption>}
</Stack>
);
};
const JobActions: FC<{job: SystemDmJobResponse; config: Config; csrfToken: string}> = ({job, config, csrfToken}) => {
if (job.status !== 'pending') return null;
return (
<form method="post" action={`${config.basePath}${PAGE_PATH}?action=approve`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="job_id" value={job.job_id} />
<Button type="submit" variant="success" fullWidth>
Approve
</Button>
</form>
);
};
const JobTableRows: FC<{jobs: Array<SystemDmJobResponse>; config: Config; csrfToken: string}> = ({
jobs,
config,
csrfToken,
}) => {
return (
<>
{jobs.map((job) => (
<TableRow>
<TableCell>{job.job_id}</TableCell>
<TableCell>
<Pill label={job.status} tone={getStatusTone(job.status)} />
</TableCell>
<TableCell>
<VStack gap={1}>
<Text size="sm">{job.target_count} recipients</Text>
<Text size="sm" color="muted">
{job.sent_count} sent, {job.failed_count} failed
</Text>
</VStack>
</TableCell>
<TableCell>
<JobFilters job={job} />
</TableCell>
<TableCell>
<JobTimestamps job={job} />
</TableCell>
<TableCell>
<JobActions job={job} config={config} csrfToken={csrfToken} />
</TableCell>
</TableRow>
))}
</>
);
};
export async function SystemDmPage({
config,
session,
currentAdmin,
flash,
adminAcls: _adminAcls,
jobs,
nextCursor,
loadError,
formError,
assetVersion,
csrfToken,
}: SystemDmPageProps) {
return (
<Layout
csrfToken={csrfToken}
title="System DMs"
activePage="system-dms"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<Stack gap="lg">
<Heading level={1}>System DMs</Heading>
<Card padding="lg">
<Stack gap="md">
<Heading level={2} size="base">
Schedule a system DM
</Heading>
{formError && <ErrorAlert error={formError} />}
<form method="post" action={`${config.basePath}${PAGE_PATH}?action=send`}>
<CsrfInput token={csrfToken} />
<Stack gap="md">
<FormFieldGroup label="Content" htmlFor="system-dm-content">
<Textarea id="system-dm-content" name="content" required rows={6} maxlength={4000} size="sm" />
</FormFieldGroup>
<Grid cols={2} gap="md">
<FormFieldGroup label="Registration start" htmlFor="system-dm-registration-start">
<Input
id="system-dm-registration-start"
type="datetime-local"
name="registration_start"
size="sm"
/>
</FormFieldGroup>
<FormFieldGroup label="Registration end" htmlFor="system-dm-registration-end">
<Input id="system-dm-registration-end" type="datetime-local" name="registration_end" size="sm" />
</FormFieldGroup>
</Grid>
<FormFieldGroup
label="Exclude guild IDs"
helper="Separate IDs with commas."
htmlFor="system-dm-excluded-guild-ids"
>
<Textarea
id="system-dm-excluded-guild-ids"
name="excluded_guild_ids"
rows={3}
placeholder="12345,67890"
size="sm"
/>
</FormFieldGroup>
<Button type="submit" variant="primary" fullWidth>
Create job
</Button>
</Stack>
</form>
</Stack>
</Card>
<Card padding="lg">
<Stack gap="md">
<Heading level={2} size="base">
Job history
</Heading>
{loadError && <ErrorAlert error={loadError} />}
<TableContainer>
<Table>
<TableHead>
<tr>
<TableHeaderCell label="Job" />
<TableHeaderCell label="Status" />
<TableHeaderCell label="Targets" />
<TableHeaderCell label="Filters" />
<TableHeaderCell label="Timestamps" />
<TableHeaderCell label="Actions" />
</tr>
</TableHead>
<TableBody>
{jobs.length === 0 ? (
<tr>
<td colSpan={6} class="px-6 py-4 text-center">
<EmptyState title="No system DM jobs have been created yet." />
</td>
</tr>
) : (
<JobTableRows jobs={jobs} config={config} csrfToken={csrfToken} />
)}
</TableBody>
</Table>
</TableContainer>
{nextCursor && (
<InlineStack gap={0} class="mt-4 justify-end">
<a
href={`${config.basePath}${PAGE_PATH}?before_job_id=${nextCursor}`}
class="font-medium text-neutral-900 text-sm hover:underline"
>
Load older jobs
</a>
</InlineStack>
)}
</Stack>
</Card>
</Stack>
</Layout>
);
}
export function parseExcludedGuildIds(value?: string): Array<string> {
if (!value) return [];
return value
.trim()
.replace(/\n/g, ',')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '');
}

View File

@@ -0,0 +1,350 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {ApiError} from '@fluxer/admin/src/api/Errors';
import {getLimitConfig} from '@fluxer/admin/src/api/LimitConfig';
import * as messagesApi from '@fluxer/admin/src/api/Messages';
import * as usersApi from '@fluxer/admin/src/api/Users';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {UserProfileBadges} from '@fluxer/admin/src/components/UserProfileBadges';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {AccountTab} from '@fluxer/admin/src/pages/user_detail/tabs/AccountTab';
import {DmHistoryTab} from '@fluxer/admin/src/pages/user_detail/tabs/DmHistoryTab';
import {GuildsTab} from '@fluxer/admin/src/pages/user_detail/tabs/GuildsTab';
import {ModerationTab} from '@fluxer/admin/src/pages/user_detail/tabs/ModerationTab';
import {OverviewTab} from '@fluxer/admin/src/pages/user_detail/tabs/OverviewTab';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {
ListUserChangeLogResponse,
ListUserDmChannelsResponse,
ListUserSessionsResponse,
UserAdminResponse,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {BackButton, NotFoundView} from '@fluxer/ui/src/components/Navigation';
import {formatDiscriminator, getUserAvatarUrl, getUserBannerUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
export interface UserDetailPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
userId: string;
tab: string | undefined;
assetVersion: string;
csrfToken: string;
guildsBefore: string | null | undefined;
guildsAfter: string | null | undefined;
guildsLimit: string | null | undefined;
guildsWithCounts: string | null | undefined;
dmBefore: string | null | undefined;
dmAfter: string | null | undefined;
dmLimit: string | null | undefined;
messageShredJobId: string | null | undefined;
deleteAllMessagesDryRun: string | null | undefined;
deleteAllMessagesChannelCount: string | null | undefined;
deleteAllMessagesMessageCount: string | null | undefined;
}
function parseIntOrDefault(value: string | null | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseBoolFlag(value: string | null | undefined, fallback: boolean): boolean {
if (value == null) return fallback;
const normalized = value.trim().toLowerCase();
if (normalized === '1' || normalized === 'true') return true;
if (normalized === '0' || normalized === 'false') return false;
return fallback;
}
function parseDryRunSummary(params: {
dryRun: string | null | undefined;
channelCount: string | null | undefined;
messageCount: string | null | undefined;
}): {channel_count: number; message_count: number} | null {
if (!parseBoolFlag(params.dryRun, false)) return null;
const channelCount = parseIntOrDefault(params.channelCount ?? null, 0);
const messageCount = parseIntOrDefault(params.messageCount ?? null, 0);
if (channelCount <= 0 && messageCount <= 0) return null;
return {channel_count: channelCount, message_count: messageCount};
}
function isNonEmptyString(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export const UserDetailPage: FC<UserDetailPageProps> = async ({
config,
session,
currentAdmin,
flash,
userId,
tab = 'overview',
assetVersion,
csrfToken,
guildsBefore,
guildsAfter,
guildsLimit,
guildsWithCounts,
dmBefore,
dmAfter,
dmLimit,
messageShredJobId,
deleteAllMessagesDryRun,
deleteAllMessagesChannelCount,
deleteAllMessagesMessageCount,
}) => {
const adminAcls = currentAdmin?.acls ?? [];
const userResult = await usersApi.lookupUser(config, session, userId);
if (!userResult.ok || !userResult.data) {
return (
<Layout
csrfToken={csrfToken}
title="User Not Found"
activePage="users"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<NotFoundView resourceName="User" backUrl={`${config.basePath}/users`} backLabel="Back to Users" />
</Layout>
);
}
const user = userResult.data;
const activeTab = tab || 'overview';
const bannerUrl = getUserBannerUrl(config.mediaEndpoint, user.id, user.banner, true);
const tabs = [
{id: 'overview', label: 'Overview'},
{id: 'account', label: 'Account'},
{id: 'guilds', label: 'Guilds'},
{id: 'dm_history', label: 'DM History'},
{id: 'moderation', label: 'Moderation'},
] as const;
const deleteAllMessagesDryRunSummary = parseDryRunSummary({
dryRun: deleteAllMessagesDryRun,
channelCount: deleteAllMessagesChannelCount,
messageCount: deleteAllMessagesMessageCount,
});
let changeLogResult: {ok: true; data: ListUserChangeLogResponse} | {ok: false; error: ApiError} | null = null;
let limitConfigResult: {ok: true; data: LimitConfigResponse} | {ok: false; error: ApiError} | null = null;
let sessionsResult: {ok: true; data: ListUserSessionsResponse} | {ok: false; error: ApiError} | null = null;
let guildsResult: {ok: true; data: ListUserGuildsResponse} | {ok: false; error: ApiError} | null = null;
let dmChannelsResult: {ok: true; data: ListUserDmChannelsResponse} | {ok: false; error: ApiError} | null = null;
let messageShredStatusResult:
| {ok: true; data: messagesApi.MessageShredStatusResponse}
| {ok: false; error: ApiError}
| null = null;
if (activeTab === 'overview') {
changeLogResult = await usersApi.listUserChangeLog(config, session, userId);
limitConfigResult = await getLimitConfig(config, session);
}
if (activeTab === 'account') {
sessionsResult = await usersApi.listUserSessions(config, session, userId);
}
if (activeTab === 'guilds') {
const limit = parseIntOrDefault(guildsLimit ?? null, 25);
const withCounts = parseBoolFlag(guildsWithCounts ?? null, true);
guildsResult = await usersApi.listUserGuilds(
config,
session,
userId,
guildsBefore ?? undefined,
guildsAfter ?? undefined,
limit,
withCounts,
);
}
if (activeTab === 'dm_history') {
const limit = parseIntOrDefault(dmLimit ?? null, 50);
dmChannelsResult = await usersApi.listUserDmChannels(
config,
session,
userId,
dmBefore ?? undefined,
dmAfter ?? undefined,
limit,
);
}
if (activeTab === 'moderation' && isNonEmptyString(messageShredJobId)) {
messageShredStatusResult = await messagesApi.getMessageShredStatus(config, session, messageShredJobId);
}
return (
<Layout
csrfToken={csrfToken}
title={`${user.username}#${formatDiscriminator(user.discriminator)} - User`}
activePage="users"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<VStack gap={6}>
<BackButton href={`${config.basePath}/users`} label="Back to Users" />
{bannerUrl && (
<VStack gap={2}>
<a href={bannerUrl} target="_blank" rel="noreferrer noopener">
<img
src={bannerUrl}
alt={`${user.username}'s banner`}
class="h-48 w-full rounded-lg border border-neutral-200 bg-neutral-50 object-cover"
loading="lazy"
/>
</a>
<Text size="xs" color="muted" class="font-mono">
Banner hash: {user.banner}
</Text>
</VStack>
)}
<HStack gap={6} align="start">
<img
src={getUserAvatarUrl(
config.mediaEndpoint,
config.staticCdnEndpoint,
user.id,
user.avatar,
false,
assetVersion,
)}
alt={`${user.username}'s avatar`}
class="h-20 w-20 rounded-full"
/>
<VStack gap={2} class="flex-1">
<HStack gap={3} class="flex-wrap">
<Heading level={1}>
{user.username}#{formatDiscriminator(user.discriminator)}
</Heading>
<UserProfileBadges config={config} user={user} size="md" />
</HStack>
<Text size="sm" color="muted" class="font-mono">
{user.id}
</Text>
</VStack>
</HStack>
<VStack gap={0} class="border-neutral-200 border-b">
<HStack gap={6}>
{tabs.map((t) => (
<a
href={`${config.basePath}/users/${userId}?tab=${t.id}`}
class={`-mb-px border-b-2 pb-3 font-medium text-sm transition-colors ${
activeTab === t.id
? 'border-neutral-900 text-neutral-900'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t.label}
</a>
))}
</HStack>
</VStack>
{activeTab === 'overview' && (
<OverviewTab
config={config}
user={user}
adminAcls={adminAcls}
changeLogResult={changeLogResult}
limitConfigResult={limitConfigResult}
csrfToken={csrfToken}
/>
)}
{activeTab === 'account' && (
<AccountTab
config={config}
user={user}
userId={userId}
sessionsResult={sessionsResult}
csrfToken={csrfToken}
/>
)}
{activeTab === 'guilds' && (
<GuildsTab
config={config}
user={user}
userId={userId}
guildsResult={guildsResult}
before={guildsBefore ?? null}
after={guildsAfter ?? null}
limit={parseIntOrDefault(guildsLimit ?? null, 25)}
withCounts={parseBoolFlag(guildsWithCounts ?? null, true)}
/>
)}
{activeTab === 'dm_history' && (
<DmHistoryTab
config={config}
userId={userId}
dmChannelsResult={dmChannelsResult}
before={dmBefore ?? null}
after={dmAfter ?? null}
limit={parseIntOrDefault(dmLimit ?? null, 50)}
/>
)}
{activeTab === 'moderation' && (
<ModerationTab
config={config}
user={user}
userId={userId}
adminAcls={adminAcls}
messageShredJobId={isNonEmptyString(messageShredJobId) ? messageShredJobId : null}
messageShredStatusResult={messageShredStatusResult}
deleteAllMessagesDryRun={deleteAllMessagesDryRunSummary}
csrfToken={csrfToken}
/>
)}
</VStack>
</Layout>
);
};

View File

@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {searchUsers} from '@fluxer/admin/src/api/Users';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {UserProfileBadges} from '@fluxer/admin/src/components/UserProfileBadges';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {TextLink} from '@fluxer/admin/src/components/ui/TextLink';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Badge} from '@fluxer/ui/src/components/Badge';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {SearchForm} from '@fluxer/ui/src/components/SearchForm';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeaderCell,
TableRow,
} from '@fluxer/ui/src/components/Table';
import {formatDiscriminator, getUserAvatarUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {Child, FC} from 'hono/jsx';
interface UsersPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
searchQuery: string | undefined;
page: number;
assetVersion: string;
csrfToken: string;
}
const InitialEmptyState: FC = () => {
return (
<Stack gap="md">
<EmptyState title="Enter a search query to find users" />
</Stack>
);
};
const SearchResults: FC<{
config: Config;
users: Array<UserAdminResponse>;
searchQuery: string;
page: number;
hasMore: boolean;
assetVersion: string;
}> = ({config, users, searchQuery, page, hasMore, assetVersion}) => {
return (
<Stack gap="md">
{users.length === 0 ? (
<EmptyState title={`No users found matching "${searchQuery}"`} />
) : (
<UsersTable config={config} users={users} assetVersion={assetVersion} />
)}
{(page > 0 || hasMore) && (
<PaginationControls config={config} searchQuery={searchQuery} page={page} hasMore={hasMore} />
)}
</Stack>
);
};
const UsersTable: FC<{config: Config; users: Array<UserAdminResponse>; assetVersion: string}> = ({
config,
users,
assetVersion,
}) => {
return (
<TableContainer>
<Table>
<TableHead>
<tr>
<TableHeaderCell label="User" />
<TableHeaderCell label="ID" />
<TableHeaderCell label="Email" />
<TableHeaderCell label="Status" />
</tr>
</TableHead>
<TableBody>
{users.map((user) => (
<UserRow config={config} user={user} assetVersion={assetVersion} />
))}
</TableBody>
</Table>
</TableContainer>
);
};
const UserRow: FC<{config: Config; user: UserAdminResponse; assetVersion: string}> = ({config, user, assetVersion}) => {
return (
<TableRow>
<TableCell>
<UserLink config={config} user={user} assetVersion={assetVersion} />
</TableCell>
<TableCell muted>
<UserIdCode userId={user.id} />
</TableCell>
<TableCell muted>{user.email ?? '-'}</TableCell>
<TableCell>
<UserStatusBadge user={user} />
</TableCell>
</TableRow>
);
};
const UserIdCode: FC<{userId: string}> = ({userId}) => {
return (
<Text size="xs" class="font-mono">
{userId}
</Text>
);
};
const UserLink: FC<{config: Config; user: UserAdminResponse; assetVersion: string}> = ({
config,
user,
assetVersion,
}) => {
return (
<UserLinkContainer href={`${config.basePath}/users/${user.id}`}>
<UserAvatar config={config} user={user} assetVersion={assetVersion} />
<UserNameTag config={config} user={user} />
</UserLinkContainer>
);
};
const UserLinkContainer: FC<{href: string; children: Child}> = ({href, children}) => {
return (
<a href={href} class="flex items-center gap-3 hover:opacity-80">
{children}
</a>
);
};
const UserAvatar: FC<{config: Config; user: UserAdminResponse; assetVersion: string}> = ({
config,
user,
assetVersion,
}) => {
return (
<img
src={getUserAvatarUrl(config.mediaEndpoint, config.staticCdnEndpoint, user.id, user.avatar, true, assetVersion)}
alt=""
class="h-8 w-8 rounded-full"
/>
);
};
const UserNameTag: FC<{config: Config; user: UserAdminResponse}> = ({config, user}) => {
return (
<div class="flex items-center gap-2">
<Text size="sm" class="font-medium">
{user.username}#{formatDiscriminator(user.discriminator)}
</Text>
<UserProfileBadges config={config} user={user} />
</div>
);
};
const UserStatusBadge: FC<{user: UserAdminResponse}> = ({user}) => {
if (user.bot) {
return <Badge text="Bot" variant="info" />;
}
if (user.system) {
return <Badge text="System" variant="warning" />;
}
return <Badge text="User" variant="default" />;
};
const PaginationControls: FC<{config: Config; searchQuery: string; page: number; hasMore: boolean}> = ({
config,
searchQuery,
page,
hasMore,
}) => {
return (
<div class="mt-4 flex justify-between">
{page > 0 ? <PreviousLink config={config} searchQuery={searchQuery} page={page} /> : <Text size="sm" />}
{hasMore && <NextLink config={config} searchQuery={searchQuery} page={page} />}
</div>
);
};
const PreviousLink: FC<{config: Config; searchQuery: string; page: number}> = ({config, searchQuery, page}) => {
return (
<Text size="sm" color="muted">
<TextLink
href={`${config.basePath}/users${buildPaginationUrl(page - 1, {q: searchQuery})}`}
class="hover:text-neutral-900"
>
Previous
</TextLink>
</Text>
);
};
const NextLink: FC<{config: Config; searchQuery: string; page: number}> = ({config, searchQuery, page}) => {
return (
<Text size="sm" color="muted">
<TextLink
href={`${config.basePath}/users${buildPaginationUrl(page + 1, {q: searchQuery})}`}
class="hover:text-neutral-900"
>
Next
</TextLink>
</Text>
);
};
export const UsersPage: FC<UsersPageProps> = async ({
config,
session,
currentAdmin,
flash,
searchQuery,
page,
assetVersion,
csrfToken,
}) => {
let users: Array<UserAdminResponse> = [];
let hasMore = false;
let error: string | undefined;
if (searchQuery) {
const result = await searchUsers(config, session, searchQuery, page, 25);
if (result.ok) {
users = result.data.users;
hasMore = result.data.has_more;
} else {
error = getErrorMessage(result.error);
}
}
return (
<Layout
csrfToken={csrfToken}
title="Users"
activePage="users"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<div class="mx-auto max-w-7xl space-y-6">
<PageHeader title="Users" />
<SearchForm
action="/users"
basePath={config.basePath}
fields={[
{
name: 'q',
type: 'text',
value: searchQuery,
placeholder: 'Search by user ID, username, email, or phone...',
},
]}
layout="horizontal"
/>
{error && <ErrorAlert error={error} />}
{searchQuery && !error && (
<SearchResults
config={config}
users={users}
searchQuery={searchQuery}
page={page}
hasMore={hasMore}
assetVersion={assetVersion}
/>
)}
{!searchQuery && <InitialEmptyState />}
</div>
</Layout>
);
};

View File

@@ -0,0 +1,303 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Table} from '@fluxer/admin/src/components/ui/Table';
import {TableBody} from '@fluxer/admin/src/components/ui/TableBody';
import {TableCell} from '@fluxer/admin/src/components/ui/TableCell';
import {TableContainer} from '@fluxer/admin/src/components/ui/TableContainer';
import {TableHeader} from '@fluxer/admin/src/components/ui/TableHeader';
import {TableHeaderCell} from '@fluxer/admin/src/components/ui/TableHeaderCell';
import {TableRow} from '@fluxer/admin/src/components/ui/TableRow';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {SliderInput} from '@fluxer/ui/src/components/SliderInput';
import type {FC} from 'hono/jsx';
const MAX_EXPAND_COUNT = 1000;
const DEFAULT_EXPAND_COUNT = 10;
export interface VisionarySlot {
slot_index: number;
user_id: string | null;
}
export interface VisionarySlotsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
adminAcls: Array<string>;
assetVersion: string;
csrfToken: string;
slots: Array<VisionarySlot>;
totalCount: number;
reservedCount: number;
}
export const VisionarySlotsPage: FC<VisionarySlotsPageProps> = ({
config,
session,
currentAdmin,
flash,
adminAcls,
assetVersion,
csrfToken,
slots,
totalCount,
reservedCount,
}) => {
const hasViewPermission = hasPermission(adminAcls, AdminACLs.VISIONARY_SLOT_VIEW);
const hasExpandPermission = hasPermission(adminAcls, AdminACLs.VISIONARY_SLOT_EXPAND);
const hasShrinkPermission = hasPermission(adminAcls, AdminACLs.VISIONARY_SLOT_SHRINK);
const hasReservePermission = hasPermission(adminAcls, AdminACLs.VISIONARY_SLOT_RESERVE);
const hasSwapPermission = hasPermission(adminAcls, AdminACLs.VISIONARY_SLOT_SWAP);
const reservedSlots = slots.filter((s) => s.user_id !== null);
const minAllowedTotalCount = reservedSlots.length > 0 ? Math.max(...reservedSlots.map((s) => s.slot_index)) : 0;
return (
<Layout
csrfToken={csrfToken}
title="Visionary Slots"
activePage="visionary-slots"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
{hasViewPermission ? (
<PageLayout maxWidth="7xl">
<VStack gap={6}>
<Card padding="md">
<VStack gap={2}>
<Heading level={1} size="2xl">
Visionary Slots Management
</Heading>
<Text size="sm" color="muted">
Total slots: <strong>{totalCount}</strong> | Reserved: <strong>{reservedCount}</strong> | Available:{' '}
<strong>{totalCount - reservedCount}</strong>
</Text>
</VStack>
</Card>
{hasExpandPermission && (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Expand Slots
</Heading>
<form id="expand-form" method="post" action={`${config.basePath}/visionary-slots/expand`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<SliderInput
id="expand-count-slider"
name="count"
label="Number of slots to add"
min={1}
max={MAX_EXPAND_COUNT}
value={DEFAULT_EXPAND_COUNT}
rangeText={`Range: 1-${MAX_EXPAND_COUNT}`}
/>
<Button type="submit" variant="primary">
Add Slots
</Button>
</VStack>
</form>
</VStack>
</Card>
)}
{hasShrinkPermission && (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Shrink Slots
</Heading>
{totalCount === 0 ? (
<Text color="muted">No slots to shrink.</Text>
) : minAllowedTotalCount >= totalCount ? (
<Text color="muted">
Cannot shrink further. The highest reserved slot is index {minAllowedTotalCount}.
</Text>
) : (
<form id="shrink-form" method="post" action={`${config.basePath}/visionary-slots/shrink`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<SliderInput
id="shrink-target-slider"
name="target_count"
label="Target total number of slots"
min={minAllowedTotalCount}
max={totalCount}
value={totalCount}
rangeText={`Range: ${minAllowedTotalCount}-${totalCount} ${
reservedSlots.length > 0
? '(cannot shrink below highest reserved slot)'
: '(can shrink to 0)'
}`}
/>
<Button type="submit" variant="primary">
Shrink to Target
</Button>
</VStack>
</form>
)}
</VStack>
</Card>
)}
{hasReservePermission && (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Reserve Slot
</Heading>
{totalCount === 0 ? (
<Text color="muted">No slots exist yet. Expand slots first before reserving.</Text>
) : (
<form id="reserve-form" method="post" action={`${config.basePath}/visionary-slots/reserve`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<FormFieldGroup
label="Slot Index"
helper="The slot index to reserve or unreserve (slots start at 1)"
>
<Input type="number" id="slot-index" name="slot_index" required min={1} max={totalCount} />
</FormFieldGroup>
<FormFieldGroup
label="User ID"
helper="User ID to reserve the slot for. Leave empty or use 'null' to unreserve. Special value '-1' is also valid."
>
<Input type="text" id="user-id" name="user_id" placeholder="null" />
</FormFieldGroup>
<Button type="submit" variant="primary">
Update Slot
</Button>
</VStack>
</form>
)}
</VStack>
</Card>
)}
{hasSwapPermission && (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
Swap Slots
</Heading>
{totalCount < 2 ? (
<Text color="muted">Need at least two slots to swap.</Text>
) : (
<form id="swap-form" method="post" action={`${config.basePath}/visionary-slots/swap`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<FormFieldGroup label="Slot Index A" helper="The first slot index to swap (slots start at 1)">
<Input
type="number"
id="slot-index-a"
name="slot_index_a"
required
min={1}
max={totalCount}
/>
</FormFieldGroup>
<FormFieldGroup label="Slot Index B" helper="The second slot index to swap (slots start at 1)">
<Input
type="number"
id="slot-index-b"
name="slot_index_b"
required
min={1}
max={totalCount}
/>
</FormFieldGroup>
<Button type="submit" variant="primary">
Swap Slots
</Button>
</VStack>
</form>
)}
</VStack>
</Card>
)}
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="xl">
All Slots
</Heading>
{slots.length === 0 ? (
<Text color="muted">No slots exist yet.</Text>
) : (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Slot Index</TableHeaderCell>
<TableHeaderCell>User ID</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{slots.map((slot) => (
<TableRow key={slot.slot_index}>
<TableCell>{slot.slot_index}</TableCell>
<TableCell>{slot.user_id ?? <em>null</em>}</TableCell>
<TableCell>{slot.user_id ? 'Reserved' : 'Available'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</VStack>
</Card>
</VStack>
</PageLayout>
) : (
<Card padding="md">
<Heading level={1} size="2xl">
Visionary Slots
</Heading>
<Text color="muted" size="sm" class="mt-2">
You do not have permission to view visionary slots.
</Text>
</Card>
)}
</Layout>
);
};

View File

@@ -0,0 +1,333 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {listVoiceRegions} from '@fluxer/admin/src/api/Voice';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {
VoiceFeaturesList,
VoiceGuildIdsList,
VoiceRestrictionFields,
VoiceStatusBadges,
} from '@fluxer/admin/src/components/VoiceComponents';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {
VoiceRegionWithServersResponse,
VoiceServerAdminResponse,
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
import {UnifiedBadge as Badge} from '@fluxer/ui/src/components/Badge';
import {Button} from '@fluxer/ui/src/components/Button';
import {CardElevated} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {Checkbox, Input} from '@fluxer/ui/src/components/Form';
import {InfoItem} from '@fluxer/ui/src/components/Layout';
import type {FC} from 'hono/jsx';
export interface VoiceRegionsPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
assetVersion: string;
csrfToken: string;
}
const ServerRow: FC<{server: VoiceServerAdminResponse}> = ({server}) => (
<HStack gap={3} class="justify-between rounded bg-neutral-50 p-3">
<VStack gap={1} class="flex-1">
<Text size="sm">{server.server_id}</Text>
<Text size="xs" color="muted">
{server.endpoint}
</Text>
</VStack>
<HStack gap={2}>
{server.is_active ? (
<Badge label="ACTIVE" tone="success" intensity="subtle" rounded="default" />
) : (
<Badge label="INACTIVE" tone="neutral" intensity="subtle" rounded="default" />
)}
</HStack>
</HStack>
);
const EditForm: FC<{config: Config; region: VoiceRegionWithServersResponse; csrfToken: string}> = ({
config,
region,
csrfToken,
}) => (
<VStack gap={4} class="rounded-lg bg-neutral-50 p-4">
<form method="post" action={`${config.basePath}/voice-regions?action=update`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="id" value={region.id} />
<VStack gap={4}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormFieldGroup label="Region Name" htmlFor="region-name">
<Input
id="region-name"
name="name"
type="text"
value={region.name}
placeholder="Display name for the region"
/>
</FormFieldGroup>
<FormFieldGroup label="Emoji" htmlFor="region-emoji">
<Input
id="region-emoji"
name="emoji"
type="text"
value={region.emoji}
placeholder="Flag or emoji for the region"
/>
</FormFieldGroup>
<FormFieldGroup label="Latitude" htmlFor="region-latitude">
<Input
id="region-latitude"
name="latitude"
type="number"
step="any"
value={String(region.latitude)}
placeholder="Geographic latitude"
/>
</FormFieldGroup>
<FormFieldGroup label="Longitude" htmlFor="region-longitude">
<Input
id="region-longitude"
name="longitude"
type="number"
step="any"
value={String(region.longitude)}
placeholder="Geographic longitude"
/>
</FormFieldGroup>
</div>
<Checkbox name="is_default" value="true" label="Set as default region" checked={region.is_default} />
<VoiceRestrictionFields
restrictions={{
vip_only: region.vip_only,
required_guild_features: region.required_guild_features,
allowed_guild_ids: region.allowed_guild_ids,
}}
/>
<Button type="submit" variant="success">
Update Region
</Button>
</VStack>
</form>
</VStack>
);
const RegionCard: FC<{config: Config; region: VoiceRegionWithServersResponse; csrfToken: string}> = ({
config,
region,
csrfToken,
}) => (
<CardElevated padding="md">
<VStack gap={4}>
<HStack gap={3}>
<Text class="text-3xl">{region.emoji}</Text>
<VStack gap={1}>
<HStack gap={2} align="center" class="flex-wrap">
<Heading level={3} size="base">
{region.name}
</Heading>
{region.is_default && <Badge label="DEFAULT" tone="info" intensity="subtle" rounded="default" />}
<VoiceStatusBadges
vip_only={region.vip_only}
has_features={region.required_guild_features.length > 0}
has_guild_ids={region.allowed_guild_ids.length > 0}
/>
</HStack>
<Text size="sm" color="muted">
Region ID: {region.id}
</Text>
</VStack>
</HStack>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
<InfoItem label="Latitude" value={String(region.latitude)} />
<InfoItem label="Longitude" value={String(region.longitude)} />
<InfoItem label="Servers" value={String(region.servers?.length ?? 0)} />
</div>
<VoiceFeaturesList features={region.required_guild_features} />
<VoiceGuildIdsList guild_ids={region.allowed_guild_ids} />
{region.servers && region.servers.length > 0 && (
<VStack gap={2} class="border-neutral-200 border-t pt-4">
<Heading level={4} size="sm">
Servers
</Heading>
<VStack gap={2}>
{region.servers.map((server) => (
<ServerRow server={server} />
))}
</VStack>
</VStack>
)}
<HStack gap={2}>
<form method="post" action={`${config.basePath}/voice-regions?action=delete`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="id" value={region.id} />
<Button
type="submit"
variant="danger"
size="small"
onclick="return confirm('Are you sure? This will delete all servers in this region.')"
>
Delete Region
</Button>
</form>
<a href={`${config.basePath}/voice-servers?region_id=${region.id}`}>
<Button variant="secondary" size="small" type="button">
Manage Servers
</Button>
</a>
</HStack>
<details>
<summary class="cursor-pointer rounded bg-blue-50 px-4 py-2 font-medium text-blue-700 text-sm transition-colors hover:bg-blue-100">
Edit Region
</summary>
<VStack gap={3} class="border-neutral-200 border-t pt-3">
<EditForm config={config} region={region} csrfToken={csrfToken} />
</VStack>
</details>
</VStack>
</CardElevated>
);
const RegionsList: FC<{config: Config; regions: Array<VoiceRegionWithServersResponse>; csrfToken: string}> = ({
config,
regions,
csrfToken,
}) =>
regions.length === 0 ? (
<EmptyState title="No voice regions configured yet." message="Create your first region to get started." />
) : (
<VStack gap={6}>
{regions.map((region) => (
<RegionCard config={config} region={region} csrfToken={csrfToken} />
))}
</VStack>
);
const CreateForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => (
<CardElevated padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Create Voice Region
</Heading>
<form method="post" action={`${config.basePath}/voice-regions?action=create`}>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormFieldGroup label="Region ID" htmlFor="new-region-id" required>
<Input id="new-region-id" name="id" type="text" placeholder="us-east" required />
</FormFieldGroup>
<FormFieldGroup label="Region Name" htmlFor="new-region-name" required>
<Input id="new-region-name" name="name" type="text" placeholder="US East" required />
</FormFieldGroup>
<FormFieldGroup label="Emoji" htmlFor="new-region-emoji" required>
<Input id="new-region-emoji" name="emoji" type="text" placeholder="Flag emoji" required />
</FormFieldGroup>
<FormFieldGroup label="Latitude" htmlFor="new-region-latitude" required>
<Input id="new-region-latitude" name="latitude" type="number" step="any" placeholder="40.7128" required />
</FormFieldGroup>
<FormFieldGroup label="Longitude" htmlFor="new-region-longitude" required>
<Input
id="new-region-longitude"
name="longitude"
type="number"
step="any"
placeholder="-74.0060"
required
/>
</FormFieldGroup>
</div>
<Checkbox name="is_default" value="true" label="Set as default region" />
<VoiceRestrictionFields
restrictions={{
vip_only: false,
required_guild_features: [],
allowed_guild_ids: [],
}}
/>
<Button type="submit" variant="primary">
Create Region
</Button>
</VStack>
</form>
</VStack>
</CardElevated>
);
export async function VoiceRegionsPage({
config,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
}: VoiceRegionsPageProps) {
const result = await listVoiceRegions(config, session, true);
return (
<Layout
csrfToken={csrfToken}
title="Voice Regions"
activePage="voice-regions"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
>
<PageLayout maxWidth="7xl">
{result.ok ? (
<VStack gap={6}>
<PageHeader
title="Voice Regions"
actions={
<a href="#create">
<Button type="button">Create Region</Button>
</a>
}
/>
<RegionsList config={config} regions={result.data.regions} csrfToken={csrfToken} />
<div id="create">
<CreateForm config={config} csrfToken={csrfToken} />
</div>
</VStack>
) : (
<ErrorAlert error={getErrorMessage(result.error)} />
)}
</PageLayout>
</Layout>
);
}

View File

@@ -0,0 +1,356 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {getVoiceRegion, listVoiceServers} from '@fluxer/admin/src/api/Voice';
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
import {Layout} from '@fluxer/admin/src/components/Layout';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {
VoiceFeaturesList,
VoiceGuildIdsList,
VoiceRestrictionFields,
VoiceStatusBadges,
} from '@fluxer/admin/src/components/VoiceComponents';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {VoiceServerAdminResponse} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
import {UnifiedBadge as Badge} from '@fluxer/ui/src/components/Badge';
import {Button} from '@fluxer/ui/src/components/Button';
import {CardElevated} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
import {Checkbox, Input} from '@fluxer/ui/src/components/Form';
import {FlexRowBetween, InfoItem} from '@fluxer/ui/src/components/Layout';
import type {FC} from 'hono/jsx';
export interface VoiceServersPageProps {
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
regionId: string | undefined;
flash: Flash | undefined;
assetVersion: string;
csrfToken: string;
}
const EditServerForm: FC<{config: Config; region_id: string; server: VoiceServerAdminResponse; csrfToken: string}> = ({
config,
region_id,
server,
csrfToken,
}) => {
const idPrefix = `voice-server-${region_id}-${server.server_id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
return (
<VStack gap={0} class="rounded-lg bg-neutral-50 p-4">
<form method="post" action={`${config.basePath}/voice-servers?action=update`} class="space-y-3">
<CsrfInput token={csrfToken} />
<input type="hidden" name="region_id" value={region_id} />
<input type="hidden" name="server_id" value={server.server_id} />
<FormFieldGroup label="Endpoint" htmlFor="server-endpoint">
<Input
id="server-endpoint"
name="endpoint"
type="url"
value={server.endpoint}
placeholder="wss://livekit.example.com"
/>
</FormFieldGroup>
<FormFieldGroup
label="API Key"
htmlFor={`${idPrefix}-api-key`}
helper="LiveKit API key (leave blank to keep unchanged)"
>
<Input name="api_key" type="text" placeholder="Leave blank to keep current" id={`${idPrefix}-api-key`} />
</FormFieldGroup>
<FormFieldGroup
label="API Secret"
htmlFor={`${idPrefix}-api-secret`}
helper="LiveKit API secret (leave blank to keep unchanged)"
>
<Input
name="api_secret"
type="password"
placeholder="Leave blank to keep current"
id={`${idPrefix}-api-secret`}
/>
</FormFieldGroup>
<div class="space-y-2">
<Checkbox name="is_active" value="true" label="Server is active" checked={server.is_active} />
</div>
<VoiceRestrictionFields
id_prefix={idPrefix}
restrictions={{
vip_only: server.vip_only,
required_guild_features: server.required_guild_features,
allowed_guild_ids: server.allowed_guild_ids,
}}
/>
<Button type="submit" variant="success" fullWidth>
Update Server
</Button>
</form>
</VStack>
);
};
const ServerCard: FC<{config: Config; region_id: string; server: VoiceServerAdminResponse; csrfToken: string}> = ({
config,
region_id,
server,
csrfToken,
}) => (
<CardElevated padding="md">
<HStack gap={4} align="start" class="mb-4">
<VStack gap={1}>
<Heading level={3} size="base">
{server.server_id}
</Heading>
<Text size="sm" color="muted">
{server.endpoint}
</Text>
</VStack>
<HStack gap={2} class="flex-wrap">
{server.is_active ? (
<Badge label="ACTIVE" tone="success" intensity="subtle" rounded="default" />
) : (
<Badge label="INACTIVE" tone="neutral" intensity="subtle" rounded="default" />
)}
<VoiceStatusBadges
vip_only={server.vip_only}
has_features={server.required_guild_features.length > 0}
has_guild_ids={server.allowed_guild_ids.length > 0}
/>
</HStack>
</HStack>
<div class="mb-4 grid grid-cols-2 gap-4 md:grid-cols-2">
<InfoItem label="Region" value={server.region_id} />
<InfoItem label="Status" value={server.is_active ? 'Active' : 'Inactive'} />
</div>
<VoiceFeaturesList features={server.required_guild_features} />
<VoiceGuildIdsList guild_ids={server.allowed_guild_ids} />
<HStack gap={2} class="flex-wrap">
<form method="post" action={`${config.basePath}/voice-servers?action=update`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="region_id" value={region_id} />
<input type="hidden" name="server_id" value={server.server_id} />
<input type="hidden" name="endpoint" value={server.endpoint} />
<input type="hidden" name="is_active" value={server.is_active ? 'false' : 'true'} />
<input type="hidden" name="vip_only" value={server.vip_only ? 'true' : 'false'} />
<Button type="submit" variant={server.is_active ? 'danger' : 'success'} size="small">
{server.is_active ? 'Deactivate' : 'Activate'}
</Button>
</form>
<form method="post" action={`${config.basePath}/voice-servers?action=delete`}>
<CsrfInput token={csrfToken} />
<input type="hidden" name="region_id" value={region_id} />
<input type="hidden" name="server_id" value={server.server_id} />
<Button
type="submit"
variant="danger"
size="small"
onclick="return confirm('Are you sure you want to delete this server?')"
>
Delete
</Button>
</form>
</HStack>
<details class="mt-6">
<summary class="cursor-pointer rounded bg-blue-50 px-4 py-2 font-medium text-blue-700 text-sm transition-colors hover:bg-blue-100">
Edit Server
</summary>
<VStack gap={0} class="mt-3 border-neutral-200 border-t pt-3">
<EditServerForm config={config} region_id={region_id} server={server} csrfToken={csrfToken} />
</VStack>
</details>
</CardElevated>
);
const ServersList: FC<{
config: Config;
region_id: string;
servers: Array<VoiceServerAdminResponse>;
csrfToken: string;
}> = ({config, region_id, servers, csrfToken}) =>
servers.length === 0 ? (
<EmptyState title="No servers configured for this region yet." message="Add your first server to get started." />
) : (
<VStack gap={4}>
{servers.map((server) => (
<ServerCard config={config} region_id={region_id} server={server} csrfToken={csrfToken} />
))}
</VStack>
);
const CreateForm: FC<{config: Config; region_id: string; csrfToken: string}> = ({config, region_id, csrfToken}) => (
<CardElevated padding="md">
<Heading level={2} size="base" class="mb-4">
Add Voice Server
</Heading>
<form method="post" action={`${config.basePath}/voice-servers?action=create`} class="space-y-4">
<CsrfInput token={csrfToken} />
<input type="hidden" name="region_id" value={region_id} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormFieldGroup label="Server ID" htmlFor="new-server-id" required>
<Input id="new-server-id" name="server_id" type="text" placeholder="livekit-us-east-1" required />
</FormFieldGroup>
<FormFieldGroup label="Endpoint" htmlFor="new-server-endpoint" required>
<Input id="new-server-endpoint" name="endpoint" type="url" placeholder="wss://livekit.example.com" required />
</FormFieldGroup>
<FormFieldGroup label="API Key" htmlFor="new-server-api-key" required>
<Input id="new-server-api-key" name="api_key" type="text" placeholder="LiveKit API key" required />
</FormFieldGroup>
<FormFieldGroup label="API Secret" htmlFor="new-server-api-secret" required>
<Input
id="new-server-api-secret"
name="api_secret"
type="password"
placeholder="LiveKit API secret"
required
/>
</FormFieldGroup>
</div>
<div class="space-y-3">
<Checkbox name="is_active" value="true" label="Server is active" checked />
</div>
<VoiceRestrictionFields
id_prefix="create"
restrictions={{
vip_only: false,
required_guild_features: [],
allowed_guild_ids: [],
}}
/>
<Button type="submit" variant="primary" fullWidth>
Add Server
</Button>
</form>
</CardElevated>
);
const NoRegionView: FC<{config: Config}> = ({config}) => (
<VStack gap={6}>
<Heading level={1}>Voice Servers</Heading>
<ErrorAlert error="Please select a region first." />
<a href={`${config.basePath}/voice-regions`}>
<Button type="button">Go to Voice Regions</Button>
</a>
</VStack>
);
export async function VoiceServersPage({
config,
session,
currentAdmin,
regionId,
flash,
assetVersion,
csrfToken,
}: VoiceServersPageProps) {
if (!regionId) {
return (
<Layout
csrfToken={csrfToken}
title="Voice Servers"
activePage="voice-servers"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={undefined}
assetVersion={assetVersion}
inspectedVoiceRegionId={regionId}
>
<NoRegionView config={config} />
</Layout>
);
}
const serversResult = await listVoiceServers(config, session, regionId);
const adminAcls = currentAdmin?.acls ?? [];
const canViewRegion = hasPermission(adminAcls, AdminACLs.VOICE_REGION_LIST);
const regionResult = canViewRegion ? await getVoiceRegion(config, session, regionId, false) : null;
if (!serversResult.ok) {
return (
<Layout
csrfToken={csrfToken}
title="Voice Servers"
activePage="voice-servers"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
inspectedVoiceRegionId={regionId}
>
<ErrorAlert error={getErrorMessage(serversResult.error)} />
</Layout>
);
}
const regionError = regionResult && !regionResult.ok ? getErrorMessage(regionResult.error) : null;
const regionName = regionResult?.ok ? (regionResult.data.region?.name ?? regionId) : regionId;
return (
<Layout
csrfToken={csrfToken}
title="Voice Servers"
activePage="voice-servers"
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
inspectedVoiceRegionId={regionId}
>
<VStack gap={6}>
{regionError ? <ErrorAlert error={regionError} /> : null}
<FlexRowBetween>
<VStack gap={2}>
<a
href={`${config.basePath}/voice-regions`}
class="body-sm inline-block text-neutral-600 hover:text-neutral-900"
>
&larr; Back to Regions
</a>
<Heading level={1}>Servers: {regionName}</Heading>
</VStack>
<a href="#create">
<Button type="button">Add Server</Button>
</a>
</FlexRowBetween>
<ServersList config={config} region_id={regionId} servers={serversResult.data.servers} csrfToken={csrfToken} />
<div id="create" class="mt-8">
<CreateForm config={config} region_id={regionId} csrfToken={csrfToken} />
</div>
</VStack>
</Layout>
);
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {DISABLED_OPERATIONS, GUILD_FEATURES, SELF_HOSTED_GUILD_FEATURES} from '@fluxer/admin/src/AdminPackageConstants';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {Button} from '@fluxer/ui/src/components/Button';
import {NativeCheckboxItem} from '@fluxer/ui/src/components/CheckboxForm';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface RenderFeaturesFormProps {
config: Config;
currentFeatures: Array<string>;
guildId: string;
csrfToken: string;
selfHosted: boolean;
}
export function RenderFeaturesForm({config, currentFeatures, guildId, csrfToken, selfHosted}: RenderFeaturesFormProps) {
const knownFeatureValues = selfHosted ? SELF_HOSTED_GUILD_FEATURES : GUILD_FEATURES;
const customFeatures = currentFeatures.filter(
(f) => !knownFeatureValues.includes(f as (typeof GUILD_FEATURES)[number]),
);
return (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_features&tab=features`}
id="features-form"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
{knownFeatureValues.map((feature) => {
const isChecked = currentFeatures.includes(feature);
return (
<NativeCheckboxItem
name="features[]"
value={feature}
label={feature}
checked={isChecked}
saveButtonId="features-save-button"
/>
);
})}
</VStack>
<VStack gap={0} class="mt-6 border-neutral-200 border-t pt-6">
<FormFieldGroup label="Custom Features">
<Text color="muted" size="xs" class="mb-2">
Enter custom feature strings separated by commas (e.g., CUSTOM_FEATURE_1, CUSTOM_FEATURE_2)
</Text>
<Input
type="text"
name="custom_features"
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
value={customFeatures.join(', ')}
/>
</FormFieldGroup>
</VStack>
<div class="mt-6 border-neutral-200 border-t pt-6" id="features-save-button">
<Button type="submit" variant="primary">
Save Changes
</Button>
</div>
</form>
);
}
interface RenderDisabledOperationsFormProps {
config: Config;
currentDisabledOperations: number;
guildId: string;
csrfToken: string;
}
export const RenderDisabledOperationsForm: FC<RenderDisabledOperationsFormProps> = ({
config,
currentDisabledOperations,
guildId,
csrfToken,
}) => (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_disabled_operations&tab=settings`}
id="disabled-ops-form"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
{DISABLED_OPERATIONS.map((operation) => (
<NativeCheckboxItem
name="disabled_operations[]"
value={operation.value.toString()}
label={operation.name}
checked={(currentDisabledOperations & operation.value) === operation.value}
saveButtonId="disabled-ops-save-button"
/>
))}
</VStack>
<div class="mt-6 hidden border-neutral-200 border-t pt-6" id="disabled-ops-save-button">
<Button type="submit" variant="primary">
Save Changes
</Button>
</div>
</form>
);

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {listGuildEmojis} from '@fluxer/admin/src/api/GuildAssets';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {GuildEmojiAsset} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface EmojisTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
const RenderPermissionNotice: FC = () => (
<Card padding="md">
<VStack gap={2}>
<Heading level={3} size="base">
Permission required
</Heading>
<Text size="sm" color="muted">
You need the {AdminACLs.ASSET_PURGE} ACL to manage guild emojis.
</Text>
</VStack>
</Card>
);
const RenderEmojiCard: FC<{config: Config; guildId: string; emoji: GuildEmojiAsset; csrfToken: string}> = ({
config,
guildId,
emoji,
csrfToken,
}) => {
return (
<Card padding="none" class="overflow-hidden shadow-sm">
<VStack gap={0}>
<VStack gap={0} class="h-32 items-center justify-center bg-neutral-100 p-6">
<img src={emoji.media_url} alt={emoji.name} class="max-h-full max-w-full object-contain" loading="lazy" />
</VStack>
<VStack gap={3} class="flex-1 px-4 py-3">
<HStack gap={2} justify="between">
<Text size="sm" weight="semibold">
{emoji.name}
</Text>
{emoji.animated && (
<Badge size="sm" variant="neutral">
Animated
</Badge>
)}
</HStack>
<Text size="xs" color="muted" class="break-words">
ID: {emoji.id}
</Text>
<a href={`${config.basePath}/users/${emoji.creator_id}`} class="text-blue-600 text-xs hover:underline">
Uploader: {emoji.creator_id}
</a>
<form action={`${config.basePath}/guilds/${guildId}?tab=emojis&action=delete_emoji`} method="post">
<CsrfInput token={csrfToken} />
<input type="hidden" name="emoji_id" value={emoji.id} />
<Button type="submit" variant="danger" size="small" fullWidth>
Delete Emoji
</Button>
</form>
</VStack>
</VStack>
</Card>
);
};
const RenderEmojis: FC<{config: Config; guildId: string; emojis: Array<GuildEmojiAsset>; csrfToken: string}> = ({
config,
guildId,
emojis,
csrfToken,
}) => {
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Emojis ({emojis.length})
</Heading>
{emojis.length === 0 ? (
<Text size="sm" color="muted">
No custom emojis found for this guild.
</Text>
) : (
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{emojis.map((emoji) => (
<RenderEmojiCard config={config} guildId={guildId} emoji={emoji} csrfToken={csrfToken} />
))}
</div>
)}
</VStack>
</Card>
);
};
export async function EmojisTab({config, session, guildId, adminAcls, csrfToken}: EmojisTabProps) {
const hasAssetPurge = hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
if (!hasAssetPurge) {
return <RenderPermissionNotice />;
}
const result = await listGuildEmojis(config, session, guildId);
if (!result.ok) {
return (
<VStack gap={4}>
<ErrorCard title="Error" message={getErrorMessage(result.error)} />
<a
href={`${config.basePath}/guilds/${guildId}?tab=emojis`}
class="inline-block rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
Back to Guild
</a>
</VStack>
);
}
return <RenderEmojis config={config} guildId={guildId} emojis={result.data.emojis} csrfToken={csrfToken} />;
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {RenderFeaturesForm} from '@fluxer/admin/src/pages/guild_detail/Forms';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Card} from '@fluxer/ui/src/components/Card';
interface FeaturesTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
export function FeaturesTab({config, guild, guildId, adminAcls, csrfToken}: FeaturesTabProps) {
return (
<Stack gap="lg">
{hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_FEATURES) ? (
<Card padding="md">
<Stack gap="md">
<VStack gap={1}>
<Heading level={2} size="base">
Guild Features
</Heading>
<Text size="sm" color="muted">
Select which features are enabled for this guild.
</Text>
</VStack>
<RenderFeaturesForm
config={config}
currentFeatures={guild.features}
guildId={guildId}
csrfToken={csrfToken}
selfHosted={config.selfHosted}
/>
</Stack>
</Card>
) : (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Guild Features
</Heading>
{guild.features.length === 0 ? (
<Text size="sm" color="muted">
No features enabled
</Text>
) : (
<div class="flex flex-wrap gap-2">
{guild.features.map((feature) => (
<Badge variant="info">{feature}</Badge>
))}
</div>
)}
</Stack>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,327 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage, getErrorTitle} from '@fluxer/admin/src/api/Errors';
import {listGuildMembers} from '@fluxer/admin/src/api/Guilds';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {ListGuildMembersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {extractTimestampFromSnowflakeAsDate} from '@fluxer/snowflake/src/SnowflakeUtils';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {formatDiscriminator, getUserAvatarUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
interface MembersTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
page: number;
assetVersion: string;
csrfToken: string;
}
function formatDate(isoDate: string): string {
const parts = isoDate.split('T');
return parts[0] ?? isoDate;
}
function extractTimestamp(snowflakeId: string): string | null {
try {
const date = extractTimestampFromSnowflakeAsDate(snowflakeId);
return date.toISOString().split('T')[0] ?? null;
} catch {
return null;
}
}
const RenderPaginationInfo: FC<{offset: number; limit: number; total: number}> = ({offset, limit, total}) => {
const start = offset + 1;
const end = offset + limit > total ? total : offset + limit;
return (
<Text size="sm" color="muted">
Showing {start}-{end} of {total}
</Text>
);
};
const RenderPagination: FC<{
config: Config;
guildId: string;
currentPage: number;
total: number;
limit: number;
}> = ({config, guildId, currentPage, total, limit}) => {
const totalPages = Math.ceil(total / limit);
const hasPrevious = currentPage > 0;
const hasNext = currentPage < totalPages - 1;
if (totalPages <= 1) {
return null;
}
return (
<HStack gap={4} justify="between" align="center" class="mt-4 border-t pt-4">
<Button
variant={hasPrevious ? 'brand' : 'secondary'}
size="small"
disabled={!hasPrevious}
href={
hasPrevious
? `${config.basePath}/guilds/${guildId}${buildPaginationUrl(currentPage - 1, {tab: 'members'})}`
: undefined
}
>
&larr; Previous
</Button>
<Text size="sm" color="muted">
Page {currentPage + 1} of {totalPages}
</Text>
<Button
variant={hasNext ? 'brand' : 'secondary'}
size="small"
disabled={!hasNext}
href={
hasNext
? `${config.basePath}/guilds/${guildId}${buildPaginationUrl(currentPage + 1, {tab: 'members'})}`
: undefined
}
>
Next &rarr;
</Button>
</HStack>
);
};
const RenderMemberActions: FC<{
config: Config;
guildId: string;
userId: string;
canBanMember: boolean;
canKickMember: boolean;
csrfToken: string;
}> = ({config, guildId, userId, canBanMember, canKickMember, csrfToken}) => {
if (!canBanMember && !canKickMember) {
return null;
}
return (
<HStack gap={2} justify="end" class="flex-wrap">
{canBanMember && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?tab=members&action=ban_member`}
onsubmit="return confirm('Are you sure you want to ban this member?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="user_id" value={userId} />
<Button type="submit" variant="danger" size="small">
Ban Member
</Button>
</form>
)}
{canKickMember && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?tab=members&action=kick_member`}
onsubmit="return confirm('Are you sure you want to kick this member from the guild?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="user_id" value={userId} />
<Button type="submit" variant="secondary" size="small">
Kick Member
</Button>
</form>
)}
</HStack>
);
};
const RenderMember: FC<{
config: Config;
guildId: string;
adminAcls: Array<string>;
member: GuildMemberResponse;
assetVersion: string;
csrfToken: string;
}> = ({config, guildId, adminAcls, member, assetVersion, csrfToken}) => {
const canBan = hasPermission(adminAcls, AdminACLs.GUILD_BAN_MEMBER);
const canKick = hasPermission(adminAcls, AdminACLs.GUILD_KICK_MEMBER);
const createdAt = extractTimestamp(member.user.id);
const discriminatorDisplay =
typeof member.user.discriminator === 'number'
? formatDiscriminator(member.user.discriminator)
: member.user.discriminator;
return (
<Card padding="md" class="transition-colors hover:border-neutral-300">
<HStack gap={4} align="center">
<img
src={getUserAvatarUrl(
config.mediaEndpoint,
config.staticCdnEndpoint,
member.user.id,
member.user.avatar,
true,
assetVersion,
)}
alt={member.user.username}
class="h-16 w-16 flex-shrink-0 rounded-full"
/>
<VStack gap={1} class="min-w-0 flex-1">
<HStack gap={2} align="center" class="mb-1">
<Heading level={2} size="base">
{member.user.username}#{discriminatorDisplay}
</Heading>
{member.user.bot && <Badge variant="info">Bot</Badge>}
{member.nick && (
<Text size="sm" color="muted" class="ml-2">
({member.nick})
</Text>
)}
</HStack>
<VStack gap={0.5}>
<Text size="sm" color="muted">
ID: {member.user.id}
</Text>
{createdAt && (
<Text size="sm" color="muted">
Created: {createdAt}
</Text>
)}
<Text size="sm" color="muted">
Joined: {formatDate(member.joined_at)}
</Text>
{member.roles.length > 0 && (
<Text size="sm" color="muted">
{member.roles.length} roles
</Text>
)}
</VStack>
</VStack>
<VStack gap={2} align="end">
<Button variant="primary" size="small" href={`${config.basePath}/users/${member.user.id}`}>
View Details
</Button>
<RenderMemberActions
config={config}
guildId={guildId}
userId={member.user.id}
canBanMember={canBan}
canKickMember={canKick}
csrfToken={csrfToken}
/>
</VStack>
</HStack>
</Card>
);
};
const RenderMembersList: FC<{
config: Config;
guildId: string;
adminAcls: Array<string>;
response: z.infer<typeof ListGuildMembersResponse>;
page: number;
limit: number;
assetVersion: string;
csrfToken: string;
}> = ({config, guildId, adminAcls, response, page, limit, assetVersion, csrfToken}) => {
return (
<VStack gap={4}>
<HStack gap={4} justify="between" align="center">
<Heading level={3} size="base">
Guild Members ({response.total})
</Heading>
<RenderPaginationInfo offset={response.offset} limit={response.limit} total={response.total} />
</HStack>
{response.members.length === 0 ? (
<Text size="sm" color="muted">
No members found.
</Text>
) : (
<VStack gap={2}>
{response.members.map((member) => (
<RenderMember
config={config}
guildId={guildId}
adminAcls={adminAcls}
member={member}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
))}
</VStack>
)}
<RenderPagination config={config} guildId={guildId} currentPage={page} total={response.total} limit={limit} />
</VStack>
);
};
export async function MembersTab({
config,
session,
guildId,
adminAcls,
page,
assetVersion,
csrfToken,
}: MembersTabProps) {
const limit = 50;
const offset = page * limit;
if (!hasPermission(adminAcls, AdminACLs.GUILD_LIST_MEMBERS)) {
return <ErrorCard title="Permission Denied" message="You don't have permission to view guild members." />;
}
const result = await listGuildMembers(config, session, guildId, limit, offset);
if (!result.ok) {
return <ErrorCard title={getErrorTitle(result.error)} message={getErrorMessage(result.error)} />;
}
return (
<RenderMembersList
config={config}
guildId={guildId}
adminAcls={adminAcls}
response={result.data}
page={page}
limit={limit}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
);
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
interface ModerationTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
export function ModerationTab({config, guild: _guild, guildId, adminAcls, csrfToken}: ModerationTabProps) {
const canUpdateName = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_NAME);
const canUpdateVanity = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_VANITY);
const canTransferOwnership = hasPermission(adminAcls, AdminACLs.GUILD_TRANSFER_OWNERSHIP);
const canForceAddUser = hasPermission(adminAcls, AdminACLs.GUILD_FORCE_ADD_MEMBER);
const canReload = hasPermission(adminAcls, AdminACLs.GUILD_RELOAD);
const canShutdown = hasPermission(adminAcls, AdminACLs.GUILD_SHUTDOWN);
const canDelete = hasPermission(adminAcls, AdminACLs.GUILD_DELETE);
return (
<Stack gap="lg">
{canUpdateName && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Update Guild Name
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_name&tab=moderation`}
onsubmit="return confirm('Are you sure you want to change this guild\\'s name?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="name" placeholder="New guild name" required fullWidth />
<Button type="submit" variant="primary">
Update Name
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canUpdateVanity && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Update Vanity URL
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_vanity&tab=moderation`}
onsubmit="return confirm('Are you sure you want to change this guild\\'s vanity URL?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="vanity_url_code" placeholder="vanity-code (leave empty to remove)" fullWidth />
<Button type="submit" variant="primary">
Update Vanity URL
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canTransferOwnership && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Transfer Ownership
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=transfer_ownership&tab=moderation`}
onsubmit="return confirm('Are you sure you want to transfer ownership of this guild? This action cannot be easily undone.')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="new_owner_id" placeholder="New owner user ID" required fullWidth />
<Button type="submit" variant="danger">
Transfer Ownership
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canForceAddUser && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Force Add User to Guild
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=force_add_user&tab=moderation`}
onsubmit="return confirm('Are you sure you want to force add this user to the guild?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="user_id" placeholder="User ID to add" required fullWidth />
<Button type="submit" variant="primary">
Add User
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{(canReload || canShutdown) && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Guild Process Controls
</Heading>
<div class="flex flex-wrap gap-3">
{canReload && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=reload&tab=moderation`}
onsubmit="return confirm('Are you sure you want to reload this guild process?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="success">
Reload Guild
</Button>
</form>
)}
{canShutdown && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=shutdown&tab=moderation`}
onsubmit="return confirm('Are you sure you want to shutdown this guild process?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="danger">
Shutdown Guild
</Button>
</form>
)}
</div>
</Stack>
</Card>
)}
{canDelete && (
<Card padding="md">
<Stack gap="md">
<VStack gap={1}>
<Heading level={2} size="base">
Delete Guild
</Heading>
<Text size="sm" color="muted">
Deleting a guild permanently removes it and all associated data. This action cannot be undone.
</Text>
</VStack>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=delete_guild&tab=moderation`}
onsubmit="return confirm('Are you sure you want to permanently delete this guild? This action cannot be undone.')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="danger">
Delete Guild
</Button>
</form>
</Stack>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {GuildChannel, GuildLookupResult, GuildRole} from '@fluxer/admin/src/api/Guilds';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {EmptyState} from '@fluxer/admin/src/components/ui/EmptyState';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {FLUXER_EPOCH} from '@fluxer/constants/src/Core';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {InfoGrid, InfoItem} from '@fluxer/ui/src/components/Layout';
import {
getGuildBannerUrl,
getGuildEmbedSplashUrl,
getGuildIconUrl,
getGuildSplashUrl,
} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
function getCurrentSnowflake(): string {
const now = Date.now();
const timestampOffset = now - FLUXER_EPOCH;
const snowflake = BigInt(timestampOffset) * 4_194_304n;
return snowflake.toString();
}
function channelTypeToString(type: number): string {
switch (type) {
case 0:
return 'Text';
case 2:
return 'Voice';
case 4:
return 'Category';
default:
return `Unknown (${type})`;
}
}
function intToHex(i: number): string {
if (i === 0) return '000000';
const r = Math.floor(i / 65536) % 256;
const g = Math.floor(i / 256) % 256;
const b = i % 256;
return byteToHex(r) + byteToHex(g) + byteToHex(b);
}
function byteToHex(byte: number): string {
const hexDigits = '0123456789ABCDEF';
const high = Math.floor(byte / 16);
const low = byte % 16;
return (hexDigits[high] ?? '0') + (hexDigits[low] ?? '0');
}
interface OverviewTabProps {
config: Config;
guild: GuildLookupResult;
csrfToken: string;
}
const RenderChannel: FC<{config: Config; channel: GuildChannel}> = ({config, channel}) => {
const currentSnowflake = getCurrentSnowflake();
return (
<a
href={`${config.basePath}/messages?channel_id=${channel.id}&message_id=${currentSnowflake}&context_limit=50`}
class="flex items-center gap-3 rounded border border-neutral-200 bg-neutral-50 p-3 transition-colors hover:bg-neutral-100"
>
<VStack gap={0} class="flex-1">
<Text size="sm" weight="semibold">
{channel.name}
</Text>
<Text size="sm" color="muted">
{channel.id}
</Text>
</VStack>
<Text size="sm" color="muted">
{channelTypeToString(channel.type)}
</Text>
</a>
);
};
const RenderRole: FC<{role: GuildRole}> = ({role}) => {
const colorHex = intToHex(role.color);
return (
<HStack gap={3} align="center" class="rounded border border-neutral-200 bg-neutral-50 p-3">
<div class="h-4 w-4 rounded" style={`background-color: #${colorHex}`} />
<VStack gap={0} class="flex-1">
<Text size="sm" weight="semibold">
{role.name}
</Text>
<Text size="sm" color="muted">
{role.id}
</Text>
</VStack>
<HStack gap={2}>
{role.hoist && <Badge variant="info">Hoisted</Badge>}
{role.mentionable && <Badge variant="success">Mentionable</Badge>}
</HStack>
</HStack>
);
};
const RenderSearchIndexButton: FC<{
config: Config;
guildId: string;
title: string;
indexType: string;
csrfToken: string;
}> = ({config, guildId, title, indexType, csrfToken}) => {
return (
<form method="post" action={`${config.basePath}/guilds/${guildId}?action=refresh_search_index`} class="w-full">
<CsrfInput token={csrfToken} />
<input type="hidden" name="index_type" value={indexType} />
<input type="hidden" name="guild_id" value={guildId} />
<Button type="submit" variant="secondary" fullWidth>
Refresh {title}
</Button>
</form>
);
};
const AssetPreview: FC<{
label: string;
url: string | null;
hash: string | null;
variant: 'square' | 'wide';
}> = ({label, url, hash, variant}) => {
const imageClass =
variant === 'square'
? 'h-24 w-24 rounded bg-neutral-100 object-cover'
: 'h-36 w-full rounded bg-neutral-100 object-cover';
return (
<VStack gap={2} class="rounded-lg border border-neutral-200 bg-white p-3">
<Text size="sm" weight="semibold">
{label}
</Text>
{url ? (
<a href={url} target="_blank" rel="noreferrer noopener" class="block">
<img src={url} alt={`${label} preview`} class={imageClass} loading="lazy" />
</a>
) : (
<div
class={`flex items-center justify-center rounded bg-neutral-100 text-neutral-500 text-sm ${
variant === 'square' ? 'h-24 w-24' : 'h-36 w-full'
}`}
>
Not set
</div>
)}
<Text size="xs" color="muted" class="break-all font-mono">
Hash: {hash ?? 'null'}
</Text>
</VStack>
);
};
export function OverviewTab({config, guild, csrfToken}: OverviewTabProps) {
const sortedChannels = [...guild.channels].sort((a, b) => a.position - b.position);
const sortedRoles = [...guild.roles].sort((a, b) => b.position - a.position);
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
const bannerUrl = getGuildBannerUrl(config.mediaEndpoint, guild.id, guild.banner, true);
const splashUrl = getGuildSplashUrl(config.mediaEndpoint, guild.id, guild.splash);
const embedSplashUrl = getGuildEmbedSplashUrl(config.mediaEndpoint, guild.id, guild.embed_splash);
return (
<VStack gap={6}>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Assets
</Heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<AssetPreview label="Icon" url={iconUrl} hash={guild.icon} variant="square" />
<AssetPreview label="Banner" url={bannerUrl} hash={guild.banner} variant="wide" />
<AssetPreview label="Splash" url={splashUrl} hash={guild.splash} variant="wide" />
<AssetPreview label="Embed Splash" url={embedSplashUrl} hash={guild.embed_splash} variant="wide" />
</div>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Guild Information
</Heading>
<InfoGrid>
<InfoItem label="Guild ID" value={guild.id} />
<InfoItem label="Name" value={guild.name} />
<InfoItem label="Member Count" value={String(guild.member_count)} />
<InfoItem label="Vanity URL" value={guild.vanity_url_code ?? 'None'} />
<VStack gap={1}>
<Text size="sm" weight="semibold" color="muted">
Owner ID
</Text>
<a
href={`${config.basePath}/users/${guild.owner_id}`}
class="text-neutral-900 text-sm hover:text-blue-600 hover:underline"
>
{guild.owner_id}
</a>
</VStack>
</InfoGrid>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Features
</Heading>
{guild.features.length === 0 ? (
<EmptyState variant="empty">No features enabled</EmptyState>
) : (
<HStack gap={2} class="flex-wrap">
{guild.features.map((feature) => (
<Badge variant="info">{feature}</Badge>
))}
</HStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Channels ({guild.channels.length})
</Heading>
{guild.channels.length === 0 ? (
<EmptyState variant="empty">No channels</EmptyState>
) : (
<VStack gap={2}>
{sortedChannels.map((channel) => (
<RenderChannel config={config} channel={channel} />
))}
</VStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Roles ({guild.roles.length})
</Heading>
{guild.roles.length === 0 ? (
<EmptyState variant="empty">No roles</EmptyState>
) : (
<VStack gap={2}>
{sortedRoles.map((role) => (
<RenderRole role={role} />
))}
</VStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3} size="base">
Search Index Management
</Heading>
<Text size="sm" color="muted">
Refresh search indexes for this guild.
</Text>
</VStack>
<RenderSearchIndexButton
config={config}
guildId={guild.id}
title="Channel Messages"
indexType="channel_messages"
csrfToken={csrfToken}
/>
<RenderSearchIndexButton
config={config}
guildId={guild.id}
title="Guild Members"
indexType="guild_members"
csrfToken={csrfToken}
/>
</VStack>
</Card>
</VStack>
);
}

View File

@@ -0,0 +1,285 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Select} from '@fluxer/admin/src/components/ui/Select';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {RenderDisabledOperationsForm} from '@fluxer/admin/src/pages/guild_detail/Forms';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import {InfoGrid, InfoItem} from '@fluxer/ui/src/components/Layout';
interface SettingsTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
function verificationLevelToString(level: number): string {
switch (level) {
case 0:
return 'None';
case 1:
return 'Low (verified email)';
case 2:
return 'Medium (registered for 5 minutes)';
case 3:
return 'High (member for 10 minutes)';
case 4:
return 'Very High (verified phone)';
default:
return `Unknown (${level})`;
}
}
function mfaLevelToString(level: number): string {
switch (level) {
case 0:
return 'None';
case 1:
return 'Elevated';
default:
return `Unknown (${level})`;
}
}
function nsfwLevelToString(level: number): string {
switch (level) {
case 0:
return 'Default';
case 1:
return 'Explicit';
case 2:
return 'Safe';
case 3:
return 'Age Restricted';
default:
return `Unknown (${level})`;
}
}
function contentFilterToString(level: number): string {
switch (level) {
case 0:
return 'Disabled';
case 1:
return 'Members without roles';
case 2:
return 'All members';
default:
return `Unknown (${level})`;
}
}
function notificationLevelToString(level: number): string {
switch (level) {
case 0:
return 'All messages';
case 1:
return 'Only mentions';
default:
return `Unknown (${level})`;
}
}
export function SettingsTab({config, guild, guildId, adminAcls, csrfToken}: SettingsTabProps) {
const canUpdateSettings = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_SETTINGS);
return (
<VStack gap={6}>
{canUpdateSettings ? (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Guild Settings
</Heading>
<form method="post" action={`${config.basePath}/guilds/${guildId}?action=update_settings&tab=settings`}>
<CsrfInput token={csrfToken} />
<Grid cols={2} gap="md">
<FormFieldGroup label="Verification Level" htmlFor="guild-verification-level">
<Select
id="guild-verification-level"
name="verification_level"
value={String(guild.verification_level)}
options={[
{value: '0', label: 'None'},
{value: '1', label: 'Low (verified email)'},
{value: '2', label: 'Medium (5+ minutes)'},
{value: '3', label: 'High (10+ minutes)'},
{value: '4', label: 'Very High (verified phone)'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="MFA Level" htmlFor="guild-mfa-level">
<Select
id="guild-mfa-level"
name="mfa_level"
value={String(guild.mfa_level)}
options={[
{value: '0', label: 'None'},
{value: '1', label: 'Elevated'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="NSFW Level" htmlFor="guild-nsfw-level">
<Select
id="guild-nsfw-level"
name="nsfw_level"
value={String(guild.nsfw_level)}
options={[
{value: '0', label: 'Default'},
{value: '1', label: 'Explicit'},
{value: '2', label: 'Safe'},
{value: '3', label: 'Age Restricted'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="Explicit Content Filter" htmlFor="guild-explicit-content-filter">
<Select
id="guild-explicit-content-filter"
name="explicit_content_filter"
value={String(guild.explicit_content_filter)}
options={[
{value: '0', label: 'Disabled'},
{value: '1', label: 'Members without roles'},
{value: '2', label: 'All members'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="Default Notifications" htmlFor="guild-default-notifications">
<Select
id="guild-default-notifications"
name="default_message_notifications"
value={String(guild.default_message_notifications)}
options={[
{value: '0', label: 'All messages'},
{value: '1', label: 'Only mentions'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
</Grid>
<VStack gap={0} class="mt-6 border-neutral-200 border-t pt-6">
<Button type="submit" variant="primary">
Save Settings
</Button>
</VStack>
</form>
</VStack>
</Card>
) : (
<Card padding="md">
<VStack gap={4}>
<Text size="base" weight="semibold">
Guild Settings
</Text>
<InfoGrid>
<InfoItem label="Verification Level" value={verificationLevelToString(guild.verification_level)} />
<InfoItem label="MFA Level" value={mfaLevelToString(guild.mfa_level)} />
<InfoItem label="NSFW Level" value={nsfwLevelToString(guild.nsfw_level)} />
<InfoItem label="Explicit Content Filter" value={contentFilterToString(guild.explicit_content_filter)} />
<InfoItem
label="Default Notifications"
value={notificationLevelToString(guild.default_message_notifications)}
/>
<InfoItem label="AFK Timeout" value={`${guild.afk_timeout} seconds`} />
</InfoGrid>
</VStack>
</Card>
)}
{canUpdateSettings ? (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Disabled Operations
</Heading>
<RenderDisabledOperationsForm
config={config}
currentDisabledOperations={guild.disabled_operations}
guildId={guildId}
csrfToken={csrfToken}
/>
</VStack>
</Card>
) : (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Disabled Operations
</Heading>
<Text size="sm" color="muted">
Bitfield value: {guild.disabled_operations}
</Text>
</VStack>
</Card>
)}
{canUpdateSettings && (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Clear Guild Fields
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=clear_fields&tab=settings`}
onsubmit="return confirm('Are you sure you want to clear these fields?')"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
<VStack gap={2}>
<Checkbox name="fields[]" value="icon" label="Icon" />
<Checkbox name="fields[]" value="banner" label="Banner" />
<Checkbox name="fields[]" value="splash" label="Splash" />
<Checkbox name="fields[]" value="embed_splash" label="Embed Splash" />
</VStack>
<Button type="submit" variant="danger">
Clear Selected Fields
</Button>
</VStack>
</form>
</VStack>
</Card>
)}
</VStack>
);
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {listGuildStickers} from '@fluxer/admin/src/api/GuildAssets';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {GuildStickerAsset} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface StickersTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
function stickerAnimatedLabel(animated: boolean): string {
return animated ? 'Animated' : 'Static';
}
const RenderPermissionNotice: FC = () => (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Permission required
</Heading>
<Text size="sm" color="muted">
You need the {AdminACLs.ASSET_PURGE} ACL to manage guild stickers.
</Text>
</Stack>
</Card>
);
const RenderStickerCard: FC<{config: Config; guildId: string; sticker: GuildStickerAsset; csrfToken: string}> = ({
config,
guildId,
sticker,
csrfToken,
}) => {
return (
<Card padding="none" class="overflow-hidden shadow-sm">
<VStack gap={0}>
<VStack gap={0} class="h-32 items-center justify-center bg-neutral-100 p-6">
<img src={sticker.media_url} alt={sticker.name} class="max-h-full max-w-full object-contain" loading="lazy" />
</VStack>
<VStack gap={1} class="flex-1 px-4 py-3">
<HStack gap={2} justify="between" align="center">
<Text size="sm" weight="semibold">
{sticker.name}
</Text>
<Badge size="sm" variant="neutral">
{stickerAnimatedLabel(sticker.animated)}
</Badge>
</HStack>
<Caption class="break-words">ID: {sticker.id}</Caption>
<a href={`${config.basePath}/users/${sticker.creator_id}`} class="text-blue-600 text-xs hover:underline">
Uploader: {sticker.creator_id}
</a>
<form
action={`${config.basePath}/guilds/${guildId}?tab=stickers&action=delete_sticker`}
method="post"
class="mt-4"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="sticker_id" value={sticker.id} />
<Button type="submit" variant="danger" size="small" fullWidth>
Delete Sticker
</Button>
</form>
</VStack>
</VStack>
</Card>
);
};
const RenderStickers: FC<{config: Config; guildId: string; stickers: Array<GuildStickerAsset>; csrfToken: string}> = ({
config,
guildId,
stickers,
csrfToken,
}) => {
return (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Stickers ({stickers.length})
</Heading>
{stickers.length === 0 ? (
<Text size="sm" color="muted">
No stickers found for this guild.
</Text>
) : (
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{stickers.map((sticker) => (
<RenderStickerCard config={config} guildId={guildId} sticker={sticker} csrfToken={csrfToken} />
))}
</div>
)}
</Stack>
</Card>
);
};
export async function StickersTab({config, session, guildId, adminAcls, csrfToken}: StickersTabProps) {
const hasAssetPurge = hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
if (!hasAssetPurge) {
return <RenderPermissionNotice />;
}
const result = await listGuildStickers(config, session, guildId);
if (!result.ok) {
return (
<VStack gap={4}>
<ErrorCard title="Error" message={getErrorMessage(result.error)} />
<a
href={`${config.basePath}/guilds/${guildId}?tab=stickers`}
class="inline-block rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
Back to Guild
</a>
</VStack>
);
}
return <RenderStickers config={config} guildId={guildId} stickers={result.data.stickers} csrfToken={csrfToken} />;
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {
ALL_ACLS,
PATCHABLE_FLAGS,
SELF_HOSTED_PATCHABLE_FLAGS,
SUSPICIOUS_ACTIVITY_FLAGS,
} from '@fluxer/admin/src/AdminPackageConstants';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import {hasBigIntFlag} from '@fluxer/admin/src/utils/Bigint';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {CheckboxForm, CheckboxItem} from '@fluxer/ui/src/components/CheckboxForm';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
export function FlagsForm({
currentFlags,
csrfToken,
selfHosted,
}: {
currentFlags: string;
csrfToken: string;
selfHosted: boolean;
}) {
const flagsBigInt = BigInt(currentFlags);
const flags = selfHosted ? SELF_HOSTED_PATCHABLE_FLAGS : PATCHABLE_FLAGS;
return (
<CheckboxForm id="flags-form" action="?action=update_flags">
<CsrfInput token={csrfToken} />
<Stack gap="sm">
{flags.map((flag) => (
<CheckboxItem
name="flags[]"
value={flag.value.toString()}
label={flag.name}
checked={hasBigIntFlag(flagsBigInt, flag.value)}
saveButtonId="flags-form-save-button"
/>
))}
</Stack>
</CheckboxForm>
);
}
export function SuspiciousFlagsForm({currentFlags, csrfToken}: {currentFlags: number; csrfToken: string}) {
return (
<CheckboxForm id="suspicious-flags-form" action="?action=update_suspicious_flags">
<CsrfInput token={csrfToken} />
<Stack gap="sm">
{SUSPICIOUS_ACTIVITY_FLAGS.map((flag) => (
<CheckboxItem
name="suspicious_flags[]"
value={String(flag.value)}
label={flag.name}
checked={(currentFlags & flag.value) === flag.value}
saveButtonId="suspicious-flags-form-save-button"
/>
))}
</Stack>
</CheckboxForm>
);
}
export function AclsForm({
user,
adminAcls,
csrfToken,
}: {
user: UserAdminResponse;
adminAcls: Array<string>;
csrfToken: string;
}) {
const canEditAcls = adminAcls.includes(AdminACLs.ACL_SET_USER) || adminAcls.includes(AdminACLs.WILDCARD);
const isDisabled = !canEditAcls;
if (isDisabled) {
if (user.acls.length === 0) {
return (
<Text size="sm" color="muted" class="italic">
No ACLs assigned
</Text>
);
}
return (
<Stack gap="sm">
{user.acls.map((acl: string) => (
<div class="rounded bg-neutral-50 px-2 py-1 text-neutral-700 text-sm">{acl}</div>
))}
</Stack>
);
}
return (
<CheckboxForm id="acls-form" action="?action=update_acls">
<CsrfInput token={csrfToken} />
<div class="max-h-96 overflow-y-auto overscroll-contain">
<Stack gap="sm">
{ALL_ACLS.map((acl) => (
<CheckboxItem
name="acls[]"
value={acl}
label={acl}
checked={user.acls.includes(acl)}
saveButtonId="acls-form-save-button"
/>
))}
</Stack>
</div>
</CheckboxForm>
);
}
export function TraitsForm({
definitions,
currentTraits,
customTraits,
csrfToken,
}: {
definitions: Array<string>;
currentTraits: Array<string>;
customTraits: Array<string>;
csrfToken: string;
}): ReturnType<
FC<{
definitions: Array<string>;
currentTraits: Array<string>;
customTraits: Array<string>;
csrfToken: string;
}>
> {
const traitsPresent = definitions.length > 0;
return (
<form method="post" action="?action=update_traits&tab=overview">
<CsrfInput token={csrfToken} />
<Stack gap="md">
{traitsPresent ? (
<Grid cols={2} gap="sm">
{definitions.map((def) => (
<TraitCheckbox definition={def} currentTraits={currentTraits} />
))}
</Grid>
) : (
<Text size="sm" color="muted">
No trait definitions are configured yet. Define them under Instance Configuration &gt; Limit Configuration.
</Text>
)}
<FormFieldGroup
label="Custom traits"
htmlFor="custom-traits"
helper="Enter additional trait names that do not appear above. Separate values with commas or line breaks."
>
<Textarea
id="custom-traits"
name="custom_traits"
rows={3}
placeholder="e.g. beta-tester, experimental"
value={customTraits.join(', ')}
/>
</FormFieldGroup>
<div class="flex justify-end">
<Button type="submit" variant="primary">
Save trait assignments
</Button>
</div>
</Stack>
</form>
);
}
const TraitCheckbox: FC<{definition: string; currentTraits: Array<string>}> = ({definition, currentTraits}) => {
const isChecked = currentTraits.includes(definition);
return <Checkbox name="traits[]" value={definition} label={definition} checked={isChecked} />;
};

View File

@@ -0,0 +1,350 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {ApiError} from '@fluxer/admin/src/api/Errors';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
ListUserSessionsResponse,
UserAdminResponse,
UserSessionResponse,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
interface AccountTabProps {
config: Config;
user: UserAdminResponse;
userId: string;
sessionsResult: {ok: true; data: ListUserSessionsResponse} | {ok: false; error: ApiError} | null;
csrfToken: string;
}
export function AccountTab({config: _config, user, userId: _userId, sessionsResult, csrfToken}: AccountTabProps) {
return (
<VStack gap={6}>
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Edit Account Information
</Heading>
<Grid cols={2} gap="md">
<VStack gap={2}>
<form
method="post"
action="?action=change_username&tab=account"
onsubmit="return confirm('Are you sure you want to change this user\\'s username?')"
class="w-full"
>
<CsrfInput token={csrfToken} />
<VStack gap={2}>
<Text size="sm" weight="medium" class="text-neutral-700">
Change Username:
</Text>
<Input type="text" name="username" placeholder="New username" required />
<Input type="number" name="discriminator" placeholder="Discriminator (optional)" min="0" max="9999" />
<Button type="submit" variant="primary" fullWidth>
Change Username
</Button>
</VStack>
</form>
</VStack>
<VStack gap={2}>
<form
method="post"
action="?action=change_email&tab=account"
onsubmit="return confirm('Are you sure you want to change this user\\'s email address?')"
class="w-full"
>
<CsrfInput token={csrfToken} />
<VStack gap={2}>
<Text size="sm" weight="medium" class="text-neutral-700">
Change Email:
</Text>
<Input type="email" name="email" placeholder="New email address" required />
<Button type="submit" variant="primary" fullWidth>
Change Email
</Button>
</VStack>
</form>
</VStack>
<VStack gap={2}>
<form
method="post"
action="?action=change_dob&tab=account"
onsubmit="return confirm('Are you sure you want to change this user\\'s date of birth?')"
class="w-full"
>
<CsrfInput token={csrfToken} />
<VStack gap={2}>
<Text size="sm" weight="medium" class="text-neutral-700">
Change Date of Birth:
</Text>
<Input type="date" name="date_of_birth" value={user.date_of_birth ?? ''} required />
<Button type="submit" variant="primary" fullWidth>
Change Date of Birth
</Button>
</VStack>
</form>
</VStack>
</Grid>
</VStack>
</Card>
{sessionsResult?.ok && (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Active Sessions
</Heading>
{sessionsResult.data.sessions.length === 0 ? (
<Text size="sm" color="muted">
No active sessions
</Text>
) : (
<VStack gap={3}>
{sessionsResult.data.sessions.map((session) => (
<SessionCard session={session} />
))}
</VStack>
)}
</VStack>
</Card>
)}
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Quick Actions
</Heading>
<HStack gap={3} class="flex-wrap">
{!user.email_verified && (
<form method="post" action="?action=verify_email&tab=account">
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Verify Email
</Button>
</form>
)}
{user.phone && (
<form
method="post"
action="?action=unlink_phone&tab=account"
onsubmit="return confirm('Are you sure you want to unlink this user\\'s phone number?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Unlink Phone
</Button>
</form>
)}
<form method="post" action="?action=send_password_reset&tab=account">
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Send Password Reset
</Button>
</form>
</HStack>
</VStack>
</Card>
{(user.avatar || user.banner || user.bio || user.pronouns || user.global_name) && (
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Clear Profile Fields
</Heading>
<form
method="post"
action="?action=clear_fields&tab=account"
onsubmit="return confirm('Are you sure you want to clear the selected fields for this user?')"
>
<CsrfInput token={csrfToken} />
<VStack gap={4}>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
{user.avatar && <Checkbox name="fields[]" value="avatar" label="Avatar" />}
{user.banner && <Checkbox name="fields[]" value="banner" label="Banner" />}
{user.bio && <Checkbox name="fields[]" value="bio" label="Bio" />}
{user.pronouns && <Checkbox name="fields[]" value="pronouns" label="Pronouns" />}
{user.global_name && <Checkbox name="fields[]" value="global_name" label="Display Name" />}
</div>
<Button type="submit" variant="primary" fullWidth>
Clear Selected Fields
</Button>
</VStack>
</form>
</VStack>
</Card>
)}
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
User Status
</Heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<form
method="post"
action={`?action=set_bot_status&status=${user.bot ? 'false' : 'true'}&tab=account`}
onsubmit={`return confirm('Are you sure you want to ${user.bot ? 'remove' : 'set'} bot status for this user?')`}
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary" fullWidth>
{user.bot ? 'Remove Bot Status' : 'Set Bot Status'}
</Button>
</form>
<form
method="post"
action={`?action=set_system_status&status=${user.system ? 'false' : 'true'}&tab=account`}
onsubmit={`return confirm('Are you sure you want to ${user.system ? 'remove' : 'set'} system status for this user?')`}
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary" fullWidth>
{user.system ? 'Remove System Status' : 'Set System Status'}
</Button>
</form>
</div>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Security Actions
</Heading>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{user.has_totp && (
<form
method="post"
action="?action=disable_mfa&tab=account"
onsubmit="return confirm('Are you sure you want to disable MFA/TOTP for this user?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary" fullWidth>
Disable MFA/TOTP
</Button>
</form>
)}
<form
method="post"
action="?action=terminate_sessions&tab=account"
onsubmit="return confirm('Are you sure you want to terminate all sessions for this user?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary" fullWidth>
Terminate All Sessions
</Button>
</form>
</div>
</VStack>
</Card>
</VStack>
);
}
const SessionCard: FC<{session: UserSessionResponse}> = ({session}) => {
function formatSessionTimestamp(value: string): string {
const [datePart, timePartRaw] = value.split('T');
if (!datePart || !timePartRaw) return value;
const timePart = timePartRaw.replace('Z', '').split('.')[0] ?? timePartRaw;
return `${datePart} ${timePart}`;
}
return (
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<div class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-3">
<div>
<Text size="sm" weight="medium" color="muted">
Session ID
</Text>
<Text size="xs" class="text-neutral-900">
<span class="font-mono">{session.session_id_hash}</span>
</Text>
</div>
<div>
<Text size="sm" weight="medium" color="muted">
Created
</Text>
<Text size="sm" class="text-neutral-900">
{formatSessionTimestamp(session.created_at)}
</Text>
</div>
<div>
<Text size="sm" weight="medium" color="muted">
Last Used
</Text>
<Text size="sm" class="text-neutral-900">
{formatSessionTimestamp(session.approx_last_used_at)}
</Text>
</div>
<div class="md:col-span-3">
<Text size="sm" weight="medium" color="muted">
IP Address
</Text>
<Text size="sm" class="text-neutral-900">
<span class="font-mono">{session.client_ip}</span>
{session.client_ip_reverse && <span class="ml-2 text-neutral-600">({session.client_ip_reverse})</span>}
</Text>
</div>
{session.client_platform && (
<div>
<Text size="sm" weight="medium" color="muted">
Platform
</Text>
<Text size="sm" class="text-neutral-900">
{session.client_platform}
</Text>
</div>
)}
{session.client_os && (
<div>
<Text size="sm" weight="medium" color="muted">
OS
</Text>
<Text size="sm" class="text-neutral-900">
{session.client_os}
</Text>
</div>
)}
{session.client_location && (
<div>
<Text size="sm" weight="medium" color="muted">
Location
</Text>
<Text size="sm" class="text-neutral-900">
{session.client_location}
</Text>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,250 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {type ApiError, getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {ListUserDmChannelsResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import type {FC} from 'hono/jsx';
interface DmHistoryTabProps {
config: Config;
userId: string;
dmChannelsResult: {ok: true; data: ListUserDmChannelsResponse} | {ok: false; error: ApiError} | null;
before: string | null;
after: string | null;
limit: number;
}
export function DmHistoryTab({config, userId, dmChannelsResult, before, after, limit}: DmHistoryTabProps) {
if (!dmChannelsResult) {
return null;
}
if (!dmChannelsResult.ok) {
return (
<Card padding="md">
<Stack gap="sm">
<Heading level={2} size="base">
DM History
</Heading>
<Text size="sm" color="muted">
Failed to load DM history: {getErrorMessage(dmChannelsResult.error)}
</Text>
</Stack>
</Card>
);
}
const channels = dmChannelsResult.data.channels;
return (
<Stack gap="lg">
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">{`DM History (${channels.length})`}</Heading>
<Text size="sm" color="muted">
Historical one-to-one DMs for this user. Group DMs are not included in this dataset.
</Text>
{channels.length === 0 ? (
<Text size="sm" color="muted">
No historical DM channels found.
</Text>
) : (
<DmChannelsGrid config={config} userId={userId} channels={channels} />
)}
</Stack>
</Card>
<DmHistoryPagination
config={config}
userId={userId}
channels={channels}
limit={limit}
before={before}
after={after}
/>
</Stack>
);
}
const DmChannelsGrid: FC<{
config: Config;
userId: string;
channels: ListUserDmChannelsResponse['channels'];
}> = ({config, userId, channels}) => {
return (
<Grid cols={1} gap="md">
{channels.map((channel) => (
<div
key={channel.channel_id}
class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-colors hover:border-neutral-300"
>
<div class="p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-2 flex items-center gap-2">
<Heading level={3} size="sm">
DM {channel.channel_id}
</Heading>
<span class="rounded bg-neutral-100 px-2 py-0.5 text-neutral-700 text-xs uppercase">
{formatChannelType(channel.channel_type)}
</span>
</div>
<Stack gap="sm">
<Text size="sm" color="muted">
Status: {channel.is_open ? 'Open' : 'Closed'}
</Text>
<Text size="sm" color="muted">
Last Message ID: {channel.last_message_id ?? 'None'}
</Text>
<CounterpartyRow config={config} userId={userId} recipientIds={channel.recipient_ids} />
</Stack>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="primary" size="small" href={buildMessageLookupHref(config, channel.channel_id)}>
View Channel
</Button>
</div>
</div>
</div>
</div>
))}
</Grid>
);
};
const CounterpartyRow: FC<{config: Config; userId: string; recipientIds: Array<string>}> = ({
config,
userId,
recipientIds,
}) => {
const counterpartyIds = recipientIds.filter((recipientId) => recipientId !== userId);
if (counterpartyIds.length === 0) {
return (
<Text size="sm" color="muted">
Counterparty: unavailable
</Text>
);
}
return (
<div class="text-neutral-600 text-sm">
Counterparty:{' '}
{counterpartyIds.map((recipientId, index) => (
<span key={recipientId}>
<a
href={`${config.basePath}/users/${recipientId}`}
class="font-mono transition-colors hover:text-blue-600 hover:underline"
>
{recipientId}
</a>
{index < counterpartyIds.length - 1 ? ', ' : ''}
</span>
))}
</div>
);
};
interface DmHistoryPaginationProps {
config: Config;
userId: string;
channels: ListUserDmChannelsResponse['channels'];
limit: number;
before: string | null;
after: string | null;
}
const DmHistoryPagination: FC<DmHistoryPaginationProps> = ({config, userId, channels, limit, before, after}) => {
const hasNext = channels.length === limit;
const hasPrevious = before !== null || after !== null;
const firstChannel = channels[0];
const lastChannel = channels[channels.length - 1];
const firstId = firstChannel ? firstChannel.channel_id : null;
const lastId = lastChannel ? lastChannel.channel_id : null;
const prevPath = hasPrevious && firstId ? buildPaginationPath(config, userId, null, firstId, limit) : null;
const nextPath = hasNext && lastId ? buildPaginationPath(config, userId, lastId, null, limit) : null;
if (!prevPath && !nextPath) {
return null;
}
return (
<div class="flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-2">
{prevPath && (
<a
href={prevPath}
class="rounded px-3 py-1 font-medium text-neutral-600 text-sm transition-colors hover:bg-white hover:text-neutral-900"
>
Previous
</a>
)}
{nextPath && (
<a
href={nextPath}
class="rounded px-3 py-1 font-medium text-neutral-600 text-sm transition-colors hover:bg-white hover:text-neutral-900"
>
Next
</a>
)}
</div>
);
};
function buildPaginationPath(
config: Config,
userId: string,
before: string | null,
after: string | null,
limit: number,
): string {
const params = [`tab=dm_history`, `dm_limit=${limit}`];
if (before) {
params.push(`dm_before=${before}`);
}
if (after) {
params.push(`dm_after=${after}`);
}
return `${config.basePath}/users/${userId}?${params.join('&')}`;
}
function buildMessageLookupHref(config: Config, channelId: string): string {
const params = new URLSearchParams();
params.set('channel_id', channelId);
params.set('context_limit', '50');
return `${config.basePath}/messages?${params.toString()}`;
}
function formatChannelType(channelType: number | null): string {
if (channelType === 1) {
return 'DM';
}
if (channelType === 3) {
return 'Group DM';
}
return 'Unknown';
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {ApiError} from '@fluxer/admin/src/api/Errors';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {GuildAdminResponse, ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {getGuildIconUrl, getInitials as getInitialsFromName} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
interface GuildsTabProps {
config: Config;
user: UserAdminResponse;
userId: string;
guildsResult: {ok: true; data: ListUserGuildsResponse} | {ok: false; error: ApiError} | null;
before: string | null;
after: string | null;
limit: number;
withCounts: boolean;
}
export function GuildsTab({
config,
user: _user,
userId,
guildsResult,
before,
after,
limit,
withCounts,
}: GuildsTabProps) {
if (!guildsResult?.ok) {
return null;
}
const guilds = guildsResult.data.guilds;
return (
<Stack gap="lg">
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">{`Guilds (${guilds.length})`}</Heading>
{guilds.length === 0 ? (
<Text size="sm" color="muted">
No guilds
</Text>
) : (
<GuildsGrid config={config} guilds={guilds} />
)}
</Stack>
</Card>
<GuildsPagination
config={config}
userId={userId}
guilds={guilds}
limit={limit}
withCounts={withCounts}
before={before}
after={after}
/>
</Stack>
);
}
const GuildsGrid: FC<{config: Config; guilds: Array<GuildAdminResponse>}> = ({config, guilds}) => {
return (
<Grid cols={1} gap="md">
{guilds.map((guild) => (
<GuildCard config={config} guild={guild} />
))}
</Grid>
);
};
const GuildCard: FC<{config: Config; guild: GuildAdminResponse}> = ({config, guild}) => {
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
return (
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-colors hover:border-neutral-300">
<div class="p-5">
<div class="flex items-center gap-4">
{iconUrl ? (
<div class="flex-shrink-0">
<img src={iconUrl} alt={guild.name} class="h-16 w-16 rounded-full" />
</div>
) : (
<div class="flex-shrink-0">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-200 font-medium text-base text-neutral-600">
{getInitialsFromName(guild.name)}
</div>
</div>
)}
<div class="min-w-0 flex-1">
<div class="mb-2 flex items-center gap-2">
<Heading level={2} size="base">
{guild.name}
</Heading>
{guild.features.length > 0 && (
<span class="rounded bg-purple-100 px-2 py-0.5 text-purple-700 text-xs uppercase">Featured</span>
)}
</div>
<Stack gap="sm">
<Text size="sm" color="muted">
ID: {guild.id}
</Text>
<Text size="sm" color="muted">
Members: {guild.member_count}
</Text>
<Text size="sm" color="muted">
Owner:{' '}
<a
href={`${config.basePath}/users/${guild.owner_id}`}
class="transition-colors hover:text-blue-600 hover:underline"
>
{guild.owner_id}
</a>
</Text>
</Stack>
</div>
<Button variant="primary" size="small" href={`${config.basePath}/guilds/${guild.id}`}>
View Details
</Button>
</div>
</div>
</div>
);
};
interface GuildsPaginationProps {
config: Config;
userId: string;
guilds: Array<GuildAdminResponse>;
limit: number;
withCounts: boolean;
before: string | null;
after: string | null;
}
const GuildsPagination: FC<GuildsPaginationProps> = ({config, userId, guilds, limit, withCounts, before, after}) => {
const hasNext = guilds.length === limit;
const hasPrevious = before !== null || after !== null;
const firstGuild = guilds[0];
const lastGuild = guilds[guilds.length - 1];
const firstId = firstGuild ? firstGuild.id : null;
const lastId = lastGuild ? lastGuild.id : null;
const prevPath =
hasPrevious && firstId ? buildGuildsPaginationPath(config, userId, firstId, null, limit, withCounts) : null;
const nextPath =
hasNext && lastId ? buildGuildsPaginationPath(config, userId, null, lastId, limit, withCounts) : null;
if (!prevPath && !nextPath) {
return null;
}
return (
<div class="flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-2">
{prevPath && (
<a
href={prevPath}
class="rounded px-3 py-1 font-medium text-neutral-600 text-sm transition-colors hover:bg-white hover:text-neutral-900"
>
Previous
</a>
)}
{nextPath && (
<a
href={nextPath}
class="rounded px-3 py-1 font-medium text-neutral-600 text-sm transition-colors hover:bg-white hover:text-neutral-900"
>
Next
</a>
)}
</div>
);
};
function buildGuildsPaginationPath(
config: Config,
userId: string,
before: string | null,
after: string | null,
limit: number,
withCounts: boolean,
): string {
return `${config.basePath}/users/${userId}?${buildGuildsPaginationQuery(before, after, limit, withCounts)}`;
}
function buildGuildsPaginationQuery(
before: string | null,
after: string | null,
limit: number,
withCounts: boolean,
): string {
const params = [`tab=guilds`, `guilds_limit=${limit}`, `guilds_with_counts=${boolToFlag(withCounts)}`];
if (before) {
params.push(`guilds_before=${before}`);
}
if (after) {
params.push(`guilds_after=${after}`);
}
return params.join('&');
}
function boolToFlag(value: boolean): string {
return value ? '1' : '0';
}

View File

@@ -0,0 +1,545 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {DELETION_REASONS, TEMP_BAN_DURATIONS} from '@fluxer/admin/src/AdminPackageConstants';
import {type ApiError, getErrorSubtitle, getErrorTitle} from '@fluxer/admin/src/api/Errors';
import type {MessageShredStatusResponse} from '@fluxer/admin/src/api/Messages';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Select} from '@fluxer/admin/src/components/ui/Select';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface ModerationTabProps {
config: Config;
user: UserAdminResponse;
userId: string;
adminAcls: Array<string>;
messageShredJobId: string | null;
messageShredStatusResult: {ok: true; data: MessageShredStatusResponse} | {ok: false; error: ApiError} | null;
deleteAllMessagesDryRun: {channel_count: number; message_count: number} | null;
csrfToken: string;
}
export function ModerationTab({
config,
user,
userId,
adminAcls,
messageShredJobId,
messageShredStatusResult,
deleteAllMessagesDryRun,
csrfToken,
}: ModerationTabProps) {
const canShredMessages = adminAcls.includes(AdminACLs.MESSAGE_SHRED) || adminAcls.includes(AdminACLs.WILDCARD);
const canDeleteAllMessages =
adminAcls.includes(AdminACLs.MESSAGE_DELETE_ALL) || adminAcls.includes(AdminACLs.WILDCARD);
return (
<VStack gap={6}>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Ban Actions
</Heading>
{user.temp_banned_until ? (
<form
method="post"
action="?action=unban&tab=moderation"
onsubmit="return confirm('Are you sure you want to unban this user?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Unban User
</Button>
</form>
) : (
<VStack gap={3} class="w-full">
<form
method="post"
action="?action=temp_ban&tab=moderation"
onsubmit="return confirm('Are you sure you want to ban/suspend this user?')"
class="w-full"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
<FormFieldGroup label="Duration">
<Select
id="temp-ban-duration"
name="duration"
options={TEMP_BAN_DURATIONS.map((dur) => ({value: String(dur.hours), label: dur.label}))}
/>
</FormFieldGroup>
<FormFieldGroup label="Public Reason (optional)">
<Input
id="temp-ban-public-reason"
type="text"
name="reason"
placeholder="Enter public ban reason..."
/>
</FormFieldGroup>
<FormFieldGroup label="Private Reason (optional)">
<Input
id="temp-ban-private-reason"
type="text"
name="private_reason"
placeholder="Enter private ban reason (audit log)..."
/>
</FormFieldGroup>
<Button type="submit" variant="primary">
Ban/Suspend User
</Button>
</VStack>
</form>
</VStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={2} size="base">
Account Deletion
</Heading>
{user.pending_deletion_at ? (
<form
method="post"
action="?action=cancel_deletion&tab=moderation"
onsubmit="return confirm('Are you sure you want to cancel the scheduled deletion for this user?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="primary">
Cancel Deletion
</Button>
</form>
) : (
<VStack gap={3} class="w-full">
<form
method="post"
action="?action=schedule_deletion&tab=moderation"
onsubmit="return confirm('Are you sure you want to schedule this user account for deletion? This action will permanently delete the account after the specified number of days.')"
class="w-full"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
<FormFieldGroup label="Days until deletion">
<Input type="number" id="user-deletion-days" name="days" value="60" min="60" max="365" />
</FormFieldGroup>
<FormFieldGroup label="Reason">
<Select
id="user-deletion-reason"
name="reason_code"
options={DELETION_REASONS.map((reason) => ({value: String(reason.id), label: reason.label}))}
/>
</FormFieldGroup>
<FormFieldGroup label="Public Reason (optional)">
<Input
id="user-deletion-public-reason"
type="text"
name="public_reason"
placeholder="Enter public reason..."
/>
</FormFieldGroup>
<FormFieldGroup label="Private Reason (optional)">
<Input
id="user-deletion-private-reason"
type="text"
name="private_reason"
placeholder="Enter private reason (audit log)..."
/>
</FormFieldGroup>
<Button type="submit" variant="primary">
Schedule Deletion
</Button>
</VStack>
</form>
</VStack>
)}
</VStack>
<DeletionDaysScript />
</Card>
</div>
{canDeleteAllMessages && (
<DeleteAllMessagesSection
config={config}
userId={userId}
dryRunData={deleteAllMessagesDryRun}
csrfToken={csrfToken}
/>
)}
{canShredMessages && (
<MessageShredSection
config={config}
userId={userId}
jobId={messageShredJobId}
statusResult={messageShredStatusResult}
csrfToken={csrfToken}
/>
)}
</VStack>
);
}
const DELETION_DAYS_SCRIPT = `
(function () {
var daysInput = document.getElementById('user-deletion-days');
var reasonSelect = document.getElementById('user-deletion-reason');
if (!daysInput || !reasonSelect) return;
function toInt(value) {
var n = parseInt(value, 10);
return Number.isFinite(n) ? n : 0;
}
function applyMin(minDays) {
daysInput.setAttribute('min', String(minDays));
if (!daysInput.value || toInt(daysInput.value) < minDays) {
daysInput.value = String(minDays);
}
}
// User requested deletions can be shorter; all other reasons default to 60 days.
var USER_REQUESTED_REASON = 1;
function syncMin() {
var reasonId = toInt(reasonSelect.value);
applyMin(reasonId === USER_REQUESTED_REASON ? 14 : 60);
}
syncMin();
reasonSelect.addEventListener('change', syncMin);
})();
`;
const DeletionDaysScript: FC = () => {
return <script defer dangerouslySetInnerHTML={{__html: DELETION_DAYS_SCRIPT}} />;
};
interface DeleteAllMessagesSectionProps {
config: Config;
userId: string;
dryRunData: {channel_count: number; message_count: number} | null;
csrfToken: string;
}
const DeleteAllMessagesSection: FC<DeleteAllMessagesSectionProps> = ({
config: _config,
userId: _userId,
dryRunData,
csrfToken,
}) => {
return (
<Card padding="md">
<VStack gap={4}>
<VStack gap={1}>
<Heading level={2} size="base">
Delete All Messages
</Heading>
<Text size="sm" color="muted">
Locate every message this user has ever sent and permanently remove them. First run a dry run to see how
many channels and messages will be affected.
</Text>
</VStack>
<VStack gap={3}>
<form method="post" action="?action=delete_all_messages&tab=moderation">
<CsrfInput token={csrfToken} />
<input type="hidden" name="dry_run" value="true" />
<Button type="submit" variant="primary">
Preview Deletion
</Button>
</form>
{dryRunData && (
<VStack gap={3} class="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<Text size="sm" class="text-neutral-700">
Channels: {dryRunData.channel_count} &middot; Messages: {dryRunData.message_count}
</Text>
<form
method="post"
action="?action=delete_all_messages&tab=moderation"
onsubmit="return confirm('This will permanently delete every message this user has ever sent. Continue?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="dry_run" value="false" />
<Button type="submit" variant="danger">
Delete All Messages
</Button>
</form>
</VStack>
)}
</VStack>
</VStack>
</Card>
);
};
interface MessageShredSectionProps {
config: Config;
userId: string;
jobId: string | null;
statusResult: {ok: true; data: MessageShredStatusResponse} | {ok: false; error: ApiError} | null;
csrfToken: string;
}
const MessageShredSection: FC<MessageShredSectionProps> = ({config, userId, jobId, statusResult, csrfToken}) => {
const entryHint =
'Upload a CSV file where each row includes the channel_id and message_id separated by a comma. Large files are chunked server-side automatically.';
return (
<Card padding="md">
<VStack gap={4}>
<VStack gap={1}>
<Heading level={2} size="base">
Message Shredder
</Heading>
<Text size="sm" color="muted">
{entryHint}
</Text>
</VStack>
<VStack gap={3}>
<form method="post" action="?action=message_shred&tab=moderation" id="message-shred-form">
<CsrfInput token={csrfToken} />
<input type="hidden" name="csv_data" id="message-shred-csv-data" />
<VStack gap={3}>
<FormFieldGroup label="CSV File">
<input id="message-shred-file" type="file" accept=".csv" class="hidden" />
<div class="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="small"
onclick="document.getElementById('message-shred-file').click()"
>
Choose file
</Button>
<span class="text-neutral-500 text-sm" id="message-shred-file-name">
No file chosen
</span>
</div>
</FormFieldGroup>
<Button type="submit" variant="primary" fullWidth id="message-shred-submit">
Shred Messages
</Button>
</VStack>
</form>
</VStack>
<MessageShredStatusSection config={config} userId={userId} jobId={jobId} statusResult={statusResult} />
<MessageShredFormScript />
</VStack>
</Card>
);
};
interface MessageShredStatusSectionProps {
config: Config;
userId: string;
jobId: string | null;
statusResult: {ok: true; data: MessageShredStatusResponse} | {ok: false; error: ApiError} | null;
}
const MessageShredStatusSection: FC<MessageShredStatusSectionProps> = ({config, userId, jobId, statusResult}) => {
if (!jobId) {
return null;
}
return (
<VStack gap={3} class="rounded-lg border border-neutral-200 bg-white p-4">
<HStack justify="between" align="center">
<Heading level={2} size="sm" class="font-medium">
Message Shred Status
</Heading>
<a href={`${config.basePath}/users/${userId}?tab=moderation`}>
<Text size="sm" color="muted">
Clear
</Text>
</a>
</HStack>
{statusResult ? (
statusResult.ok ? (
<MessageShredStatusContent status={statusResult.data} />
) : statusResult.error.type === 'notFound' ? (
<Text size="sm" class="text-neutral-700">
Preparing job... check back in a moment.
</Text>
) : (
<StatusError error={statusResult.error} />
)
) : (
<Text size="sm" class="text-neutral-700">
Preparing job... check back in a moment.
</Text>
)}
</VStack>
);
};
const MessageShredStatusContent: FC<{status: MessageShredStatusResponse}> = ({status}) => {
if (status.status === 'not_found') {
return (
<VStack gap={3}>
<Text size="sm" class="text-neutral-700">
Status: {formatMessageShredStatusLabel(status.status)}
</Text>
</VStack>
);
}
const percentage = status.total > 0 ? Math.floor((status.processed * 100) / status.total) : 0;
return (
<VStack gap={3}>
<Text size="sm" class="text-neutral-700">
Status: {formatMessageShredStatusLabel(status.status)}
</Text>
<Text size="sm" class="text-neutral-700">
Requested {status.requested} entries, skipped {status.skipped} entries
</Text>
{status.status === 'in_progress' && (
<VStack gap={2}>
<HStack justify="between" class="text-neutral-700 text-sm">
<span>
{status.processed} / {status.total} ({percentage}%)
</span>
</HStack>
<div class="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
<div class="h-2 bg-neutral-900 transition-[width] duration-300" style={`width: ${percentage}%`} />
</div>
</VStack>
)}
{status.status === 'completed' && (
<Text size="sm" class="text-neutral-700">
Deleted {status.processed} / {status.total} entries
</Text>
)}
{status.started_at && (
<Caption class="text-neutral-500">Started {formatTimestampLocal(status.started_at)}</Caption>
)}
{status.completed_at && (
<Caption class="text-neutral-500">Completed {formatTimestampLocal(status.completed_at)}</Caption>
)}
{status.failed_at && <Caption class="text-red-600">Failed {formatTimestampLocal(status.failed_at)}</Caption>}
{status.error && (
<Text size="sm" class="text-red-600">
{status.error}
</Text>
)}
</VStack>
);
};
function formatMessageShredStatusLabel(status: string): string {
switch (status) {
case 'in_progress':
return 'In progress';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
case 'not_found':
return 'Preparing';
default:
return 'Unknown';
}
}
function formatTimestampLocal(timestamp: string): string {
try {
return formatTimestamp(timestamp, 'en-US');
} catch {
return timestamp;
}
}
const StatusError: FC<{error: ApiError}> = ({error}) => {
return <ErrorCard title={getErrorTitle(error)} message={getErrorSubtitle(error)} />;
};
const MESSAGE_SHRED_FORM_SCRIPT = `
(function () {
var form = document.getElementById('message-shred-form');
if (!form) return;
var file = document.getElementById('message-shred-file');
var csvInput = document.getElementById('message-shred-csv-data');
var submitButton = document.getElementById('message-shred-submit');
var fileName = document.getElementById('message-shred-file-name');
if (!file || !csvInput || !submitButton) return;
file.addEventListener('change', function () {
if (fileName) {
fileName.textContent = file.files && file.files[0]
? file.files[0].name
: 'No file chosen';
}
});
var processing = false;
form.addEventListener('submit', function (event) {
if (processing) {
event.preventDefault();
return;
}
var selected = file.files && file.files[0];
if (!selected) {
event.preventDefault();
alert('Please select a CSV file to continue.');
return;
}
event.preventDefault();
processing = true;
submitButton.disabled = true;
submitButton.querySelector('span').textContent = 'Processing...';
var reader = new FileReader();
reader.onload = function () {
csvInput.value = reader.result || '';
form.submit();
};
reader.onerror = function () {
processing = false;
submitButton.disabled = false;
submitButton.querySelector('span').textContent = 'Shred Messages';
alert('Failed to read the CSV file. Please try again.');
};
reader.readAsText(selected);
});
})();
`;
const MessageShredFormScript: FC = () => {
return <script defer dangerouslySetInnerHTML={{__html: MESSAGE_SHRED_FORM_SCRIPT}} />;
};

View File

@@ -0,0 +1,496 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {type ApiError, getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading} from '@fluxer/admin/src/components/ui/Typography';
import {AclsForm, FlagsForm, SuspiciousFlagsForm, TraitsForm} from '@fluxer/admin/src/pages/user_detail/Forms';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {
ListUserChangeLogResponse,
UserAdminResponse,
UserContactChangeLogEntry,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {extractTimestampFromSnowflake, formatDiscriminator} from '@fluxer/ui/src/utils/FormatUser';
import type {FC, PropsWithChildren} from 'hono/jsx';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
interface OverviewTabProps {
config: Config;
user: UserAdminResponse;
adminAcls: Array<string>;
changeLogResult: {ok: true; data: ListUserChangeLogResponse} | {ok: false; error: ApiError} | null;
limitConfigResult: {ok: true; data: LimitConfigResponse} | {ok: false; error: ApiError} | null;
csrfToken: string;
}
function buildUserSearchHref(config: Config, value: string): string {
return `${config.basePath}/users?q=${encodeURIComponent(value)}`;
}
export function OverviewTab({
config,
user,
adminAcls,
changeLogResult,
limitConfigResult,
csrfToken,
}: OverviewTabProps) {
return (
<div class="space-y-6">
{user.temp_banned_until && (
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 font-medium text-red-900 text-sm">
Temporarily Banned Until: {user.temp_banned_until}
</div>
</div>
)}
{!user.temp_banned_until && user.pending_deletion_at && (
<div class="rounded-lg border border-orange-200 bg-orange-50 p-4">
<div class="font-medium text-orange-900 text-sm">Scheduled for Deletion: {user.pending_deletion_at}</div>
{user.deletion_reason_code !== null && user.deletion_public_reason !== null && (
<div class="mt-1 text-orange-700 text-sm">
Reason: {user.deletion_public_reason} (code: {user.deletion_reason_code})
</div>
)}
{user.deletion_reason_code !== null && user.deletion_public_reason === null && (
<div class="mt-1 text-orange-700 text-sm">Reason code: {user.deletion_reason_code}</div>
)}
{user.deletion_reason_code === null && user.deletion_public_reason !== null && (
<div class="mt-1 text-orange-700 text-sm">Reason: {user.deletion_public_reason}</div>
)}
</div>
)}
{user.pending_bulk_message_deletion_at && (
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<div class="font-medium text-neutral-700 text-sm">
Bulk message deletion scheduled for: {user.pending_bulk_message_deletion_at}
</div>
{hasPermission(adminAcls, AdminACLs.USER_CANCEL_BULK_MESSAGE_DELETION) && (
<form
method="post"
action="?action=cancel_bulk_message_deletion&tab=overview"
onsubmit="return confirm('Are you sure you want to cancel the scheduled bulk message deletion for this user?')"
>
<CsrfInput token={csrfToken} />
<button
type="submit"
class="mt-3 w-full rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
Cancel Bulk Message Deletion
</button>
</form>
)}
</div>
)}
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-3">
<div class="space-y-6 md:col-span-2">
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Account Information
</Heading>
<div class="grid grid-cols-1 gap-x-6 gap-y-2 text-sm md:grid-cols-2">
<CompactInfoMono label="User ID" value={user.id} />
<CompactInfo label="Created" value={extractTimestampFromSnowflake(user.id)} />
<CompactInfo label="Username" value={`${user.username}#${formatDiscriminator(user.discriminator)}`} />
<CompactInfoWithElement label="Email">
{user.email ? (
<span>
<span>{user.email}</span>{' '}
{user.email_verified ? <CheckmarkIcon class="text-green-600" /> : <XIcon class="text-red-600" />}
{user.email_bounced && <span class="ml-1 text-orange-600">(bounced)</span>}
<a
href={buildUserSearchHref(config, user.email)}
class="ml-2 text-blue-600 text-xs no-underline hover:underline"
>
Search
</a>
</span>
) : (
<span class="text-neutral-500">Not set</span>
)}
</CompactInfoWithElement>
<CompactInfoWithElement label="Phone">
{user.phone ? (
<span>
<span class="font-mono">{user.phone}</span>
<a
href={buildUserSearchHref(config, user.phone)}
class="ml-2 text-blue-600 text-xs no-underline hover:underline"
>
Search
</a>
</span>
) : (
<span class="text-neutral-500">Not set</span>
)}
</CompactInfoWithElement>
<CompactInfo label="Date of Birth" value={user.date_of_birth ?? 'Not set'} />
<CompactInfo label="Locale" value={user.locale ?? 'Not set'} />
{user.bio && (
<div class="md:col-span-2">
<CompactInfo label="Bio" value={user.bio} />
</div>
)}
{user.pronouns && <CompactInfo label="Pronouns" value={user.pronouns} />}
<CompactInfo label="Bot" value={user.bot ? 'Yes' : 'No'} />
<CompactInfo label="System" value={user.system ? 'Yes' : 'No'} />
<CompactInfo label="Last Active" value={user.last_active_at ?? 'Never'} />
<CompactInfoWithElement label="Last Active IP">
{user.last_active_ip ? (
<span>
<span class="font-mono">{user.last_active_ip}</span>
{user.last_active_ip_reverse && (
<span class="ml-2 text-neutral-500">({user.last_active_ip_reverse})</span>
)}
<a
href={buildUserSearchHref(config, user.last_active_ip)}
class="ml-2 text-blue-600 text-xs no-underline hover:underline"
>
Search
</a>
</span>
) : (
<span class="text-neutral-500">Not recorded</span>
)}
</CompactInfoWithElement>
<CompactInfo label="Location" value={user.last_active_location ?? 'Unknown Location'} />
</div>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Security &amp; Premium
</Heading>
<div class="grid grid-cols-1 gap-x-6 gap-y-2 text-sm md:grid-cols-2">
<CompactInfo
label="Authenticators"
value={
user.authenticator_types.length === 0
? 'None'
: user.authenticator_types
.map((t: number) => {
switch (t) {
case 0:
return 'TOTP';
case 1:
return 'SMS';
case 2:
return 'WebAuthn';
default:
return 'Unknown';
}
})
.join(', ')
}
/>
<CompactInfo
label="Premium Type"
value={
user.premium_type === null || user.premium_type === 0
? 'None'
: user.premium_type === 1
? 'Subscription'
: user.premium_type === 2
? 'Lifetime'
: 'Unknown'
}
/>
{user.premium_since && <CompactInfo label="Premium Since" value={user.premium_since} />}
{user.premium_until && <CompactInfo label="Premium Until" value={user.premium_until} />}
</div>
</VStack>
</Card>
<TraitsCard
config={config}
user={user}
adminAcls={adminAcls}
limitConfigResult={limitConfigResult}
csrfToken={csrfToken}
/>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
User Flags
</Heading>
<FlagsForm currentFlags={user.flags} csrfToken={csrfToken} selfHosted={config.selfHosted} />
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Suspicious Activity Flags
</Heading>
<SuspiciousFlagsForm currentFlags={user.suspicious_activity_flags} csrfToken={csrfToken} />
</VStack>
</Card>
<ChangeLogCard changeLogResult={changeLogResult} />
</div>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Admin ACLs
</Heading>
<AclsForm user={user} adminAcls={adminAcls} csrfToken={csrfToken} />
</VStack>
</Card>
</div>
</div>
);
}
const CompactInfo: FC<{label: string; value: string}> = ({label, value}) => (
<div>
<span class="text-neutral-500 text-sm">{label}:</span> <span class="text-neutral-900">{value}</span>
</div>
);
const CompactInfoMono: FC<{label: string; value: string}> = ({label, value}) => (
<div>
<span class="text-neutral-500 text-sm">{label}:</span> <span class="font-mono text-neutral-900">{value}</span>
</div>
);
const CompactInfoWithElement: FC<PropsWithChildren<{label: string}>> = ({label, children}) => (
<div>
<span class="text-neutral-500 text-sm">{label}:</span> {children}
</div>
);
const CheckmarkIcon: FC<{class?: string}> = ({class: className}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${className ?? ''}`}>
<polyline
points="40 144 96 200 224 72"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
const XIcon: FC<{class?: string}> = ({class: className}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${className ?? ''}`}>
<line
x1="200"
y1="56"
x2="56"
y2="200"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
<line
x1="200"
y1="200"
x2="56"
y2="56"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
interface TraitsCardProps {
config: Config;
user: UserAdminResponse;
adminAcls: Array<string>;
limitConfigResult: {ok: true; data: LimitConfigResponse} | {ok: false; error: ApiError} | null;
csrfToken: string;
}
const TraitsCard: FC<TraitsCardProps> = ({config, user, adminAcls, limitConfigResult, csrfToken}) => {
const traitDefinitions = parseTraitDefinitions(limitConfigResult);
const customTraits = user.traits.filter((trait: string) => !traitDefinitions.includes(trait));
const canEdit = hasPermission(adminAcls, AdminACLs.USER_UPDATE_TRAITS);
return (
<Card padding="md">
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3} size="base">
Traits
</Heading>
<p class="text-neutral-600 text-sm">
Assign traits to a user so the runtime limit rules and guild features can unlock alternate limits.
</p>
</VStack>
<AssignedTraits traits={user.traits} />
{limitConfigResult?.ok ? (
canEdit ? (
<TraitsForm
definitions={traitDefinitions}
currentTraits={user.traits}
customTraits={customTraits}
csrfToken={csrfToken}
/>
) : (
<div class="text-neutral-500 text-sm">You need the trait update ACL to make changes.</div>
)
) : (
<div class="text-red-700 text-sm">
Failed to load limit configuration: {formatError(limitConfigResult?.error)}
</div>
)}
{traitDefinitions.length === 0 && (
<p class="text-neutral-500 text-xs">
No trait definitions are declared yet. Add entries to the limit configuration so they can be assigned here.{' '}
<a href={`${config.basePath}/instance-config`} class="text-blue-600 underline">
Open Instance Configuration
</a>
</p>
)}
</VStack>
</Card>
);
};
const AssignedTraits: FC<{traits: Array<string>}> = ({traits}) => {
if (traits.length === 0) {
return <p class="text-neutral-500 text-sm">No traits assigned to this user.</p>;
}
return (
<div>
<div class="mb-2 text-neutral-500 text-xs uppercase tracking-wider">Assigned Traits</div>
<div class="flex flex-wrap gap-2">
{traits.map((trait) => (
<span class="rounded-full border border-neutral-200 bg-neutral-100 px-3 py-1 font-medium text-neutral-900 text-sm">
{trait}
</span>
))}
</div>
</div>
);
};
function parseTraitDefinitions(
limitConfigResult: {ok: true; data: LimitConfigResponse} | {ok: false; error: ApiError} | null,
): Array<string> {
if (!limitConfigResult?.ok) {
return [];
}
return limitConfigResult.data.limit_config.traitDefinitions
.map((value: string) => value.trim())
.filter((value: string) => value !== '');
}
interface ChangeLogCardProps {
changeLogResult: {ok: true; data: ListUserChangeLogResponse} | {ok: false; error: ApiError} | null;
}
const ChangeLogCard: FC<ChangeLogCardProps> = ({changeLogResult}) => {
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Contact Change Log
</Heading>
{changeLogResult?.ok ? (
<ChangeLogEntries entries={changeLogResult.data.entries} />
) : (
<div class="text-red-700 text-sm">Failed to load change log: {formatError(changeLogResult?.error)}</div>
)}
</VStack>
</Card>
);
};
const ChangeLogEntries: FC<{entries: Array<UserContactChangeLogEntry>}> = ({entries}) => {
if (entries.length === 0) {
return <div class="text-neutral-600 text-sm">No contact changes recorded.</div>;
}
return (
<ul class="divide-y divide-neutral-200">
{entries.map((entry) => (
<ChangeLogEntry entry={entry} />
))}
</ul>
);
};
const ChangeLogEntry: FC<{entry: UserContactChangeLogEntry}> = ({entry}) => {
return (
<li class="flex flex-col gap-1 py-3">
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-neutral-900">{labelForField(entry.field)}</span>
<span class="text-neutral-500">{entry.event_at}</span>
</div>
<div class="text-neutral-800 text-sm">{oldNewText(entry.old_value, entry.new_value)}</div>
<div class="text-neutral-600 text-xs">
Reason: {entry.reason ?? 'Unknown'}
{entry.actor_user_id && ` \u2022 Actor: ${entry.actor_user_id}`}
</div>
</li>
);
};
function labelForField(field: string): string {
switch (field) {
case 'email':
return 'Email';
case 'phone':
return 'Phone';
case 'fluxer_tag':
return 'FluxerTag';
default:
return field;
}
}
function oldNewText(old_value: string | null, new_value: string | null): string {
const oldDisplay = old_value ?? 'null';
const newDisplay = new_value ?? 'null';
return `${oldDisplay} \u2192 ${newDisplay}`;
}
function formatError(err: ApiError | undefined | null): string {
if (!err) {
return 'An error occurred';
}
return getErrorMessage(err);
}