refactor progress
This commit is contained in:
356
packages/admin/src/pages/AdminApiKeysPage.tsx
Normal file
356
packages/admin/src/pages/AdminApiKeysPage.tsx
Normal 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;
|
||||
}
|
||||
193
packages/admin/src/pages/ArchivesPage.tsx
Normal file
193
packages/admin/src/pages/ArchivesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
packages/admin/src/pages/AssetPurgePage.tsx
Normal file
212
packages/admin/src/pages/AssetPurgePage.tsx
Normal 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);
|
||||
}
|
||||
501
packages/admin/src/pages/AuditLogsPage.tsx
Normal file
501
packages/admin/src/pages/AuditLogsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
packages/admin/src/pages/BanManagementPage.tsx
Normal file
309
packages/admin/src/pages/BanManagementPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
426
packages/admin/src/pages/BulkActionsPage.tsx
Normal file
426
packages/admin/src/pages/BulkActionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
packages/admin/src/pages/DiscoveryPage.tsx
Normal file
309
packages/admin/src/pages/DiscoveryPage.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
281
packages/admin/src/pages/GatewayPage.tsx
Normal file
281
packages/admin/src/pages/GatewayPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
packages/admin/src/pages/GiftCodesPage.tsx
Normal file
152
packages/admin/src/pages/GiftCodesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
521
packages/admin/src/pages/GuildDetailPage.tsx
Normal file
521
packages/admin/src/pages/GuildDetailPage.tsx
Normal 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">←</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">←</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">←</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>
|
||||
);
|
||||
}
|
||||
251
packages/admin/src/pages/GuildsPage.tsx
Normal file
251
packages/admin/src/pages/GuildsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
514
packages/admin/src/pages/InstanceConfigPage.tsx
Normal file
514
packages/admin/src/pages/InstanceConfigPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
840
packages/admin/src/pages/LimitConfigPage.tsx
Normal file
840
packages/admin/src/pages/LimitConfigPage.tsx
Normal 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`;
|
||||
}
|
||||
64
packages/admin/src/pages/LoginPage.tsx
Normal file
64
packages/admin/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
packages/admin/src/pages/MessagesPage.tsx
Normal file
383
packages/admin/src/pages/MessagesPage.tsx
Normal 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};
|
||||
}
|
||||
497
packages/admin/src/pages/ReportDetailPage.tsx
Normal file
497
packages/admin/src/pages/ReportDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
838
packages/admin/src/pages/ReportsPage.tsx
Normal file
838
packages/admin/src/pages/ReportsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
packages/admin/src/pages/SearchIndexPage.tsx
Normal file
284
packages/admin/src/pages/SearchIndexPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
packages/admin/src/pages/StrangePlacePage.tsx
Normal file
84
packages/admin/src/pages/StrangePlacePage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
296
packages/admin/src/pages/SystemDmPage.tsx
Normal file
296
packages/admin/src/pages/SystemDmPage.tsx
Normal 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 !== '');
|
||||
}
|
||||
350
packages/admin/src/pages/UserDetailPage.tsx
Normal file
350
packages/admin/src/pages/UserDetailPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
309
packages/admin/src/pages/UsersPage.tsx
Normal file
309
packages/admin/src/pages/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
303
packages/admin/src/pages/VisionarySlotsPage.tsx
Normal file
303
packages/admin/src/pages/VisionarySlotsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
333
packages/admin/src/pages/VoiceRegionsPage.tsx
Normal file
333
packages/admin/src/pages/VoiceRegionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
356
packages/admin/src/pages/VoiceServersPage.tsx
Normal file
356
packages/admin/src/pages/VoiceServersPage.tsx
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
128
packages/admin/src/pages/guild_detail/Forms.tsx
Normal file
128
packages/admin/src/pages/guild_detail/Forms.tsx
Normal 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>
|
||||
);
|
||||
155
packages/admin/src/pages/guild_detail/tabs/EmojisTab.tsx
Normal file
155
packages/admin/src/pages/guild_detail/tabs/EmojisTab.tsx
Normal 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} />;
|
||||
}
|
||||
87
packages/admin/src/pages/guild_detail/tabs/FeaturesTab.tsx
Normal file
87
packages/admin/src/pages/guild_detail/tabs/FeaturesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
packages/admin/src/pages/guild_detail/tabs/MembersTab.tsx
Normal file
327
packages/admin/src/pages/guild_detail/tabs/MembersTab.tsx
Normal 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
|
||||
}
|
||||
>
|
||||
← 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 →
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
packages/admin/src/pages/guild_detail/tabs/ModerationTab.tsx
Normal file
208
packages/admin/src/pages/guild_detail/tabs/ModerationTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
packages/admin/src/pages/guild_detail/tabs/OverviewTab.tsx
Normal file
310
packages/admin/src/pages/guild_detail/tabs/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
packages/admin/src/pages/guild_detail/tabs/SettingsTab.tsx
Normal file
285
packages/admin/src/pages/guild_detail/tabs/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
packages/admin/src/pages/guild_detail/tabs/StickersTab.tsx
Normal file
160
packages/admin/src/pages/guild_detail/tabs/StickersTab.tsx
Normal 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} />;
|
||||
}
|
||||
202
packages/admin/src/pages/user_detail/Forms.tsx
Normal file
202
packages/admin/src/pages/user_detail/Forms.tsx
Normal 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 > 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} />;
|
||||
};
|
||||
350
packages/admin/src/pages/user_detail/tabs/AccountTab.tsx
Normal file
350
packages/admin/src/pages/user_detail/tabs/AccountTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
250
packages/admin/src/pages/user_detail/tabs/DmHistoryTab.tsx
Normal file
250
packages/admin/src/pages/user_detail/tabs/DmHistoryTab.tsx
Normal 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';
|
||||
}
|
||||
235
packages/admin/src/pages/user_detail/tabs/GuildsTab.tsx
Normal file
235
packages/admin/src/pages/user_detail/tabs/GuildsTab.tsx
Normal 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';
|
||||
}
|
||||
545
packages/admin/src/pages/user_detail/tabs/ModerationTab.tsx
Normal file
545
packages/admin/src/pages/user_detail/tabs/ModerationTab.tsx
Normal 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} · 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}} />;
|
||||
};
|
||||
496
packages/admin/src/pages/user_detail/tabs/OverviewTab.tsx
Normal file
496
packages/admin/src/pages/user_detail/tabs/OverviewTab.tsx
Normal 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 & 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);
|
||||
}
|
||||
Reference in New Issue
Block a user