refactor progress

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

View File

@@ -0,0 +1,32 @@
/*
* 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 {AdminACLs} from '@fluxer/constants/src/AdminACLs';
export function hasPermission(adminAcls: Array<string>, requiredAcl: string): boolean {
return adminAcls.includes(requiredAcl) || adminAcls.includes(AdminACLs.WILDCARD);
}
export function hasAnyPermission(adminAcls: Array<string>, requiredAcls: Array<string>): boolean {
if (requiredAcls.length === 0) return true;
return requiredAcls.some((acl) => hasPermission(adminAcls, acl));
}

View File

@@ -0,0 +1,225 @@
/*
* 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 {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DeletionReasons as CoreDeletionReasons} from '@fluxer/constants/src/Core';
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
export interface PatchableUserFlag {
name: string;
value: bigint;
}
export const FLAG_STAFF: PatchableUserFlag = {name: 'STAFF', value: UserFlags.STAFF};
export const FLAG_STAFF_HIDDEN: PatchableUserFlag = {name: 'STAFF_HIDDEN', value: UserFlags.STAFF_HIDDEN};
export const FLAG_CTP_MEMBER: PatchableUserFlag = {name: 'CTP_MEMBER', value: UserFlags.CTP_MEMBER};
export const FLAG_PARTNER: PatchableUserFlag = {name: 'PARTNER', value: UserFlags.PARTNER};
export const FLAG_BUG_HUNTER: PatchableUserFlag = {name: 'BUG_HUNTER', value: UserFlags.BUG_HUNTER};
export const FLAG_HIGH_GLOBAL_RATE_LIMIT: PatchableUserFlag = {
name: 'HIGH_GLOBAL_RATE_LIMIT',
value: UserFlags.HIGH_GLOBAL_RATE_LIMIT,
};
export const FLAG_PREMIUM_PURCHASE_DISABLED: PatchableUserFlag = {
name: 'PREMIUM_PURCHASE_DISABLED',
value: UserFlags.PREMIUM_PURCHASE_DISABLED,
};
export const FLAG_PREMIUM_ENABLED_OVERRIDE: PatchableUserFlag = {
name: 'PREMIUM_ENABLED_OVERRIDE',
value: UserFlags.PREMIUM_ENABLED_OVERRIDE,
};
export const FLAG_RATE_LIMIT_BYPASS: PatchableUserFlag = {
name: 'RATE_LIMIT_BYPASS',
value: UserFlags.RATE_LIMIT_BYPASS,
};
export const FLAG_REPORT_BANNED: PatchableUserFlag = {name: 'REPORT_BANNED', value: UserFlags.REPORT_BANNED};
export const FLAG_VERIFIED_NOT_UNDERAGE: PatchableUserFlag = {
name: 'VERIFIED_NOT_UNDERAGE',
value: UserFlags.VERIFIED_NOT_UNDERAGE,
};
export const FLAG_USED_MOBILE_CLIENT: PatchableUserFlag = {
name: 'USED_MOBILE_CLIENT',
value: UserFlags.USED_MOBILE_CLIENT,
};
export const FLAG_APP_STORE_REVIEWER: PatchableUserFlag = {
name: 'APP_STORE_REVIEWER',
value: UserFlags.APP_STORE_REVIEWER,
};
export const FLAG_DM_HISTORY_BACKFILLED: PatchableUserFlag = {
name: 'DM_HISTORY_BACKFILLED',
value: UserFlags.DM_HISTORY_BACKFILLED,
};
export const SELF_HOSTED_PATCHABLE_FLAGS: Array<PatchableUserFlag> = [
FLAG_STAFF,
FLAG_STAFF_HIDDEN,
FLAG_HIGH_GLOBAL_RATE_LIMIT,
FLAG_RATE_LIMIT_BYPASS,
FLAG_REPORT_BANNED,
FLAG_VERIFIED_NOT_UNDERAGE,
];
export const PATCHABLE_FLAGS: Array<PatchableUserFlag> = [
FLAG_STAFF,
FLAG_STAFF_HIDDEN,
FLAG_CTP_MEMBER,
FLAG_PARTNER,
FLAG_BUG_HUNTER,
FLAG_HIGH_GLOBAL_RATE_LIMIT,
FLAG_PREMIUM_PURCHASE_DISABLED,
FLAG_PREMIUM_ENABLED_OVERRIDE,
FLAG_RATE_LIMIT_BYPASS,
FLAG_REPORT_BANNED,
FLAG_VERIFIED_NOT_UNDERAGE,
FLAG_USED_MOBILE_CLIENT,
FLAG_APP_STORE_REVIEWER,
FLAG_DM_HISTORY_BACKFILLED,
];
export interface Flag {
name: string;
value: number;
}
export const SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL: Flag = {
name: 'REQUIRE_VERIFIED_EMAIL',
value: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
};
export const SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL: Flag = {
name: 'REQUIRE_REVERIFIED_EMAIL',
value: SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
};
export const SUSPICIOUS_FLAG_REQUIRE_VERIFIED_PHONE: Flag = {
name: 'REQUIRE_VERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
};
export const SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_PHONE: Flag = {
name: 'REQUIRE_REVERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE,
};
export const SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE: Flag = {
name: 'REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE,
};
export const SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE: Flag = {
name: 'REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
};
export const SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE: Flag = {
name: 'REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
};
export const SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE: Flag = {
name: 'REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE',
value: SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
};
export const SUSPICIOUS_ACTIVITY_FLAGS: Array<Flag> = [
SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL,
SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL,
SUSPICIOUS_FLAG_REQUIRE_VERIFIED_PHONE,
SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_PHONE,
SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE,
SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
SUSPICIOUS_FLAG_REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
SUSPICIOUS_FLAG_REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
];
export const DELETION_REASONS: Array<{id: number; label: string}> = [
{id: CoreDeletionReasons.USER_REQUESTED, label: 'User requested'},
{id: CoreDeletionReasons.OTHER, label: 'Other'},
{id: CoreDeletionReasons.SPAM, label: 'Spam'},
{id: CoreDeletionReasons.CHEATING_OR_EXPLOITATION, label: 'Cheating or exploitation'},
{id: CoreDeletionReasons.COORDINATED_RAIDING, label: 'Coordinated raiding or manipulation'},
{id: CoreDeletionReasons.AUTOMATION_OR_SELFBOT, label: 'Automation or self-bot usage'},
{id: CoreDeletionReasons.NONCONSENSUAL_SEXUAL_CONTENT, label: 'Nonconsensual sexual content'},
{id: CoreDeletionReasons.SCAM_OR_SOCIAL_ENGINEERING, label: 'Scam or social engineering'},
{id: CoreDeletionReasons.CHILD_SEXUAL_CONTENT, label: 'Child sexual content'},
{id: CoreDeletionReasons.PRIVACY_VIOLATION_OR_DOXXING, label: 'Privacy violation or doxxing'},
{id: CoreDeletionReasons.HARASSMENT_OR_BULLYING, label: 'Harassment or bullying'},
{id: CoreDeletionReasons.PAYMENT_FRAUD, label: 'Payment fraud'},
{id: CoreDeletionReasons.CHILD_SAFETY_VIOLATION, label: 'Child safety violation'},
{id: CoreDeletionReasons.BILLING_DISPUTE_OR_ABUSE, label: 'Billing dispute or abuse'},
{id: CoreDeletionReasons.UNSOLICITED_EXPLICIT_CONTENT, label: 'Unsolicited explicit content'},
{id: CoreDeletionReasons.GRAPHIC_VIOLENCE, label: 'Graphic violence'},
{id: CoreDeletionReasons.BAN_EVASION, label: 'Ban evasion'},
{id: CoreDeletionReasons.TOKEN_OR_CREDENTIAL_SCAM, label: 'Token or credential scam'},
{id: CoreDeletionReasons.INACTIVITY, label: 'Inactivity'},
{id: CoreDeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT, label: 'Hate speech or extremist content'},
{id: CoreDeletionReasons.MALICIOUS_LINKS_OR_MALWARE, label: 'Malicious links or malware distribution'},
{id: CoreDeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY, label: 'Impersonation or fake identity'},
];
export const TEMP_BAN_DURATIONS: Array<{hours: number; label: string}> = [
{hours: 1, label: '1 hour'},
{hours: 12, label: '12 hours'},
{hours: 24, label: '1 day'},
{hours: 72, label: '3 days'},
{hours: 120, label: '5 days'},
{hours: 168, label: '1 week'},
{hours: 336, label: '2 weeks'},
{hours: 720, label: '30 days'},
{hours: 0, label: 'Permanent'},
];
export const ALL_ACLS = Object.values(AdminACLs);
export const GUILD_FEATURES = Object.values(GuildFeatures) as ReadonlyArray<string>;
const HOSTED_ONLY_GUILD_FEATURES: ReadonlyArray<string> = [
GuildFeatures.VISIONARY,
GuildFeatures.VIP_VOICE,
GuildFeatures.OPERATOR,
GuildFeatures.MANAGED_MESSAGE_SCHEDULING,
GuildFeatures.MANAGED_EXPRESSION_PACKS,
];
export const SELF_HOSTED_GUILD_FEATURES: ReadonlyArray<string> = GUILD_FEATURES.filter(
(f) => !HOSTED_ONLY_GUILD_FEATURES.includes(f),
);
export const DISABLED_OP_PUSH_NOTIFICATIONS: Flag = {
name: 'PUSH_NOTIFICATIONS',
value: GuildOperations.PUSH_NOTIFICATIONS,
};
export const DISABLED_OP_EVERYONE_MENTIONS: Flag = {
name: 'EVERYONE_MENTIONS',
value: GuildOperations.EVERYONE_MENTIONS,
};
export const DISABLED_OP_TYPING_EVENTS: Flag = {name: 'TYPING_EVENTS', value: GuildOperations.TYPING_EVENTS};
export const DISABLED_OP_INSTANT_INVITES: Flag = {name: 'INSTANT_INVITES', value: GuildOperations.INSTANT_INVITES};
export const DISABLED_OP_SEND_MESSAGE: Flag = {name: 'SEND_MESSAGE', value: GuildOperations.SEND_MESSAGE};
export const DISABLED_OP_REACTIONS: Flag = {name: 'REACTIONS', value: GuildOperations.REACTIONS};
export const DISABLED_OP_MEMBER_LIST_UPDATES: Flag = {
name: 'MEMBER_LIST_UPDATES',
value: GuildOperations.MEMBER_LIST_UPDATES,
};
export const DISABLED_OPERATIONS: Array<Flag> = [
DISABLED_OP_PUSH_NOTIFICATIONS,
DISABLED_OP_EVERYONE_MENTIONS,
DISABLED_OP_TYPING_EVENTS,
DISABLED_OP_INSTANT_INVITES,
DISABLED_OP_SEND_MESSAGE,
DISABLED_OP_REACTIONS,
DISABLED_OP_MEMBER_LIST_UPDATES,
];

251
packages/admin/src/App.tsx Normal file
View File

@@ -0,0 +1,251 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {createRequireAuth} from '@fluxer/admin/src/middleware/Auth';
import {csrfMiddleware, initializeCsrf} from '@fluxer/admin/src/middleware/Csrf';
import {createAdminErrorHandler} from '@fluxer/admin/src/middleware/ErrorHandler';
import {resolveAdminPublicDir} from '@fluxer/admin/src/PublicDir';
import {createAdminRoutes} from '@fluxer/admin/src/routes/Admin';
import {createAuthRoutes} from '@fluxer/admin/src/routes/Auth';
import {createBansRoutes} from '@fluxer/admin/src/routes/Bans';
import {createCodesRoutes} from '@fluxer/admin/src/routes/Codes';
import {createDiscoveryRoutes} from '@fluxer/admin/src/routes/Discovery';
import {createGuildsRoutes} from '@fluxer/admin/src/routes/Guilds';
import {createMessagesRoutes} from '@fluxer/admin/src/routes/Messages';
import {createReportsRoutes} from '@fluxer/admin/src/routes/Reports';
import type {RouteFactory} from '@fluxer/admin/src/routes/RouteTypes';
import {createSystemRoutes} from '@fluxer/admin/src/routes/System';
import {createUsersRoutes} from '@fluxer/admin/src/routes/Users';
import {createVisionarySlotsRoutes} from '@fluxer/admin/src/routes/VisionarySlots';
import {createVoiceRoutes} from '@fluxer/admin/src/routes/Voice';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {KVCacheProvider} from '@fluxer/cache/src/providers/KVCacheProvider';
import {cacheHeaders} from '@fluxer/hono/src/middleware/CacheHeaders';
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
import {normalizeEndpointOrigin, validateOutboundEndpointUrl} from '@fluxer/hono/src/security/OutboundEndpoint';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {KVClient} from '@fluxer/kv_client/src/KVClient';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {throwKVRequiredError} from '@fluxer/rate_limit/src/KVRequiredError';
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
import {serveStatic} from '@hono/node-server/serve-static';
import {Hono} from 'hono';
export interface CreateAdminAppOptions {
config: AdminConfig;
logger: LoggerInterface;
assetVersion?: string;
metricsCollector?: MetricsCollector;
tracing?: TracingOptions;
cacheService?: ICacheService;
kvProvider?: IKVProvider | null;
}
export interface AdminAppResult {
app: Hono<{Variables: AppVariables}>;
shutdown: () => void;
}
export function createAdminApp(options: CreateAdminAppOptions): AdminAppResult {
const {logger, assetVersion = Date.now().toString(), metricsCollector, tracing} = options;
const config = normalizeAdminSecurityConfig(options.config);
const app = new Hono<{Variables: AppVariables}>();
const publicDir = resolveAdminPublicDir();
let rateLimitService: RateLimitService | null = null;
initializeCsrf(config.secretKeyBase, config.env === 'production');
app.use(
'/static/*',
serveStatic({
root: publicDir,
rewriteRequestPath: (path: string) => toRelativeStaticPath(stripLeadingBasePath(path, config.basePath)),
onNotFound: (_path) => {
logger.error(
{
publicDir,
cwd: process.cwd(),
},
'Admin static asset not found (expected packages/admin/public/static/app.css to exist)',
);
},
}),
);
const kvProvider = options.kvProvider ?? createHttpKVProvider(config, logger);
if (!kvProvider) {
throwKVRequiredError({
serviceName: 'Admin panel',
configPath: 'config.kvUrl',
fluxerServerHint: 'kvProvider is passed in as an option',
});
}
const cacheService = options.cacheService ?? new KVCacheProvider({client: kvProvider});
rateLimitService = new RateLimitService(cacheService);
applyMiddlewareStack(app, {
requestId: {},
tracing,
metrics: metricsCollector
? {
enabled: true,
collector: metricsCollector,
skipPaths: ['/_health', '/robots.txt'],
}
: undefined,
logger: {
log: (data) => {
logger.debug(
{
method: data.method,
path: data.path,
status: data.status,
durationMs: data.durationMs,
},
'Request completed',
);
},
skip: ['/_health', '/robots.txt'],
},
rateLimit: rateLimitService
? {
enabled: true,
service: rateLimitService,
maxAttempts: 100,
windowMs: 60000,
skipPaths: ['/_health', '/robots.txt'],
}
: undefined,
customMiddleware: [cacheHeaders(), csrfMiddleware],
skipErrorHandler: true,
});
app.use('*', async (c, next) => {
if (c.req.query('sh') === '1') {
c.set('selfHostedOverride', true);
}
await next();
if (c.get('selfHostedOverride')) {
const location = c.res.headers.get('Location');
if (location?.startsWith('/') && !location.includes('sh=1')) {
const separator = location.includes('?') ? '&' : '?';
const newHeaders = new Headers(c.res.headers);
newHeaders.set('Location', `${location}${separator}sh=1`);
c.res = new Response(c.res.body, {
status: c.res.status,
headers: newHeaders,
});
}
}
});
app.onError(createAdminErrorHandler(logger, config.env === 'development'));
app.get('/_health', (c) => c.json({status: 'ok'}));
app.get('/robots.txt', (c) => {
return c.text('User-agent: *\nDisallow: /\n');
});
const requireAuth = createRequireAuth(config, assetVersion);
const deps = {config, assetVersion, requireAuth};
const routeFactories: Array<RouteFactory> = [
createAuthRoutes,
createSystemRoutes,
createUsersRoutes,
createGuildsRoutes,
createBansRoutes,
createReportsRoutes,
createMessagesRoutes,
createVoiceRoutes,
createCodesRoutes,
createDiscoveryRoutes,
createVisionarySlotsRoutes,
createAdminRoutes,
];
for (const factory of routeFactories) {
app.route('/', factory(deps));
}
const shutdown = (): void => {
logger.info('Admin app shutting down');
};
return {app, shutdown};
}
function createHttpKVProvider(config: AdminConfig, _logger: LoggerInterface): IKVProvider | null {
if (!config.kvUrl) {
return null;
}
return new KVClient({url: config.kvUrl});
}
function normalizeAdminSecurityConfig(config: AdminConfig): AdminConfig {
const isProduction = config.env === 'production';
const apiEndpoint = validateOutboundEndpointUrl(config.apiEndpoint, {
name: 'admin.apiEndpoint',
allowHttp: !isProduction,
allowLocalhost: !isProduction,
allowPrivateIpLiterals: !isProduction,
});
return {
...config,
apiEndpoint: normalizeEndpointOrigin(apiEndpoint),
kvUrl: normalizeKVUrl(config.kvUrl),
};
}
function normalizeKVUrl(rawKvUrl: string): string {
const kvUrl = new URL(rawKvUrl);
if (kvUrl.protocol !== 'redis:' && kvUrl.protocol !== 'rediss:') {
throw new Error('admin.kvUrl must use redis or rediss');
}
if (kvUrl.search || kvUrl.hash) {
throw new Error('admin.kvUrl must not include query string or fragment');
}
return rawKvUrl.trim();
}
function stripLeadingBasePath(path: string, basePath: string): string {
if (!basePath) return path;
if (path === basePath) return '';
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
return path;
}
function toRelativeStaticPath(path: string): string {
if (!path) return path;
return path.startsWith('/') ? path.slice(1) : path;
}

28
packages/admin/src/HonoJsx.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
/*
* 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/>.
*/
import type {JSX as HonoJSX} from 'hono/jsx';
declare global {
namespace JSX {
type Element = HonoJSX.Element;
interface IntrinsicAttributes extends HonoJSX.IntrinsicAttributes {}
interface IntrinsicElements extends HonoJSX.IntrinsicElements {}
}
}

View File

@@ -0,0 +1,246 @@
/*
* 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 {hasAnyPermission} from '@fluxer/admin/src/AccessControlList';
import type {NavigationContext, NavSection} from '@fluxer/admin/src/navigation/NavigationTypes';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
export function getSections(): Array<NavSection> {
return [
{
title: 'Lookup',
items: [
{title: 'Users', path: '/users', activeKey: 'users', requiredAcls: [AdminACLs.USER_LOOKUP]},
{title: 'Guilds', path: '/guilds', activeKey: 'guilds', requiredAcls: [AdminACLs.GUILD_LOOKUP]},
],
},
{
title: 'Moderation',
items: [
{title: 'Reports', path: '/reports', activeKey: 'reports', requiredAcls: [AdminACLs.REPORT_VIEW]},
{
title: 'Bulk Actions',
path: '/bulk-actions',
activeKey: 'bulk-actions',
requiredAcls: [
AdminACLs.BULK_UPDATE_USER_FLAGS,
AdminACLs.BULK_UPDATE_GUILD_FEATURES,
AdminACLs.BULK_ADD_GUILD_MEMBERS,
AdminACLs.BULK_DELETE_USERS,
],
},
],
},
{
title: 'Bans',
items: [
{
title: 'IP Bans',
path: '/ip-bans',
activeKey: 'ip-bans',
requiredAcls: [AdminACLs.BAN_IP_CHECK, AdminACLs.BAN_IP_ADD, AdminACLs.BAN_IP_REMOVE],
},
{
title: 'Email Bans',
path: '/email-bans',
activeKey: 'email-bans',
requiredAcls: [AdminACLs.BAN_EMAIL_CHECK, AdminACLs.BAN_EMAIL_ADD, AdminACLs.BAN_EMAIL_REMOVE],
},
{
title: 'Phone Bans',
path: '/phone-bans',
activeKey: 'phone-bans',
requiredAcls: [AdminACLs.BAN_PHONE_CHECK, AdminACLs.BAN_PHONE_ADD, AdminACLs.BAN_PHONE_REMOVE],
},
],
},
{
title: 'Content',
items: [
{
title: 'Message Tools',
path: '/messages',
activeKey: 'message-tools',
requiredAcls: [
AdminACLs.MESSAGE_LOOKUP,
AdminACLs.MESSAGE_DELETE,
AdminACLs.MESSAGE_SHRED,
AdminACLs.MESSAGE_DELETE_ALL,
],
},
{
title: 'System DMs',
path: '/system-dms',
activeKey: 'system-dms',
requiredAcls: [AdminACLs.SYSTEM_DM_SEND],
},
{
title: 'Archives',
path: '/archives',
activeKey: 'archives',
requiredAcls: [AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD],
},
{
title: 'Asset Purge',
path: '/asset-purge',
activeKey: 'asset-purge',
requiredAcls: [AdminACLs.ASSET_PURGE],
},
],
},
{
title: 'Observability',
items: [
{
title: 'Gateway',
path: '/gateway',
activeKey: 'gateway',
requiredAcls: [AdminACLs.GATEWAY_MEMORY_STATS, AdminACLs.GATEWAY_RELOAD_ALL],
},
{
title: 'Audit Logs',
path: '/audit-logs',
activeKey: 'audit-logs',
requiredAcls: [AdminACLs.AUDIT_LOG_VIEW],
},
],
},
{
title: 'Platform',
items: [
{
title: 'Search Index',
path: '/search-index',
activeKey: 'search-index',
requiredAcls: [AdminACLs.GUILD_LOOKUP],
},
{
title: 'Voice Regions',
path: '/voice-regions',
activeKey: 'voice-regions',
requiredAcls: [AdminACLs.VOICE_REGION_LIST],
},
{
title: 'Voice Servers',
path: '/voice-servers',
activeKey: 'voice-servers',
requiredAcls: [AdminACLs.VOICE_SERVER_LIST],
},
],
},
{
title: 'Configuration',
items: [
{
title: 'Instance Config',
path: '/instance-config',
activeKey: 'instance-config',
requiredAcls: [AdminACLs.INSTANCE_CONFIG_VIEW, AdminACLs.INSTANCE_CONFIG_UPDATE],
},
{
title: 'Limit Config',
path: '/limit-config',
activeKey: 'limit-config',
requiredAcls: [AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW, AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE],
},
{
title: 'Admin API Keys',
path: '/admin-api-keys',
activeKey: 'admin-api-keys',
requiredAcls: [AdminACLs.ADMIN_API_KEY_MANAGE],
},
],
},
{
title: 'Discovery',
items: [
{
title: 'Applications',
path: '/discovery?status=pending',
activeKey: 'discovery',
requiredAcls: [AdminACLs.DISCOVERY_REVIEW],
},
],
},
{
title: 'Codes',
items: [
{
title: 'Gift Codes',
path: '/gift-codes',
activeKey: 'gift-codes',
requiredAcls: [AdminACLs.GIFT_CODES_GENERATE],
hostedOnly: true,
},
],
},
{
title: 'Premium',
items: [
{
title: 'Visionary Slots',
path: '/visionary-slots',
activeKey: 'visionary-slots',
requiredAcls: [AdminACLs.VISIONARY_SLOT_VIEW],
hostedOnly: true,
},
],
},
];
}
export function getAccessibleSections(adminAcls: Array<string>, context?: NavigationContext): Array<NavSection> {
const selfHosted = context?.selfHosted ?? false;
const inspectedVoiceRegionId = context?.inspectedVoiceRegionId;
const hasContext = Boolean(context);
const hasInspectedVoiceRegion = Boolean(inspectedVoiceRegionId);
return getSections()
.map((section) => ({
...section,
items: section.items
.filter((item) => !item.hostedOnly || !selfHosted)
.filter((item) => hasAnyPermission(adminAcls, item.requiredAcls))
.filter((item) => !hasContext || item.activeKey !== 'voice-servers' || hasInspectedVoiceRegion)
.map((item) => {
if (!hasContext || item.activeKey !== 'voice-servers' || !inspectedVoiceRegionId) {
return item;
}
const encodedRegionId = encodeURIComponent(inspectedVoiceRegionId);
return {
...item,
path: `/voice-servers?region_id=${encodedRegionId}`,
};
}),
}))
.filter((section) => section.items.length > 0);
}
export function getFirstAccessiblePath(adminAcls: Array<string>, context?: NavigationContext): string | null {
const sections = getAccessibleSections(adminAcls, context);
const firstSection = sections[0];
if (!firstSection || firstSection.items.length === 0) return null;
const firstItem = firstSection.items[0];
if (!firstItem) return null;
return firstItem.path;
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {IOAuth2Client} from '@fluxer/oauth2/src/client/IOAuth2Client';
import {createOAuth2Client} from '@fluxer/oauth2/src/client/OAuth2Client';
import type {OAuth2ClientConfig} from '@fluxer/oauth2/src/config/OAuth2ClientConfig';
const ADMIN_OAUTH_SCOPE = 'identify email admin';
export function createAdminOAuth2Client(config: Config): IOAuth2Client {
const oauth2Config: OAuth2ClientConfig = {
clientId: config.oauthClientId,
clientSecret: config.oauthClientSecret,
redirectUri: config.oauthRedirectUri,
scope: ADMIN_OAUTH_SCOPE,
endpoints: {
authorizeEndpoint: `${config.webAppEndpoint}/oauth2/authorize`,
tokenEndpoint: `${config.apiEndpoint}/oauth2/token`,
userInfoEndpoint: `${config.apiEndpoint}/users/@me`,
revokeEndpoint: `${config.apiEndpoint}/oauth2/revoke`,
},
};
return createOAuth2Client(oauth2Config);
}

View File

@@ -0,0 +1,24 @@
/*
* 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/>.
*/
import {fileURLToPath} from 'node:url';
export function resolveAdminPublicDir(): string {
return fileURLToPath(new URL('../public', import.meta.url));
}

View File

@@ -0,0 +1,32 @@
/*
* 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/>.
*/
import type {AppContext} from '@fluxer/admin/src/types/App';
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
export function getPageConfig(c: AppContext, config: AdminConfig): AdminConfig {
if (c.get('selfHostedOverride')) {
return {...config, selfHosted: true};
}
return config;
}
export function isSelfHostedOverride(c: AppContext, config: AdminConfig): boolean {
return c.get('selfHostedOverride') === true || config.selfHosted;
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {Session} from '@fluxer/admin/src/types/App';
import {createSession as createSessionCore, parseSession as parseSessionCore} from '@fluxer/hono/src/Session';
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
interface SessionData {
userId: string;
accessToken: string;
}
export function createSession(userId: string, accessToken: string, secretKey: string): string {
return createSessionCore<SessionData>({userId, accessToken}, secretKey);
}
export function parseSession(cookieValue: string, secretKey: string): Session | null {
const session = parseSessionCore<SessionData>(cookieValue, secretKey, MAX_AGE_SECONDS);
if (!session) {
return null;
}
return {
userId: session.userId,
accessToken: session.accessToken,
createdAt: session.createdAt,
};
}

View File

@@ -0,0 +1,49 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {CreateAdminApiKeyResponse, ListAdminApiKeyResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function createApiKey(
config: Config,
session: Session,
name: string,
acls: Array<string>,
): Promise<ApiResult<CreateAdminApiKeyResponse>> {
const client = new ApiClient(config, session);
return client.post<CreateAdminApiKeyResponse>('/admin/api-keys', {name, acls});
}
export async function listApiKeys(
config: Config,
session: Session,
): Promise<ApiResult<Array<ListAdminApiKeyResponse>>> {
const client = new ApiClient(config, session);
return client.get<Array<ListAdminApiKeyResponse>>('/admin/api-keys');
}
export async function revokeApiKey(config: Config, session: Session, key_id: string): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.deleteVoid(`/admin/api-keys/${key_id}`);
}

View File

@@ -0,0 +1,86 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
AdminArchiveResponseSchema,
DownloadUrlResponseSchema,
ListArchivesResponseSchema,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
export type Archive = z.infer<typeof AdminArchiveResponseSchema>;
export type ListArchivesResponse = z.infer<typeof ListArchivesResponseSchema>;
export type ArchiveDownloadUrlResponse = z.infer<typeof DownloadUrlResponseSchema>;
export async function triggerUserArchive(
config: Config,
session: Session,
userId: string,
reason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/archives/user', {user_id: userId}, reason);
}
export async function triggerGuildArchive(
config: Config,
session: Session,
guildId: string,
reason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/archives/guild', {guild_id: guildId}, reason);
}
export async function listArchives(
config: Config,
session: Session,
subjectType: string,
subjectId?: string,
includeExpired: boolean = false,
requestedBy?: string,
): Promise<ApiResult<ListArchivesResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
subject_type: subjectType,
include_expired: includeExpired,
...(subjectId ? {subject_id: subjectId} : {}),
...(requestedBy ? {requested_by: requestedBy} : {}),
};
return client.post<ListArchivesResponse>('/admin/archives/list', body);
}
export async function getArchiveDownloadUrl(
config: Config,
session: Session,
subjectType: string,
subjectId: string,
archiveId: string,
): Promise<ApiResult<ArchiveDownloadUrlResponse>> {
const client = new ApiClient(config, session);
return client.get<ArchiveDownloadUrlResponse>(`/admin/archives/${subjectType}/${subjectId}/${archiveId}/download`);
}

View File

@@ -0,0 +1,36 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {PurgeGuildAssetsResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function purgeAssets(
config: Config,
session: Session,
ids: Array<string>,
reason?: string,
): Promise<ApiResult<PurgeGuildAssetsResponse>> {
const client = new ApiClient(config, session);
return client.post<PurgeGuildAssetsResponse>('/admin/assets/purge', {ids}, reason);
}

View File

@@ -0,0 +1,55 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {AuditLogsListResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
type AuditLogsListResponse = z.infer<typeof AuditLogsListResponseSchema>;
export interface SearchAuditLogsParams {
query: string | undefined;
admin_user_id: string | undefined;
target_id: string | undefined;
limit: number;
offset: number;
}
export async function searchAuditLogs(
config: Config,
session: Session,
params: SearchAuditLogsParams,
): Promise<ApiResult<AuditLogsListResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
limit: params.limit,
offset: params.offset,
...(params.query && params.query !== '' ? {query: params.query} : {}),
...(params.admin_user_id && params.admin_user_id !== '' ? {admin_user_id: params.admin_user_id} : {}),
...(params.target_id && params.target_id !== '' ? {target_id: params.target_id} : {}),
};
return client.post<AuditLogsListResponse>('/admin/audit-logs/search', body);
}

View File

@@ -0,0 +1,147 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
BanCheckResponseSchema,
ListEmailBansResponseSchema,
ListIpBansResponseSchema,
ListPhoneBansResponseSchema,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
export type BanCheckResponse = z.infer<typeof BanCheckResponseSchema>;
export type ListIpBansResponse = z.infer<typeof ListIpBansResponseSchema>;
export type ListEmailBansResponse = z.infer<typeof ListEmailBansResponseSchema>;
export type ListPhoneBansResponse = z.infer<typeof ListPhoneBansResponseSchema>;
export async function banEmail(
config: Config,
session: Session,
email: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/email/add', {email}, auditLogReason);
}
export async function unbanEmail(
config: Config,
session: Session,
email: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/email/remove', {email}, auditLogReason);
}
export async function checkEmailBan(
config: Config,
session: Session,
email: string,
): Promise<ApiResult<BanCheckResponse>> {
const client = new ApiClient(config, session);
return client.post<BanCheckResponse>('/admin/bans/email/check', {email});
}
export async function banIp(
config: Config,
session: Session,
ip: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/ip/add', {ip}, auditLogReason);
}
export async function unbanIp(
config: Config,
session: Session,
ip: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/ip/remove', {ip}, auditLogReason);
}
export async function checkIpBan(config: Config, session: Session, ip: string): Promise<ApiResult<BanCheckResponse>> {
const client = new ApiClient(config, session);
return client.post<BanCheckResponse>('/admin/bans/ip/check', {ip});
}
export async function banPhone(
config: Config,
session: Session,
phone: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/phone/add', {phone}, auditLogReason);
}
export async function unbanPhone(
config: Config,
session: Session,
phone: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/bans/phone/remove', {phone}, auditLogReason);
}
export async function checkPhoneBan(
config: Config,
session: Session,
phone: string,
): Promise<ApiResult<BanCheckResponse>> {
const client = new ApiClient(config, session);
return client.post<BanCheckResponse>('/admin/bans/phone/check', {phone});
}
export async function listIpBans(
config: Config,
session: Session,
limit: number = 200,
): Promise<ApiResult<ListIpBansResponse>> {
const client = new ApiClient(config, session);
return client.post<ListIpBansResponse>('/admin/bans/ip/list', {limit});
}
export async function listEmailBans(
config: Config,
session: Session,
limit: number = 200,
): Promise<ApiResult<ListEmailBansResponse>> {
const client = new ApiClient(config, session);
return client.post<ListEmailBansResponse>('/admin/bans/email/list', {limit});
}
export async function listPhoneBans(
config: Config,
session: Session,
limit: number = 200,
): Promise<ApiResult<ListPhoneBansResponse>> {
const client = new ApiClient(config, session);
return client.post<ListPhoneBansResponse>('/admin/bans/phone/list', {limit});
}

View File

@@ -0,0 +1,107 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {BulkOperationResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
type BulkOperationResponseType = z.infer<typeof BulkOperationResponse>;
export async function bulkUpdateUserFlags(
config: Config,
session: Session,
user_ids: Array<string>,
add_flags: Array<string>,
remove_flags: Array<string>,
audit_log_reason?: string,
): Promise<ApiResult<BulkOperationResponseType>> {
const client = new ApiClient(config, session);
return client.post<BulkOperationResponseType>(
'/admin/bulk/update-user-flags',
{
user_ids,
add_flags,
remove_flags,
},
audit_log_reason,
);
}
export async function bulkUpdateGuildFeatures(
config: Config,
session: Session,
guild_ids: Array<string>,
add_features: Array<string>,
remove_features: Array<string>,
audit_log_reason?: string,
): Promise<ApiResult<BulkOperationResponseType>> {
const client = new ApiClient(config, session);
return client.post<BulkOperationResponseType>(
'/admin/bulk/update-guild-features',
{
guild_ids,
add_features,
remove_features,
},
audit_log_reason,
);
}
export async function bulkAddGuildMembers(
config: Config,
session: Session,
guild_id: string,
user_ids: Array<string>,
audit_log_reason?: string,
): Promise<ApiResult<BulkOperationResponseType>> {
const client = new ApiClient(config, session);
return client.post<BulkOperationResponseType>(
'/admin/bulk/add-guild-members',
{
guild_id,
user_ids,
},
audit_log_reason,
);
}
export async function bulkScheduleUserDeletion(
config: Config,
session: Session,
user_ids: Array<string>,
reason_code: number,
days_until_deletion: number,
public_reason?: string,
audit_log_reason?: string,
): Promise<ApiResult<BulkOperationResponseType>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_ids,
reason_code,
days_until_deletion,
...(public_reason ? {public_reason} : {}),
};
return client.post<BulkOperationResponseType>('/admin/bulk/schedule-user-deletion', body, audit_log_reason);
}

View File

@@ -0,0 +1,216 @@
/*
* 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, parseApiResponse} from '@fluxer/admin/src/api/Errors';
import type {JsonValue} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
import {buildEndpointUrl, validateOutboundEndpointUrl} from '@fluxer/hono/src/security/OutboundEndpoint';
export type ApiResult<T> = {ok: true; data: T} | {ok: false; error: ApiError};
export interface RequestOptions {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
path: string;
body?: JsonValue | string;
queryParams?: Record<string, string | number | boolean | undefined | null>;
auditLogReason?: string;
}
export class ApiClient {
private session: Session;
private apiEndpointUrl: URL;
constructor(config: AdminConfig, session: Session) {
this.session = session;
this.apiEndpointUrl = validateOutboundEndpointUrl(config.apiEndpoint, {
name: 'admin.apiEndpoint',
allowHttp: config.env !== 'production',
allowLocalhost: config.env !== 'production',
allowPrivateIpLiterals: config.env !== 'production',
});
}
private buildHeaders(auditLogReason?: string): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.session.accessToken}`,
'Content-Type': 'application/json',
};
if (auditLogReason) {
headers['X-Audit-Log-Reason'] = auditLogReason;
}
return headers;
}
private buildUrl(path: string, queryParams?: Record<string, string | number | boolean | undefined | null>): string {
const baseUrl = buildEndpointUrl(this.apiEndpointUrl, path);
if (!queryParams) {
return baseUrl;
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}
async request<T>(options: RequestOptions): Promise<ApiResult<T>> {
try {
const url = this.buildUrl(options.path, options.queryParams);
const headers = this.buildHeaders(options.auditLogReason);
const fetchOptions: RequestInit = {
method: options.method,
headers,
};
if (options.body !== undefined && options.method !== 'GET') {
if (typeof options.body === 'string') {
fetchOptions.body = options.body;
} else {
fetchOptions.body = JSON.stringify(options.body);
}
}
const response = await fetch(url, fetchOptions);
if (response.status === 204) {
return {ok: true, data: undefined as T};
}
if (response.ok) {
const contentLength = response.headers.get('content-length');
if (contentLength === '0') {
return {ok: true, data: undefined as T};
}
try {
const data = (await response.json()) as T;
return {ok: true, data};
} catch {
return {ok: true, data: undefined as T};
}
}
return parseApiResponse<T>(response);
} catch (e) {
return {
ok: false,
error: {type: 'networkError', message: (e as Error).message},
};
}
}
async get<T>(
path: string,
queryParams?: Record<string, string | number | boolean | undefined | null>,
): Promise<ApiResult<T>> {
return this.request<T>({
method: 'GET',
path,
...(queryParams !== undefined ? {queryParams} : {}),
});
}
async post<T>(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<T>> {
return this.request<T>({
method: 'POST',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async postVoid(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<void>> {
return this.request<void>({
method: 'POST',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async patch<T>(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<T>> {
return this.request<T>({
method: 'PATCH',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async patchVoid(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<void>> {
return this.request<void>({
method: 'PATCH',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async delete<T>(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<T>> {
return this.request<T>({
method: 'DELETE',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async deleteVoid(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<void>> {
return this.request<void>({
method: 'DELETE',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async put<T>(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<T>> {
return this.request<T>({
method: 'PUT',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
async putVoid(path: string, body?: JsonValue | string, auditLogReason?: string): Promise<ApiResult<void>> {
return this.request<void>({
method: 'PUT',
path,
...(body !== undefined ? {body} : {}),
...(auditLogReason !== undefined ? {auditLogReason} : {}),
});
}
}
export function createApiClient(config: AdminConfig, session: Session): ApiClient {
return new ApiClient(config, session);
}

View File

@@ -0,0 +1,43 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {CodesResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
type CodesResponseType = z.infer<typeof CodesResponse>;
export async function generateGiftCodes(
config: Config,
session: Session,
count: number,
product_type: string,
): Promise<ApiResult<Array<string>>> {
const client = new ApiClient(config, session);
const result = await client.post<CodesResponseType>('/admin/codes/gift', {count, product_type});
if (result.ok) {
return {ok: true, data: result.data.codes};
}
return result;
}

View File

@@ -0,0 +1,77 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {z} from 'zod';
type DiscoveryApplicationResponseType = z.infer<typeof DiscoveryApplicationResponse>;
export async function listDiscoveryApplications(
config: Config,
session: Session,
status: string,
limit?: number,
): Promise<ApiResult<Array<DiscoveryApplicationResponseType>>> {
const client = new ApiClient(config, session);
return await client.get<Array<DiscoveryApplicationResponseType>>('/admin/discovery/applications', {
status,
limit: limit ?? 25,
});
}
export async function approveDiscoveryApplication(
config: Config,
session: Session,
guildId: string,
reason?: string,
): Promise<ApiResult<DiscoveryApplicationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<DiscoveryApplicationResponseType>(
`/admin/discovery/applications/${guildId}/approve`,
reason ? {reason} : {},
);
}
export async function rejectDiscoveryApplication(
config: Config,
session: Session,
guildId: string,
reason: string,
): Promise<ApiResult<DiscoveryApplicationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<DiscoveryApplicationResponseType>(`/admin/discovery/applications/${guildId}/reject`, {
reason,
});
}
export async function removeFromDiscovery(
config: Config,
session: Session,
guildId: string,
reason: string,
): Promise<ApiResult<DiscoveryApplicationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<DiscoveryApplicationResponseType>(`/admin/discovery/guilds/${guildId}/remove`, {reason});
}

View File

@@ -0,0 +1,325 @@
/*
* 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 {JsonValue} from '@fluxer/admin/src/api/JsonTypes';
import {isJsonObject, parseJson} from '@fluxer/admin/src/api/JsonTypes';
export interface ValidationError {
path: string;
message: string;
code: string;
}
export type ApiError =
| {type: 'unauthorized'}
| {type: 'forbidden'; code: string; message: string; details: Record<string, string>}
| {type: 'notFound'; code: string; message: string}
| {type: 'badRequest'; code: string; message: string; errors: Array<ValidationError>}
| {type: 'rateLimited'; code: string; message: string; retryAfter: number; isGlobal: boolean}
| {type: 'clientError'; status: number; code: string; message: string}
| {type: 'serverError'; status: number; code: string | null; message: string}
| {type: 'networkError'; message: string}
| {type: 'parseError'; body: string; parseError: string};
export function isUnauthorized(error: ApiError): error is {type: 'unauthorized'} {
return error.type === 'unauthorized';
}
export function isForbidden(
error: ApiError,
): error is {type: 'forbidden'; code: string; message: string; details: Record<string, string>} {
return error.type === 'forbidden';
}
export function isNotFound(error: ApiError): error is {type: 'notFound'; code: string; message: string} {
return error.type === 'notFound';
}
export function getErrorMessage(error: ApiError): string {
switch (error.type) {
case 'unauthorized':
return 'Authentication required';
case 'forbidden':
return error.message;
case 'notFound':
return error.message;
case 'badRequest':
return error.message;
case 'rateLimited':
return error.message;
case 'clientError':
return error.message;
case 'serverError':
return error.message;
case 'networkError':
return error.message;
case 'parseError':
return 'Failed to parse API response';
}
}
export function getErrorCode(error: ApiError): string | null {
switch (error.type) {
case 'forbidden':
return error.code;
case 'notFound':
return error.code;
case 'badRequest':
return error.code;
case 'rateLimited':
return error.code;
case 'clientError':
return error.code;
case 'serverError':
return error.code;
default:
return null;
}
}
export function getErrorDisplayString(error: ApiError): string {
switch (error.type) {
case 'badRequest': {
const base = `${error.message} (${error.code})`;
if (error.errors.length === 0) return base;
const errorDetails = error.errors.map((e) => `${e.path}: ${e.message}`).join('\n');
return `${base}\n${errorDetails}`;
}
case 'rateLimited':
return `${error.message} (Retry after ${Math.round(error.retryAfter)}s)`;
case 'forbidden':
return error.message;
case 'notFound':
return error.message;
case 'clientError':
return error.message;
case 'serverError':
return error.code ? `${error.message} (Error code: ${error.code})` : error.message;
default:
return getErrorMessage(error);
}
}
export function isRetryable(error: ApiError): boolean {
return error.type === 'rateLimited' || error.type === 'serverError' || error.type === 'networkError';
}
export function getRetryAfterSeconds(error: ApiError): number | null {
if (error.type === 'rateLimited') {
return error.retryAfter;
}
return null;
}
export function getValidationErrors(error: ApiError): Array<ValidationError> {
if (error.type === 'badRequest') {
return error.errors;
}
return [];
}
export function getErrorTitle(error: ApiError): string {
switch (error.type) {
case 'unauthorized':
return 'Authentication Required';
case 'forbidden':
return 'Permission Denied';
case 'notFound':
return 'Not Found';
case 'badRequest':
return 'Validation Error';
case 'rateLimited':
return 'Rate Limited';
case 'clientError':
return 'Client Error';
case 'serverError':
return 'Server Error';
case 'networkError':
return 'Network Error';
case 'parseError':
return 'Response Error';
}
}
export function getErrorSubtitle(error: ApiError): string {
switch (error.type) {
case 'unauthorized':
return 'Your session has expired. Please log in again.';
case 'forbidden':
return "You don't have permission to perform this action.";
case 'notFound':
return 'The requested resource could not be found.';
case 'badRequest':
return 'Please check your input and try again.';
case 'rateLimited':
return "You've made too many requests. Please wait before trying again.";
case 'clientError':
return 'The request was invalid or malformed.';
case 'serverError':
return 'An internal server error occurred. Please try again later.';
case 'networkError':
return 'Could not connect to the server. Please check your connection.';
case 'parseError':
return 'The server returned an invalid response.';
}
}
export function getErrorDetails(error: ApiError): Array<string> {
switch (error.type) {
case 'forbidden':
return [`Permission denied (Error code: ${error.code})`];
case 'notFound':
return [`Resource not found (Error code: ${error.code})`];
case 'badRequest': {
const codeDetail = `Validation failed (Error code: ${error.code})`;
const validationDetails = error.errors.map((e) => `${e.path}: ${e.message}`);
return [codeDetail, ...validationDetails];
}
case 'rateLimited':
return [
`Rate limit exceeded (Error code: ${error.code})`,
`Retry after: ${Math.round(error.retryAfter)} seconds`,
error.isGlobal ? 'This is a global rate limit' : 'This is an endpoint-specific rate limit',
];
case 'clientError':
return [`HTTP Status: ${error.status}`, `Client error (Error code: ${error.code})`];
case 'serverError': {
const statusDetail = `HTTP Status: ${error.status}`;
return error.code ? [statusDetail, `Error code: ${error.code}`] : [statusDetail];
}
case 'parseError': {
const preview = error.body.slice(0, 200).replace(/\n/g, ' ');
return ['Could not parse server response', `Response preview: ${preview}`];
}
default:
return [];
}
}
export async function parseApiResponse<T>(
response: Response,
): Promise<{ok: true; data: T} | {ok: false; error: ApiError}> {
if (response.ok) {
try {
const data = (await response.json()) as T;
return {ok: true, data};
} catch (e) {
const body = await response.text().catch(() => '');
return {ok: false, error: {type: 'parseError', body, parseError: String(e)}};
}
}
if (response.status === 401) {
return {ok: false, error: {type: 'unauthorized'}};
}
const bodyText = await response.text().catch(() => '');
const parsed = parseJson(bodyText);
const body = isJsonObject(parsed) ? parsed : {};
if (response.status === 403) {
return {
ok: false,
error: {
type: 'forbidden',
code: typeof body['code'] === 'string' ? body['code'] : 'FORBIDDEN',
message: typeof body['message'] === 'string' ? body['message'] : bodyText || 'Forbidden',
details: Object.fromEntries(
Object.entries(body).flatMap(([k, v]) => (typeof v === 'string' ? [[k, v] as const] : [])),
),
},
};
}
if (response.status === 404) {
return {
ok: false,
error: {
type: 'notFound',
code: typeof body['code'] === 'string' ? body['code'] : 'NOT_FOUND',
message: typeof body['message'] === 'string' ? body['message'] : 'The requested resource was not found.',
},
};
}
if (response.status === 400) {
const errors: Array<ValidationError> = [];
const errorList = Array.isArray(body['errors']) ? body['errors'] : [];
for (const e of errorList) {
if (typeof e !== 'object' || e === null || Array.isArray(e)) {
continue;
}
const errObj = e as {[key: string]: JsonValue};
errors.push({
path: typeof errObj['path'] === 'string' ? errObj['path'] : '',
message: typeof errObj['message'] === 'string' ? errObj['message'] : '',
code: typeof errObj['code'] === 'string' ? errObj['code'] : '',
});
}
return {
ok: false,
error: {
type: 'badRequest',
code: typeof body['code'] === 'string' ? body['code'] : 'BAD_REQUEST',
message: typeof body['message'] === 'string' ? body['message'] : 'Bad request',
errors,
},
};
}
if (response.status === 429) {
const retryAfterHeader = response.headers.get('retry-after');
const retryAfter = retryAfterHeader ? parseFloat(retryAfterHeader) : 60;
return {
ok: false,
error: {
type: 'rateLimited',
code: (body['code'] as string) ?? 'RATE_LIMITED',
message: (body['message'] as string) ?? 'Rate limited',
retryAfter,
isGlobal: (body['global'] as boolean) ?? false,
},
};
}
if (response.status >= 500) {
return {
ok: false,
error: {
type: 'serverError',
status: response.status,
code: (body['code'] as string) ?? null,
message: (body['message'] as string) ?? (bodyText || 'Server error'),
},
};
}
return {
ok: false,
error: {
type: 'clientError',
status: response.status,
code: (body['code'] as string) ?? 'CLIENT_ERROR',
message: (body['message'] as string) ?? (bodyText || `HTTP ${response.status}`),
},
};
}

View File

@@ -0,0 +1,44 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {ListGuildEmojisResponse, ListGuildStickersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function listGuildEmojis(
config: Config,
session: Session,
guild_id: string,
): Promise<ApiResult<ListGuildEmojisResponse>> {
const client = new ApiClient(config, session);
return client.get<ListGuildEmojisResponse>(`/admin/guilds/${guild_id}/emojis`);
}
export async function listGuildStickers(
config: Config,
session: Session,
guild_id: string,
): Promise<ApiResult<ListGuildStickersResponse>> {
const client = new ApiClient(config, session);
return client.get<ListGuildStickersResponse>(`/admin/guilds/${guild_id}/stickers`);
}

View File

@@ -0,0 +1,212 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
ListGuildMembersResponse,
LookupGuildResponse,
SearchGuildsResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
export type GuildLookupResult = NonNullable<z.infer<typeof LookupGuildResponse>['guild']>;
export type GuildChannel = GuildLookupResult['channels'][number];
export type GuildRole = GuildLookupResult['roles'][number];
export interface UpdateGuildSettingsOptions {
verification_level?: number | undefined;
mfa_level?: number | undefined;
nsfw_level?: number | undefined;
explicit_content_filter?: number | undefined;
default_message_notifications?: number | undefined;
disabled_operations?: number | undefined;
}
export async function lookupGuild(
config: Config,
session: Session,
guildId: string,
): Promise<ApiResult<GuildLookupResult | null>> {
const client = new ApiClient(config, session);
const result = await client.post<z.infer<typeof LookupGuildResponse>>('/admin/guilds/lookup', {guild_id: guildId});
if (result.ok) {
return {ok: true, data: result.data.guild};
}
return result;
}
export async function clearGuildFields(
config: Config,
session: Session,
guildId: string,
fields: Array<string>,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/clear-fields', {guild_id: guildId, fields});
}
export async function updateGuildFeatures(
config: Config,
session: Session,
guildId: string,
addFeatures: Array<string>,
removeFeatures: Array<string>,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/update-features', {
guild_id: guildId,
add_features: addFeatures,
remove_features: removeFeatures,
});
}
export async function updateGuildSettings(
config: Config,
session: Session,
guildId: string,
options: UpdateGuildSettingsOptions,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
guild_id: guildId,
...(options.verification_level !== undefined ? {verification_level: options.verification_level} : {}),
...(options.mfa_level !== undefined ? {mfa_level: options.mfa_level} : {}),
...(options.nsfw_level !== undefined ? {nsfw_level: options.nsfw_level} : {}),
...(options.explicit_content_filter !== undefined
? {explicit_content_filter: options.explicit_content_filter}
: {}),
...(options.default_message_notifications !== undefined
? {default_message_notifications: options.default_message_notifications}
: {}),
...(options.disabled_operations !== undefined ? {disabled_operations: options.disabled_operations} : {}),
};
return client.postVoid('/admin/guilds/update-settings', body);
}
export async function updateGuildName(
config: Config,
session: Session,
guildId: string,
name: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/update-name', {guild_id: guildId, name});
}
export async function updateGuildVanity(
config: Config,
session: Session,
guildId: string,
vanityUrlCode?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
guild_id: guildId,
...(vanityUrlCode !== undefined ? {vanity_url_code: vanityUrlCode} : {}),
};
return client.postVoid('/admin/guilds/update-vanity', body);
}
export async function transferGuildOwnership(
config: Config,
session: Session,
guildId: string,
newOwnerId: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/transfer-ownership', {guild_id: guildId, new_owner_id: newOwnerId});
}
export async function reloadGuild(config: Config, session: Session, guildId: string): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/reload', {guild_id: guildId});
}
export async function shutdownGuild(config: Config, session: Session, guildId: string): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/shutdown', {guild_id: guildId});
}
export async function deleteGuild(config: Config, session: Session, guildId: string): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/delete', {guild_id: guildId});
}
export async function forceAddUserToGuild(
config: Config,
session: Session,
userId: string,
guildId: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/force-add-user', {user_id: userId, guild_id: guildId});
}
export async function searchGuilds(
config: Config,
session: Session,
query: string,
limit: number = 25,
offset: number = 0,
): Promise<ApiResult<z.infer<typeof SearchGuildsResponse>>> {
const client = new ApiClient(config, session);
return client.post<z.infer<typeof SearchGuildsResponse>>('/admin/guilds/search', {query, limit, offset});
}
export async function listGuildMembers(
config: Config,
session: Session,
guildId: string,
limit: number = 50,
offset: number = 0,
): Promise<ApiResult<z.infer<typeof ListGuildMembersResponse>>> {
const client = new ApiClient(config, session);
return client.post<z.infer<typeof ListGuildMembersResponse>>('/admin/guilds/list-members', {
guild_id: guildId,
limit,
offset,
});
}
export async function banGuildMember(
config: Config,
session: Session,
guildId: string,
userId: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/ban-member', {guild_id: guildId, user_id: userId});
}
export async function kickGuildMember(
config: Config,
session: Session,
guildId: string,
userId: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/guilds/kick-member', {guild_id: guildId, user_id: userId});
}

View File

@@ -0,0 +1,79 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
SnowflakeReservationEntry,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function getInstanceConfig(config: Config, session: Session): Promise<ApiResult<InstanceConfigResponse>> {
const client = new ApiClient(config, session);
return client.post<InstanceConfigResponse>('/admin/instance-config/get', {});
}
export async function updateInstanceConfig(
config: Config,
session: Session,
update: InstanceConfigUpdateRequest,
): Promise<ApiResult<InstanceConfigResponse>> {
const client = new ApiClient(config, session);
return client.post<InstanceConfigResponse>('/admin/instance-config/update', update as JsonObject);
}
export async function listSnowflakeReservations(
config: Config,
session: Session,
): Promise<ApiResult<Array<SnowflakeReservationEntry>>> {
const client = new ApiClient(config, session);
const result = await client.post<{reservations: Array<SnowflakeReservationEntry>}>(
'/admin/snowflake-reservations/list',
{},
);
if (result.ok) {
return {ok: true, data: result.data.reservations};
}
return result;
}
export async function addSnowflakeReservation(
config: Config,
session: Session,
email: string,
snowflake: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/snowflake-reservations/add', {email, snowflake});
}
export async function deleteSnowflakeReservation(
config: Config,
session: Session,
email: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/snowflake-reservations/delete', {email});
}

View File

@@ -0,0 +1,37 @@
/*
* 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 */
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export type JsonObject = {[key: string]: JsonValue};
export type JsonArray = Array<JsonValue>;
export function parseJson(text: string): JsonValue | null {
try {
return JSON.parse(text) as JsonValue;
} catch {
return null;
}
}
export function isJsonObject(value: JsonValue | null): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,74 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
type LimitRule = LimitConfigResponse['limit_config']['rules'][number];
export async function getLimitConfig(config: Config, session: Session): Promise<ApiResult<LimitConfigResponse>> {
const client = new ApiClient(config, session);
return client.post<LimitConfigResponse>('/admin/limit-config/get', {});
}
export async function updateLimitConfig(
config: Config,
session: Session,
limitConfigJsonValue: string,
): Promise<ApiResult<LimitConfigResponse>> {
const client = new ApiClient(config, session);
return client.post<LimitConfigResponse>('/admin/limit-config/update', `{"limit_config":${limitConfigJsonValue}}`);
}
export function getDefaultValue(response: LimitConfigResponse, ruleId: string, limitKey: string): number | null {
const ruleDefaults = response.defaults[ruleId];
if (!ruleDefaults) {
return null;
}
const value = ruleDefaults[limitKey];
return value !== undefined ? value : null;
}
export function isModified(rule: LimitRule, limitKey: string): boolean {
return rule.modifiedFields?.includes(limitKey) ?? false;
}
export function getKeysByCategory(response: LimitConfigResponse): Record<string, Array<string>> {
const result: Record<string, Array<string>> = {};
for (const key of response.limit_keys) {
const meta = response.metadata[key];
if (meta) {
const category = meta.category;
if (!result[category]) {
result[category] = [];
}
result[category].push(key);
}
}
return result;
}

View File

@@ -0,0 +1,131 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
DeleteAllUserMessagesResponse,
MessageShredResponse,
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
import type {
LookupMessageResponse as LookupMessageResponseSchema,
MessageShredStatusResponse as MessageShredStatusResponseSchema,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
export type LookupMessageResponse = z.infer<typeof LookupMessageResponseSchema>;
export type MessageShredStatusResponse = z.infer<typeof MessageShredStatusResponseSchema>;
export type ShredEntry = {
channel_id: string;
message_id: string;
};
export async function deleteMessage(
config: Config,
session: Session,
channel_id: string,
message_id: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid(
'/admin/messages/delete',
{
channel_id,
message_id,
},
auditLogReason,
);
}
export async function lookupMessage(
config: Config,
session: Session,
channel_id: string,
message_id: string,
context_limit: number,
): Promise<ApiResult<LookupMessageResponse>> {
const client = new ApiClient(config, session);
return client.post<LookupMessageResponse>('/admin/messages/lookup', {
channel_id,
message_id,
context_limit,
});
}
export async function queueMessageShred(
config: Config,
session: Session,
user_id: string,
entries: Array<ShredEntry>,
): Promise<ApiResult<MessageShredResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id,
entries: entries.map((e) => ({channel_id: e.channel_id, message_id: e.message_id})),
};
return client.post<MessageShredResponse>('/admin/messages/shred', body);
}
export async function deleteAllUserMessages(
config: Config,
session: Session,
user_id: string,
dry_run: boolean,
): Promise<ApiResult<DeleteAllUserMessagesResponse>> {
const client = new ApiClient(config, session);
return client.post<DeleteAllUserMessagesResponse>('/admin/messages/delete-all', {
user_id,
dry_run,
});
}
export async function getMessageShredStatus(
config: Config,
session: Session,
job_id: string,
): Promise<ApiResult<MessageShredStatusResponse>> {
const client = new ApiClient(config, session);
return client.post<MessageShredStatusResponse>('/admin/messages/shred-status', {
job_id,
});
}
export async function lookupMessageByAttachment(
config: Config,
session: Session,
channel_id: string,
attachment_id: string,
filename: string,
context_limit: number,
): Promise<ApiResult<LookupMessageResponse>> {
const client = new ApiClient(config, session);
return client.post<LookupMessageResponse>('/admin/messages/lookup-by-attachment', {
channel_id,
attachment_id,
filename,
context_limit,
});
}

View File

@@ -0,0 +1,94 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
ListReportsResponse,
ReportAdminResponseSchema,
SearchReportsResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
export type Report = z.infer<typeof ReportAdminResponseSchema>;
export async function listReports(
config: Config,
session: Session,
status: number,
limit: number,
offset?: number,
): Promise<ApiResult<z.infer<typeof ListReportsResponse>>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
status,
limit,
...(offset !== undefined ? {offset} : {}),
};
return client.post<z.infer<typeof ListReportsResponse>>('/admin/reports/list', body);
}
export async function resolveReport(
config: Config,
session: Session,
reportId: string,
publicComment?: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
report_id: reportId,
...(publicComment !== undefined ? {public_comment: publicComment} : {}),
};
return client.postVoid('/admin/reports/resolve', body, auditLogReason);
}
export async function searchReports(
config: Config,
session: Session,
query?: string,
statusFilter?: number,
typeFilter?: number,
categoryFilter?: string,
limit: number = 25,
offset: number = 0,
): Promise<ApiResult<z.infer<typeof SearchReportsResponse>>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
limit,
offset,
...(query && query !== '' ? {query} : {}),
...(statusFilter !== undefined ? {status: statusFilter} : {}),
...(typeFilter !== undefined ? {report_type: typeFilter} : {}),
...(categoryFilter && categoryFilter !== '' ? {category: categoryFilter} : {}),
};
return client.post<z.infer<typeof SearchReportsResponse>>('/admin/reports/search', body);
}
export async function getReportDetail(config: Config, session: Session, reportId: string): Promise<ApiResult<Report>> {
const client = new ApiClient(config, session);
return client.get<Report>(`/admin/reports/${reportId}`);
}

View File

@@ -0,0 +1,62 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
IndexRefreshStatusResponse,
RefreshSearchIndexResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function refreshSearchIndex(
config: Config,
session: Session,
indexType: string,
reason?: string,
): Promise<ApiResult<RefreshSearchIndexResponse>> {
return refreshSearchIndexWithGuild(config, session, indexType, undefined, reason);
}
export async function refreshSearchIndexWithGuild(
config: Config,
session: Session,
indexType: string,
guildId?: string,
reason?: string,
): Promise<ApiResult<RefreshSearchIndexResponse>> {
const client = new ApiClient(config, session);
const body: Record<string, string> = {
index_type: indexType,
...(guildId ? {guild_id: guildId} : {}),
};
return client.post<RefreshSearchIndexResponse>('/admin/search/refresh-index', body, reason);
}
export async function getIndexRefreshStatus(
config: Config,
session: Session,
jobId: string,
): Promise<ApiResult<IndexRefreshStatusResponse>> {
const client = new ApiClient(config, session);
return client.post<IndexRefreshStatusResponse>('/admin/search/refresh-status', {job_id: jobId});
}

View File

@@ -0,0 +1,53 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
GuildMemoryStatsResponse,
NodeStatsResponse,
ReloadAllGuildsResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function getGuildMemoryStats(
config: Config,
session: Session,
limit: number = 25,
): Promise<ApiResult<GuildMemoryStatsResponse>> {
const client = new ApiClient(config, session);
return client.post<GuildMemoryStatsResponse>('/admin/gateway/memory-stats', {limit});
}
export async function reloadAllGuilds(
config: Config,
session: Session,
guildIds: Array<string>,
): Promise<ApiResult<ReloadAllGuildsResponse>> {
const client = new ApiClient(config, session);
return client.post<ReloadAllGuildsResponse>('/admin/gateway/reload-all', {guild_ids: guildIds});
}
export async function getNodeStats(config: Config, session: Session): Promise<ApiResult<NodeStatsResponse>> {
const client = new ApiClient(config, session);
return client.get<NodeStatsResponse>('/admin/gateway/stats');
}

View File

@@ -0,0 +1,68 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {ListSystemDmJobsResponse, SystemDmJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export async function createSystemDmJob(
config: Config,
session: Session,
content: string,
registrationStart?: string,
registrationEnd?: string,
excludedGuildIds: Array<string> = [],
): Promise<ApiResult<SystemDmJobResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
content,
...(registrationStart ? {registration_start: registrationStart} : {}),
...(registrationEnd ? {registration_end: registrationEnd} : {}),
...(excludedGuildIds.length > 0 ? {excluded_guild_ids: excludedGuildIds} : {}),
};
return client.post<SystemDmJobResponse>('/admin/system-dm-jobs', body);
}
export async function listSystemDmJobs(
config: Config,
session: Session,
limit: number = 25,
beforeJobId?: string,
): Promise<ApiResult<ListSystemDmJobsResponse>> {
const client = new ApiClient(config, session);
return client.get<ListSystemDmJobsResponse>('/admin/system-dm-jobs', {
limit,
before_job_id: beforeJobId,
});
}
export async function approveSystemDmJob(
config: Config,
session: Session,
jobId: string,
): Promise<ApiResult<SystemDmJobResponse>> {
const client = new ApiClient(config, session);
return client.post<SystemDmJobResponse>(`/admin/system-dm-jobs/${jobId}/approve`, {});
}

View File

@@ -0,0 +1,419 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {
AdminUsersMeResponse,
ListUserChangeLogResponse,
ListUserDmChannelsResponse,
ListUserSessionsResponse,
LookupUserResponse,
UserAdminResponse,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
export async function getCurrentAdmin(config: Config, session: Session): Promise<ApiResult<UserAdminResponse | null>> {
const client = new ApiClient(config, session);
const result = await client.get<AdminUsersMeResponse>('/admin/users/me');
if (result.ok) {
return {ok: true, data: result.data.user};
}
if (result.error.type === 'notFound') {
return {ok: true, data: null};
}
return result;
}
export interface UserSearchResult {
users: Array<UserAdminResponse>;
has_more: boolean;
}
export async function searchUsers(
config: Config,
session: Session,
query: string,
page: number = 0,
limit: number = 25,
): Promise<ApiResult<UserSearchResult>> {
const client = new ApiClient(config, session);
const offset = Math.max(0, page) * limit;
const result = await client.post<{users: Array<UserAdminResponse>; total: number}>('/admin/users/search', {
query,
limit,
offset,
});
if (result.ok) {
const users = result.data.users;
const hasMore = offset + users.length < result.data.total;
return {ok: true, data: {users, has_more: hasMore}};
}
return result;
}
export async function lookupUser(
config: Config,
session: Session,
query: string,
): Promise<ApiResult<UserAdminResponse | null>> {
const client = new ApiClient(config, session);
const result = await client.post<LookupUserResponse>('/admin/users/lookup', {query});
if (result.ok) {
const user = result.data.users[0];
return {ok: true, data: user ?? null};
}
return result;
}
export async function lookupUsersByIds(
config: Config,
session: Session,
userIds: Array<string>,
): Promise<ApiResult<Array<UserAdminResponse>>> {
if (userIds.length === 0) {
return {ok: true, data: []};
}
const client = new ApiClient(config, session);
const result = await client.post<LookupUserResponse>('/admin/users/lookup', {user_ids: userIds});
if (result.ok) {
return {ok: true, data: result.data.users};
}
return result;
}
export async function listUserGuilds(
config: Config,
session: Session,
userId: string,
before?: string,
after?: string,
limit: number = 25,
withCounts: boolean = true,
): Promise<ApiResult<ListUserGuildsResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id: userId,
limit,
with_counts: withCounts,
...(before ? {before} : {}),
...(after ? {after} : {}),
};
const result = await client.post<ListUserGuildsResponse>('/admin/users/list-guilds', body);
if (result.ok) {
return {ok: true, data: result.data};
}
return result;
}
export async function listUserDmChannels(
config: Config,
session: Session,
userId: string,
before?: string,
after?: string,
limit: number = 50,
): Promise<ApiResult<ListUserDmChannelsResponse>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id: userId,
limit,
...(before ? {before} : {}),
...(after ? {after} : {}),
};
const result = await client.post<ListUserDmChannelsResponse>('/admin/users/list-dm-channels', body);
if (result.ok) {
return {ok: true, data: result.data};
}
return result;
}
export async function listUserSessions(
config: Config,
session: Session,
userId: string,
): Promise<ApiResult<ListUserSessionsResponse>> {
const client = new ApiClient(config, session);
const result = await client.post<ListUserSessionsResponse>('/admin/users/list-sessions', {
user_id: userId,
});
if (result.ok) {
return {ok: true, data: result.data};
}
return result;
}
export async function listUserChangeLog(
config: Config,
session: Session,
userId: string,
): Promise<ApiResult<ListUserChangeLogResponse>> {
const client = new ApiClient(config, session);
const result = await client.post<ListUserChangeLogResponse>('/admin/users/change-log', {user_id: userId, limit: 50});
if (result.ok) {
return {ok: true, data: result.data};
}
return result;
}
export async function updateUserFlags(
config: Config,
session: Session,
userId: string,
addFlags: Array<string>,
removeFlags: Array<string>,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid(
'/admin/users/update-flags',
{user_id: userId, add_flags: addFlags, remove_flags: removeFlags},
auditLogReason,
);
}
export async function updateSuspiciousActivityFlags(
config: Config,
session: Session,
userId: string,
flags: number,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/update-suspicious-activity-flags', {user_id: userId, flags}, auditLogReason);
}
export async function setUserAcls(
config: Config,
session: Session,
userId: string,
acls: Array<string>,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/set-acls', {user_id: userId, acls}, auditLogReason);
}
export async function setUserTraits(
config: Config,
session: Session,
userId: string,
traits: Array<string>,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/set-traits', {user_id: userId, traits}, auditLogReason);
}
export async function disableMfa(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/disable-mfa', {user_id: userId}, auditLogReason);
}
export async function verifyEmail(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/verify-email', {user_id: userId}, auditLogReason);
}
export async function unlinkPhone(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/unlink-phone', {user_id: userId}, auditLogReason);
}
export async function terminateSessions(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/terminate-sessions', {user_id: userId}, auditLogReason);
}
export async function clearUserFields(
config: Config,
session: Session,
userId: string,
fields: Array<string>,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/clear-fields', {user_id: userId, fields}, auditLogReason);
}
export async function setBotStatus(
config: Config,
session: Session,
userId: string,
bot: boolean,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/set-bot-status', {user_id: userId, bot}, auditLogReason);
}
export async function setSystemStatus(
config: Config,
session: Session,
userId: string,
system: boolean,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/set-system-status', {user_id: userId, system}, auditLogReason);
}
export async function changeUsername(
config: Config,
session: Session,
userId: string,
username: string,
discriminator?: number,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id: userId,
username,
...(discriminator !== undefined ? {discriminator} : {}),
};
return client.postVoid('/admin/users/change-username', body, auditLogReason);
}
export async function changeEmail(
config: Config,
session: Session,
userId: string,
email: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/change-email', {user_id: userId, email}, auditLogReason);
}
export async function scheduleDeletion(
config: Config,
session: Session,
userId: string,
reasonCode: number,
publicReason: string | undefined,
daysUntilDeletion: number,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id: userId,
reason_code: reasonCode,
days_until_deletion: daysUntilDeletion,
...(publicReason ? {public_reason: publicReason} : {}),
};
return client.postVoid('/admin/users/schedule-deletion', body, auditLogReason);
}
export async function cancelDeletion(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/cancel-deletion', {user_id: userId}, auditLogReason);
}
export async function cancelBulkMessageDeletion(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/cancel-bulk-message-deletion', {user_id: userId}, auditLogReason);
}
export async function tempBanUser(
config: Config,
session: Session,
userId: string,
durationHours: number,
reason?: string,
privateReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
user_id: userId,
duration_hours: durationHours,
...(reason ? {reason} : {}),
};
return client.postVoid('/admin/users/temp-ban', body, privateReason);
}
export async function unbanUser(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/unban', {user_id: userId}, auditLogReason);
}
export async function changeDob(
config: Config,
session: Session,
userId: string,
dateOfBirth: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/change-dob', {user_id: userId, date_of_birth: dateOfBirth}, auditLogReason);
}
export async function sendPasswordReset(
config: Config,
session: Session,
userId: string,
auditLogReason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/users/send-password-reset', {user_id: userId}, auditLogReason);
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
ListVisionarySlotsResponse,
VisionarySlotOperationResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {z} from 'zod';
type ListVisionarySlotsResponseType = z.infer<typeof ListVisionarySlotsResponse>;
type VisionarySlotOperationResponseType = z.infer<typeof VisionarySlotOperationResponse>;
export async function listVisionarySlots(
config: Config,
session: Session,
): Promise<ApiResult<ListVisionarySlotsResponseType>> {
const client = new ApiClient(config, session);
return await client.get<ListVisionarySlotsResponseType>('/admin/visionary-slots');
}
export async function expandVisionarySlots(
config: Config,
session: Session,
count: number,
): Promise<ApiResult<VisionarySlotOperationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<VisionarySlotOperationResponseType>('/admin/visionary-slots/expand', {count});
}
export async function shrinkVisionarySlots(
config: Config,
session: Session,
targetCount: number,
): Promise<ApiResult<VisionarySlotOperationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<VisionarySlotOperationResponseType>('/admin/visionary-slots/shrink', {
target_count: targetCount,
});
}
export async function reserveVisionarySlot(
config: Config,
session: Session,
slotIndex: number,
userId: string | null,
): Promise<ApiResult<VisionarySlotOperationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<VisionarySlotOperationResponseType>('/admin/visionary-slots/reserve', {
slot_index: slotIndex,
user_id: userId,
});
}
export async function swapVisionarySlots(
config: Config,
session: Session,
slotIndexA: number,
slotIndexB: number,
): Promise<ApiResult<VisionarySlotOperationResponseType>> {
const client = new ApiClient(config, session);
return await client.post<VisionarySlotOperationResponseType>('/admin/visionary-slots/swap', {
slot_index_a: slotIndexA,
slot_index_b: slotIndexB,
});
}

View File

@@ -0,0 +1,217 @@
/*
* 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 {ApiClient, type ApiResult} from '@fluxer/admin/src/api/Client';
import type {JsonObject} from '@fluxer/admin/src/api/JsonTypes';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {
CreateVoiceRegionRequest,
CreateVoiceServerRequest,
ListVoiceRegionsResponse,
ListVoiceServersResponse,
UpdateVoiceRegionRequest,
UpdateVoiceServerRequest,
VoiceRegionWithServersResponse,
VoiceServerAdminResponse,
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
import type {z} from 'zod';
export interface GetVoiceRegionResponse {
region: VoiceRegionWithServersResponse | null;
}
export interface GetVoiceServerResponse {
server: VoiceServerAdminResponse | null;
}
export async function listVoiceRegions(
config: Config,
session: Session,
include_servers: boolean,
): Promise<ApiResult<ListVoiceRegionsResponse>> {
const client = new ApiClient(config, session);
return client.post<ListVoiceRegionsResponse>('/admin/voice/regions/list', {include_servers});
}
export async function getVoiceRegion(
config: Config,
session: Session,
id: string,
include_servers: boolean,
): Promise<ApiResult<GetVoiceRegionResponse>> {
const client = new ApiClient(config, session);
return client.post<GetVoiceRegionResponse>('/admin/voice/regions/get', {
id,
include_servers,
});
}
export interface CreateVoiceRegionParams
extends Omit<z.infer<typeof CreateVoiceRegionRequest>, 'allowed_guild_ids' | 'allowed_user_ids'> {
allowed_guild_ids: Array<string>;
allowed_user_ids?: Array<string>;
audit_log_reason?: string;
}
export async function createVoiceRegion(
config: Config,
session: Session,
params: CreateVoiceRegionParams,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid(
'/admin/voice/regions/create',
{
id: params.id,
name: params.name,
emoji: params.emoji,
latitude: params.latitude,
longitude: params.longitude,
is_default: params.is_default,
vip_only: params.vip_only,
required_guild_features: params.required_guild_features,
allowed_guild_ids: params.allowed_guild_ids,
allowed_user_ids: params.allowed_user_ids ?? [],
},
params.audit_log_reason,
);
}
export interface UpdateVoiceRegionParams
extends Omit<z.infer<typeof UpdateVoiceRegionRequest>, 'allowed_guild_ids' | 'allowed_user_ids'> {
allowed_guild_ids?: Array<string>;
allowed_user_ids?: Array<string>;
audit_log_reason?: string;
}
export async function updateVoiceRegion(
config: Config,
session: Session,
params: UpdateVoiceRegionParams,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
id: params.id,
...(params.name !== undefined ? {name: params.name} : {}),
...(params.emoji !== undefined ? {emoji: params.emoji} : {}),
...(params.latitude !== undefined ? {latitude: params.latitude} : {}),
...(params.longitude !== undefined ? {longitude: params.longitude} : {}),
...(params.is_default !== undefined ? {is_default: params.is_default} : {}),
...(params.vip_only !== undefined ? {vip_only: params.vip_only} : {}),
...(params.required_guild_features !== undefined ? {required_guild_features: params.required_guild_features} : {}),
...(params.allowed_guild_ids !== undefined ? {allowed_guild_ids: params.allowed_guild_ids} : {}),
...(params.allowed_user_ids !== undefined ? {allowed_user_ids: params.allowed_user_ids} : {}),
};
return client.postVoid('/admin/voice/regions/update', body, params.audit_log_reason);
}
export async function deleteVoiceRegion(
config: Config,
session: Session,
id: string,
audit_log_reason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/voice/regions/delete', {id}, audit_log_reason);
}
export async function listVoiceServers(
config: Config,
session: Session,
region_id: string,
): Promise<ApiResult<ListVoiceServersResponse>> {
const client = new ApiClient(config, session);
return client.post<ListVoiceServersResponse>('/admin/voice/servers/list', {region_id});
}
export interface CreateVoiceServerParams
extends Omit<z.infer<typeof CreateVoiceServerRequest>, 'allowed_guild_ids' | 'allowed_user_ids'> {
allowed_guild_ids: Array<string>;
allowed_user_ids?: Array<string>;
audit_log_reason?: string;
}
export async function createVoiceServer(
config: Config,
session: Session,
params: CreateVoiceServerParams,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid(
'/admin/voice/servers/create',
{
region_id: params.region_id,
server_id: params.server_id,
endpoint: params.endpoint,
api_key: params.api_key,
api_secret: params.api_secret,
is_active: params.is_active,
vip_only: params.vip_only,
required_guild_features: params.required_guild_features,
allowed_guild_ids: params.allowed_guild_ids,
allowed_user_ids: params.allowed_user_ids ?? [],
},
params.audit_log_reason,
);
}
export interface UpdateVoiceServerParams
extends Omit<z.infer<typeof UpdateVoiceServerRequest>, 'allowed_guild_ids' | 'allowed_user_ids'> {
allowed_guild_ids?: Array<string>;
allowed_user_ids?: Array<string>;
audit_log_reason?: string;
}
export async function updateVoiceServer(
config: Config,
session: Session,
params: UpdateVoiceServerParams,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
const body: JsonObject = {
region_id: params.region_id,
server_id: params.server_id,
...(params.endpoint !== undefined ? {endpoint: params.endpoint} : {}),
...(params.api_key !== undefined ? {api_key: params.api_key} : {}),
...(params.api_secret !== undefined ? {api_secret: params.api_secret} : {}),
...(params.is_active !== undefined ? {is_active: params.is_active} : {}),
...(params.vip_only !== undefined ? {vip_only: params.vip_only} : {}),
...(params.required_guild_features !== undefined ? {required_guild_features: params.required_guild_features} : {}),
...(params.allowed_guild_ids !== undefined ? {allowed_guild_ids: params.allowed_guild_ids} : {}),
...(params.allowed_user_ids !== undefined ? {allowed_user_ids: params.allowed_user_ids} : {}),
};
return client.postVoid('/admin/voice/servers/update', body, params.audit_log_reason);
}
export async function deleteVoiceServer(
config: Config,
session: Session,
region_id: string,
server_id: string,
audit_log_reason?: string,
): Promise<ApiResult<void>> {
const client = new ApiClient(config, session);
return client.postVoid('/admin/voice/servers/delete', {region_id, server_id}, audit_log_reason);
}

View File

@@ -0,0 +1,51 @@
/*
* 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 {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {Alert} from '@fluxer/ui/src/components/Alert';
import {Card} from '@fluxer/ui/src/components/Card';
import type {FC} from 'hono/jsx';
interface ErrorAlertProps {
error: string;
}
interface ErrorCardProps {
title: string;
message: string;
}
export const ErrorAlert: FC<ErrorAlertProps> = ({error}) => <Alert variant="error">{error}</Alert>;
export const ErrorCard: FC<ErrorCardProps> = ({title, message}) => (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
{title}
</Heading>
<Text size="sm" color="muted">
{message}
</Text>
</VStack>
</Card>
);

View File

@@ -0,0 +1,91 @@
/*
* 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 */
export function PaperclipIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-3 w-3 ${color}`}>
<rect width="256" height="256" fill="none" />
<path
d="M108.71,197.23l-5.11,5.11a46.63,46.63,0,0,1-66-.05h0a46.63,46.63,0,0,1,.06-65.89L72.4,101.66a46.62,46.62,0,0,1,65.94,0h0A46.34,46.34,0,0,1,150.78,124"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
<path
d="M147.29,58.77l5.11-5.11a46.62,46.62,0,0,1,65.94,0h0a46.62,46.62,0,0,1,0,65.94L193.94,144,183.6,154.34a46.63,46.63,0,0,1-66-.05h0A46.46,46.46,0,0,1,105.22,132"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
}
export function CheckmarkIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${color}`}>
<rect width="256" height="256" fill="none" />
<polyline
points="40 144 96 200 224 72"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="24"
/>
</svg>
);
}
export function XIcon({color}: {color: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class={`inline-block h-4 w-4 ${color}`}>
<rect width="256" height="256" fill="none" />
<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>
);
}

View File

@@ -0,0 +1,388 @@
/*
* 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 {getAccessibleSections} from '@fluxer/admin/src/Navigation';
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 {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {FlashMessage} from '@fluxer/ui/src/components/Flash';
import {formatDiscriminator, getUserAvatarUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {FC, PropsWithChildren} from 'hono/jsx';
interface LayoutProps {
title: string;
activePage: string;
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
autoRefresh?: boolean;
assetVersion: string;
csrfToken: string;
extraScripts?: string;
inspectedVoiceRegionId?: string;
}
function cacheBustedAsset(basePath: string, assetVersion: string, path: string): string {
return `${basePath}${path}?t=${assetVersion}`;
}
const Head: FC<{
title: string;
basePath: string;
staticCdnEndpoint: string;
assetVersion: string;
autoRefresh: boolean | undefined;
}> = ({title, basePath, staticCdnEndpoint, assetVersion, autoRefresh}) => (
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{autoRefresh && <meta http-equiv="refresh" content="3" />}
<title>{title} ~ Fluxer Admin</title>
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/ibm-plex.css`} />
<link rel="stylesheet" href={`${staticCdnEndpoint}/fonts/bricolage.css`} />
<link rel="stylesheet" href={cacheBustedAsset(basePath, assetVersion, '/static/app.css')} />
<link rel="icon" type="image/x-icon" href={`${staticCdnEndpoint}/web/favicon.ico`} />
<link rel="apple-touch-icon" href={`${staticCdnEndpoint}/web/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${staticCdnEndpoint}/web/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${staticCdnEndpoint}/web/favicon-16x16.png`} />
</head>
);
const SidebarSection: FC<PropsWithChildren<{title: string}>> = ({title, children}) => (
<div>
<div class="mb-2 text-neutral-400 text-xs uppercase">{title}</div>
<div class="space-y-1">{children}</div>
</div>
);
const SidebarItem: FC<{title: string; path: string; active: boolean; basePath: string}> = ({
title,
path,
active,
basePath,
}) => {
const classes = active
? 'block px-3 py-2 rounded bg-neutral-800 text-white text-sm transition-colors'
: 'block px-3 py-2 rounded text-neutral-300 hover:bg-neutral-800 hover:text-white text-sm transition-colors';
return (
<a href={`${basePath}${path}`} class={classes} {...(active ? {'data-active': ''} : {})}>
{title}
</a>
);
};
const Sidebar: FC<{
activePage: string;
adminAcls: Array<string>;
basePath: string;
selfHosted: boolean;
inspectedVoiceRegionId?: string;
}> = ({activePage, adminAcls, basePath, selfHosted, inspectedVoiceRegionId}) => {
const sections = getAccessibleSections(adminAcls, {selfHosted, inspectedVoiceRegionId});
return (
<div
data-sidebar=""
class="fixed inset-y-0 left-0 z-40 flex h-screen w-64 -translate-x-full transform flex-col bg-neutral-900 text-white shadow-xl transition-transform duration-200 ease-in-out lg:static lg:inset-auto lg:translate-x-0 lg:shadow-none"
>
<div class="flex items-center justify-between gap-3 border-neutral-800 border-b p-6">
<a href={`${basePath}/users`}>
<h1 class="font-semibold text-base">Fluxer Admin</h1>
</a>
<button
type="button"
data-sidebar-close=""
class="inline-flex items-center justify-center rounded-md p-2 text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40 lg:hidden"
aria-label="Close sidebar"
>
Close
</button>
</div>
<nav class="sidebar-scrollbar flex-1 space-y-4 overflow-y-auto p-4">
{sections.map((section) => (
<SidebarSection title={section.title}>
{section.items.map((item) => (
<SidebarItem
title={item.title}
path={item.path}
active={activePage === item.activeKey}
basePath={basePath}
/>
))}
</SidebarSection>
))}
</nav>
<script
defer
dangerouslySetInnerHTML={{
__html: SIDEBAR_ACTIVE_SCROLL_SCRIPT,
}}
/>
</div>
);
};
const Header: FC<{
config: Config;
session: Session;
currentAdmin: UserAdminResponse | undefined;
assetVersion: string;
csrfToken: string;
}> = ({config, session, currentAdmin, assetVersion, csrfToken}) => (
<header class="sticky top-0 z-10 flex items-center justify-between gap-4 border-neutral-200 border-b bg-white px-4 py-4 sm:px-6 lg:px-8">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
data-sidebar-toggle=""
class="inline-flex items-center justify-center rounded-md border border-neutral-300 p-2 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400 lg:hidden"
aria-label="Toggle sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{currentAdmin ? (
<a
href={`${config.basePath}/users/${session.userId}`}
class="flex items-center gap-3 transition-opacity hover:opacity-80"
>
<img
src={getUserAvatarUrl(
config.mediaEndpoint,
config.staticCdnEndpoint,
currentAdmin.id,
currentAdmin.avatar,
true,
assetVersion,
)}
alt={`${currentAdmin.username}'s avatar`}
class="h-10 w-10 rounded-full"
/>
<div class="flex flex-col">
<div class="text-neutral-900 text-sm">
{currentAdmin.username}#{formatDiscriminator(currentAdmin.discriminator)}
</div>
<div class="text-neutral-500 text-xs">Admin</div>
</div>
</a>
) : (
<div class="text-neutral-600 text-sm">
Logged in as:{' '}
<a
href={`${config.basePath}/users/${session.userId}`}
class="text-blue-600 hover:text-blue-800 hover:underline"
>
{session.userId}
</a>
</div>
)}
</div>
<form method="post" action={`${config.basePath}/logout`}>
<CsrfInput token={csrfToken} />
<button
type="submit"
class="rounded border border-neutral-300 px-4 py-2 font-medium text-neutral-700 text-sm transition-colors hover:border-neutral-400 hover:text-neutral-900"
>
Logout
</button>
</form>
</header>
);
const SIDEBAR_ACTIVE_SCROLL_SCRIPT = `
(function () {
var el = document.querySelector('[data-active]');
if (el) el.scrollIntoView({block: 'nearest'});
})();
`;
const SIDEBAR_SCRIPT = `
(function () {
var sidebar = document.querySelector('[data-sidebar]');
var overlay = document.querySelector('[data-sidebar-overlay]');
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
var closes = document.querySelectorAll('[data-sidebar-close]');
if (!sidebar || !overlay) return;
function open() {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
}
function close() {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
}
toggles.forEach(function (btn) {
btn.addEventListener('click', function () {
if (sidebar.classList.contains('-translate-x-full')) {
open();
} else {
close();
}
});
});
closes.forEach(function (btn) {
btn.addEventListener('click', close);
});
overlay.addEventListener('click', close);
window.addEventListener('keydown', function (event) {
if (event.key === 'Escape') close();
});
function syncForDesktop() {
if (window.innerWidth >= 1024) {
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
sidebar.classList.remove('-translate-x-full');
} else {
sidebar.classList.add('-translate-x-full');
}
}
window.addEventListener('resize', syncForDesktop);
syncForDesktop();
})();
`;
const SH_LINK_REWRITE_SCRIPT = `
(function () {
if (window.location.search.indexOf('sh=1') === -1) return;
function rewriteHref(el) {
var href = el.getAttribute('href');
if (!href || href.indexOf('sh=1') >= 0) return;
if (href.charAt(0) === '#' || href.indexOf('javascript:') === 0 || href.indexOf('data:') === 0 || href.indexOf('mailto:') === 0) return;
if (href.indexOf('://') >= 0) {
try {
var url = new URL(href);
if (url.origin !== window.location.origin) return;
} catch (e) {
return;
}
}
var sep = href.indexOf('?') >= 0 ? '&' : '?';
el.setAttribute('href', href + sep + 'sh=1');
}
function rewriteAction(form) {
var action = form.getAttribute('action');
if (!action || action.indexOf('sh=1') >= 0) return;
var sep = action.indexOf('?') >= 0 ? '&' : '?';
form.setAttribute('action', action + sep + 'sh=1');
}
document.querySelectorAll('a[href]').forEach(rewriteHref);
document.querySelectorAll('form[action]').forEach(rewriteAction);
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (a) rewriteHref(a);
}, true);
document.addEventListener('submit', function (e) {
var form = e.target.closest('form[action]');
if (form) rewriteAction(form);
}, true);
})();
`;
export function Layout({
title,
activePage,
config,
session,
currentAdmin,
flash,
autoRefresh,
assetVersion,
csrfToken,
extraScripts,
inspectedVoiceRegionId,
children,
}: PropsWithChildren<LayoutProps>) {
const adminAcls = currentAdmin?.acls ?? [];
return (
<html lang="en" data-base-path={config.basePath}>
<Head
title={title}
basePath={config.basePath}
staticCdnEndpoint={config.staticCdnEndpoint}
assetVersion={assetVersion}
autoRefresh={autoRefresh}
/>
<body class="min-h-screen overflow-hidden bg-neutral-50">
<div class="flex h-screen">
<Sidebar
activePage={activePage}
adminAcls={adminAcls}
basePath={config.basePath}
selfHosted={config.selfHosted}
inspectedVoiceRegionId={inspectedVoiceRegionId}
/>
<div data-sidebar-overlay="" class="fixed inset-0 z-30 hidden bg-black/50 lg:hidden" />
<div class="flex h-screen w-full flex-1 flex-col overflow-y-auto">
<Header
config={config}
session={session}
currentAdmin={currentAdmin}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto w-full max-w-7xl">
{flash && (
<div class="mb-6">
<FlashMessage flash={flash} />
</div>
)}
{children}
</div>
</main>
</div>
</div>
<script defer dangerouslySetInnerHTML={{__html: SIDEBAR_SCRIPT}} />
<script defer dangerouslySetInnerHTML={{__html: SH_LINK_REWRITE_SCRIPT}} />
{extraScripts && <script defer dangerouslySetInnerHTML={{__html: extraScripts}} />}
</body>
</html>
);
}

View File

@@ -0,0 +1,204 @@
/*
* 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 {PaperclipIcon} from '@fluxer/admin/src/components/Icons';
import {CSRF_FORM_FIELD} from '@fluxer/constants/src/Cookies';
import {formatUserTag} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
interface Attachment {
url: string;
filename: string;
}
export interface Message {
id: string;
content: string;
timestamp: string;
author_id: string;
author_username: string;
author_discriminator: string;
channel_id: string;
guild_id?: string | null;
attachments: Array<Attachment>;
}
interface MessageRowProps {
basePath: string;
message: Message;
includeDeleteButton: boolean;
}
function buildMessageLookupHref(basePath: string, channelId: string, messageId: string): string {
return `${basePath}/messages?channel_id=${channelId}&message_id=${messageId}&context_limit=50`;
}
const MessageRow: FC<MessageRowProps> = ({basePath, message, includeDeleteButton}) => (
<div
class="group flex items-start gap-3 px-4 py-2 transition-colors hover:bg-neutral-50"
data-message-id={message.id}
>
<div class="flex-shrink-0 pt-0.5">
<a
href={`${basePath}/users/${message.author_id}`}
class="cursor-pointer text-neutral-900 text-xs hover:underline"
title={message.author_id}
>
{formatUserTag(message.author_username, message.author_discriminator)}
</a>
<div class="text-neutral-500 text-xs">{message.timestamp}</div>
</div>
<div class="message-content min-w-0 flex-1">
<div class="whitespace-pre-wrap break-words text-neutral-900 text-sm">{message.content}</div>
{message.attachments.length > 0 && (
<div class="mt-2 space-y-1">
{message.attachments.map((att) => (
<div class="flex items-center gap-1 text-xs">
<PaperclipIcon color="text-neutral-500" />
<a href={att.url} target="_blank" class="text-blue-600 hover:underline">
{att.filename}
</a>
</div>
))}
</div>
)}
<div class="mt-1 flex flex-wrap items-center gap-2 text-neutral-400 text-xs">
<span>ID: {message.id}</span>
{message.channel_id && (
<>
<span>|</span>
<a
href={buildMessageLookupHref(basePath, message.channel_id, message.id)}
class="text-neutral-500 hover:text-neutral-700 hover:underline"
>
Channel: {message.channel_id}
</a>
</>
)}
{message.guild_id && (
<>
<span>|</span>
<a
href={`${basePath}/guilds/${message.guild_id}`}
class="text-neutral-500 hover:text-neutral-700 hover:underline"
>
Guild: {message.guild_id}
</a>
</>
)}
</div>
</div>
{includeDeleteButton && message.channel_id && (
<div class="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
class="rounded px-2 py-1 text-red-600 text-xs transition-colors hover:bg-red-50 hover:text-red-700"
title="Delete message"
onclick={`deleteMessage('${message.channel_id}', '${message.id}', this)`}
>
Delete
</button>
</div>
)}
</div>
);
export function MessageList({
basePath,
messages,
includeDeleteButton,
}: {
basePath: string;
messages: Array<Message>;
includeDeleteButton: boolean;
}) {
return (
<div class="space-y-1">
{messages.map((message) => (
<MessageRow basePath={basePath} message={message} includeDeleteButton={includeDeleteButton} />
))}
</div>
);
}
export function createMessageDeletionScriptBody(csrfToken: string): string {
return `
function deleteMessage(channelId, messageId, button) {
const csrfToken = ${JSON.stringify(csrfToken)};
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const formData = new FormData();
formData.append('channel_id', channelId);
formData.append('message_id', messageId);
formData.append('${CSRF_FORM_FIELD}', csrfToken);
button.disabled = true;
button.textContent = 'Deleting...';
const basePath = document.documentElement.dataset.basePath || '';
fetch(basePath + '/messages?action=delete', {
method: 'POST',
body: formData
})
.then(async response => {
if (response.ok) {
const messageRow = button.closest('[data-message-id]');
if (messageRow) {
messageRow.style.opacity = '0.5';
messageRow.style.pointerEvents = 'none';
const messageContent = messageRow.querySelector('.message-content');
if (messageContent) {
messageContent.style.textDecoration = 'line-through';
}
}
const buttonContainer = button.parentElement;
const deletedBadge = document.createElement('span');
deletedBadge.className = 'px-2 py-1 bg-red-100 text-red-800 text-xs rounded opacity-100';
deletedBadge.textContent = 'DELETED';
button.replaceWith(deletedBadge);
if (buttonContainer) {
buttonContainer.style.opacity = '1';
}
} else {
button.disabled = false;
button.textContent = 'Delete';
let errorMessage = 'Failed to delete message';
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
} catch (e) {}
alert(errorMessage);
}
})
.catch(error => {
console.error('Error:', error);
button.disabled = false;
button.textContent = 'Delete';
alert('Error deleting message: ' + (error.message || 'Unknown error'));
});
}
`;
}

View File

@@ -0,0 +1,124 @@
/*
* 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 {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {hasBigIntFlag, parseBigIntOrZero} from '@fluxer/admin/src/utils/Bigint';
import {cn} from '@fluxer/admin/src/utils/ClassNames';
import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {getFormattedShortDate} from '@fluxer/date_utils/src/DateFormatting';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {normalizeEndpoint} from '@fluxer/ui/src/utils/AvatarMediaUtils';
interface BadgeDefinition {
key: string;
iconUrl: string;
tooltip: string;
}
export interface UserProfileBadgesProps {
config: Config;
user: UserAdminResponse;
size?: 'sm' | 'md';
class?: string;
}
export function UserProfileBadges({config, user, size = 'sm', class: className}: UserProfileBadgesProps) {
const flags = parseBigIntOrZero(user.flags);
const badges: Array<BadgeDefinition> = [];
const isSelfHosted = config.selfHosted;
const staticCdnEndpoint = normalizeEndpoint(config.staticCdnEndpoint);
if (hasBigIntFlag(flags, UserFlags.STAFF)) {
badges.push({
key: 'staff',
iconUrl: `${staticCdnEndpoint}/badges/staff.svg`,
tooltip: 'Fluxer Staff',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.CTP_MEMBER)) {
badges.push({
key: 'ctp',
iconUrl: `${staticCdnEndpoint}/badges/ctp.svg`,
tooltip: 'Fluxer Community Team',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.PARTNER)) {
badges.push({
key: 'partner',
iconUrl: `${staticCdnEndpoint}/badges/partner.svg`,
tooltip: 'Fluxer Partner',
});
}
if (!isSelfHosted && hasBigIntFlag(flags, UserFlags.BUG_HUNTER)) {
badges.push({
key: 'bug_hunter',
iconUrl: `${staticCdnEndpoint}/badges/bug-hunter.svg`,
tooltip: 'Fluxer Bug Hunter',
});
}
if (!isSelfHosted && user.premium_type && user.premium_type !== UserPremiumTypes.NONE) {
let tooltip = 'Fluxer Plutonium';
if (user.premium_type === UserPremiumTypes.LIFETIME) {
if (user.premium_since) {
const premiumSince = getFormattedShortDate(user.premium_since);
tooltip = `Fluxer Visionary since ${premiumSince}`;
} else {
tooltip = 'Fluxer Visionary';
}
} else if (user.premium_since) {
const premiumSince = getFormattedShortDate(user.premium_since);
tooltip = `Fluxer Plutonium subscriber since ${premiumSince}`;
}
badges.push({
key: 'premium',
iconUrl: `${staticCdnEndpoint}/badges/plutonium.svg`,
tooltip,
});
}
if (badges.length === 0) {
return null;
}
const badgeSizeClass = size === 'md' ? 'h-5 w-5' : 'h-4 w-4';
const containerClass = cn('flex items-center', size === 'md' ? 'gap-2' : 'gap-1.5', className);
return (
<div class={containerClass}>
{badges.map((badge) => (
<img
key={badge.key}
src={badge.iconUrl}
alt={badge.tooltip}
title={badge.tooltip}
class={cn(badgeSizeClass, 'shrink-0')}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,103 @@
/*
* 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 {UnifiedBadge as Badge} from '@fluxer/ui/src/components/Badge';
import {Checkbox, Input} from '@fluxer/ui/src/components/Form';
import type {FC} from 'hono/jsx';
export interface VoiceRestrictions {
vip_only: boolean;
required_guild_features: Array<string>;
allowed_guild_ids: Array<string>;
}
export interface VoiceStatusBadgesProps {
vip_only: boolean;
has_features: boolean;
has_guild_ids: boolean;
}
export const VoiceStatusBadges: FC<VoiceStatusBadgesProps> = ({vip_only, has_features, has_guild_ids}) => (
<>
{vip_only && <Badge label="VIP ONLY" tone="purple" intensity="subtle" rounded="default" />}
{has_features && <Badge label="FEATURES" tone="orange" intensity="subtle" rounded="default" />}
{has_guild_ids && <Badge label="GUILD IDS" tone="warning" intensity="subtle" rounded="default" />}
</>
);
export interface VoiceFeaturesListProps {
features: Array<string>;
}
export const VoiceFeaturesList: FC<VoiceFeaturesListProps> = ({features}) =>
features.length > 0 ? (
<div>
<span class="font-medium text-neutral-600 text-xs">Required Features: </span>
<span class="text-neutral-700 text-xs">{features.join(', ')}</span>
</div>
) : null;
export interface VoiceGuildIdsListProps {
guild_ids: Array<string>;
}
export const VoiceGuildIdsList: FC<VoiceGuildIdsListProps> = ({guild_ids}) =>
guild_ids.length > 0 ? (
<div>
<span class="font-medium text-neutral-600 text-xs">Allowed Guilds: </span>
<span class="text-neutral-700 text-xs">{guild_ids.join(', ')}</span>
</div>
) : null;
export interface VoiceRestrictionFieldsProps {
id_prefix?: string;
restrictions: VoiceRestrictions;
}
export const VoiceRestrictionFields: FC<VoiceRestrictionFieldsProps> = ({id_prefix = '', restrictions}) => {
const {vip_only, required_guild_features, allowed_guild_ids} = restrictions;
return (
<div class="space-y-3 border-neutral-200 border-t pt-3">
<h4 class="font-medium text-neutral-700 text-sm">Access Restrictions</h4>
<Checkbox name="vip_only" value="true" label="VIP Only" checked={vip_only} />
<Input
label="Required Guild Features"
name="required_guild_features"
type="text"
value={required_guild_features.join(', ')}
placeholder="e.g. FEATURE_1, FEATURE_2"
id={id_prefix ? `${id_prefix}-required-guild-features` : undefined}
helper="Separate features with commas."
/>
<Input
label="Allowed Guild IDs"
name="allowed_guild_ids"
type="text"
value={allowed_guild_ids.join(', ')}
placeholder="e.g. 123456789, 987654321"
id={id_prefix ? `${id_prefix}-allowed-guild-ids` : undefined}
helper="Separate guild IDs with commas."
/>
</div>
);
};

View File

@@ -0,0 +1,47 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface AlertProps {
variant?: 'success' | 'warning' | 'error' | 'info';
title?: string;
children: Child;
class?: string;
}
export function Alert({variant = 'info', title, children, class: className}: AlertProps) {
const variantStyles = {
success: 'bg-green-50 border-green-200 text-green-700',
warning: 'bg-neutral-50 border-neutral-200 text-neutral-700',
error: 'bg-red-50 border-red-200 text-red-700',
info: 'bg-blue-50 border-blue-200 text-blue-700',
};
return (
<div class={cn('rounded-lg border p-4', variantStyles[variant], className)}>
{title && <div class="mb-2 font-bold">{title}</div>}
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface BadgeProps {
variant?: 'success' | 'danger' | 'warning' | 'info' | 'neutral';
size?: 'sm' | 'md';
}
export function Badge({variant = 'neutral', size = 'md', children}: PropsWithChildren<BadgeProps>) {
const classes = clsx(
'inline-flex items-center justify-center rounded-full font-medium',
{
'px-2 py-0.5 text-xs': size === 'sm',
'px-2.5 py-1 text-sm': size === 'md',
},
{
'bg-green-100 text-green-600': variant === 'success',
'bg-red-100 text-red-600': variant === 'danger',
'bg-neutral-100 text-neutral-700 border border-neutral-200': variant === 'warning',
'bg-blue-100 text-blue-600': variant === 'info',
'bg-neutral-100 text-neutral-600': variant === 'neutral',
},
);
return <span class={classes}>{children}</span>;
}

View File

@@ -0,0 +1,53 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export type CardPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl';
export type CardVariant = 'default' | 'bordered' | 'elevated';
export interface CardProps {
padding?: CardPadding;
variant?: CardVariant;
className?: string;
}
const paddingClasses: Record<CardPadding, string> = {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
xl: 'p-12',
};
const variantClasses: Record<CardVariant, string> = {
default: 'border border-neutral-200',
bordered: 'border-2 border-neutral-300',
elevated: 'border border-neutral-200 shadow-md',
};
export function Card({padding = 'md', variant = 'default', className, children}: PropsWithChildren<CardProps>) {
const classes = clsx('rounded-lg bg-white', variantClasses[variant], paddingClasses[padding], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardBodyProps {
className?: string;
}
export function CardBody({className, children}: PropsWithChildren<CardBodyProps>) {
const classes = clsx('p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardFooterProps {
className?: string;
}
export function CardFooter({className, children}: PropsWithChildren<CardFooterProps>) {
const classes = clsx('border-neutral-200 border-t p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,34 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface CardHeaderProps {
className?: string;
}
export function CardHeader({className, children}: PropsWithChildren<CardHeaderProps>) {
const classes = clsx('border-neutral-200 border-b p-6', className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface ChipProps {
active?: boolean;
href?: string;
}
export function Chip({active = false, href, children}: PropsWithChildren<ChipProps>) {
const chipClasses = clsx(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 font-medium text-sm transition-colors no-underline',
{
'border-brand-primary bg-brand-primary text-white': active,
'border-neutral-300 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50': !active,
},
);
if (href) {
return (
<a href={href} class={chipClasses}>
{children}
</a>
);
}
return <span class={chipClasses}>{children}</span>;
}

View File

@@ -0,0 +1,63 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface CodeBlockProps {
children: Child;
copiable?: boolean;
class?: string;
}
const COPY_CODE_SCRIPT = `
function copyCode(button) {
var code = button.previousElementSibling.textContent;
navigator.clipboard.writeText(code).then(function () {
var original = button.textContent;
button.textContent = 'Copied!';
setTimeout(function () { button.textContent = original; }, 2000);
});
}
`;
export function CodeBlock({children, copiable = false, class: className}: CodeBlockProps) {
return (
<div class={cn('relative', className)}>
<pre class="overflow-x-auto rounded border border-neutral-200 bg-neutral-100 p-3 font-mono text-sm">
<code>{children}</code>
</pre>
{copiable && (
<>
<button
type="button"
onclick="copyCode(this)"
class="absolute top-2 right-2 rounded border border-neutral-300 bg-white px-2 py-1 text-xs hover:bg-neutral-50"
>
Copy
</button>
<script dangerouslySetInnerHTML={{__html: COPY_CODE_SCRIPT}} />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export type ContainerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export interface ContainerProps {
size?: ContainerSize;
}
const sizeClasses: Record<ContainerSize, string> = {
sm: 'max-w-2xl',
md: 'max-w-4xl',
lg: 'max-w-6xl',
xl: 'max-w-7xl',
full: 'max-w-full',
};
export function Container({size = 'xl', children}: PropsWithChildren<ContainerProps>) {
const classes = clsx('mx-auto w-full px-4 sm:px-6 lg:px-8', sizeClasses[size]);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,40 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface EmptyStateProps {
variant?: 'empty' | 'loading' | 'error';
children: Child;
class?: string;
}
export function EmptyState({variant = 'empty', children, class: className}: EmptyStateProps) {
const variantStyles = {
empty: 'text-neutral-500 text-center py-8',
loading: 'text-neutral-500 text-center py-8',
error: 'text-red-600 text-center py-8',
};
return <div class={cn(variantStyles[variant], className)}>{children}</div>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import {Button} from '@fluxer/ui/src/components/Button';
export interface FormActionsProps {
submitText?: string;
cancelText?: string;
loading?: boolean;
cancelHref?: string;
class?: string;
}
export function FormActions(props: FormActionsProps) {
const {submitText = 'Submit', cancelText = 'Cancel', loading = false, cancelHref, class: className} = props;
return (
<div class={cn('flex items-center justify-end gap-3 border-gray-200 border-t pt-4', className)}>
{cancelHref && (
<Button variant="secondary" href={cancelHref} disabled={loading}>
{cancelText}
</Button>
)}
<Button type="submit" variant="primary" loading={loading}>
{submitText}
</Button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
/*
* 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 {Card, type CardPadding, type CardVariant} from '@fluxer/admin/src/components/ui/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormCardProps {
action: string;
method?: 'get' | 'post';
csrfToken: string;
padding?: CardPadding;
variant?: CardVariant;
className?: string;
}
export function FormCard(props: PropsWithChildren<FormCardProps>) {
const {action, method = 'post', csrfToken, padding = 'md', variant = 'default', className, children} = props;
return (
<Card padding={padding} variant={variant} className={className}>
<form method={method} action={action}>
<CsrfInput token={csrfToken} />
{children}
</form>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
/*
* 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 {Caption, Label} from '@fluxer/admin/src/components/ui/Typography';
import {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
export interface FormFieldGroupProps {
label: string;
htmlFor?: string;
required?: boolean;
error?: string;
helper?: Child;
children: Child;
class?: string;
}
export function FormFieldGroup(props: FormFieldGroupProps) {
const {label, htmlFor, required = false, error, helper, children, class: className} = props;
const helperView =
!error && helper !== undefined && helper !== null ? (
typeof helper === 'string' || typeof helper === 'number' ? (
<Caption>{helper}</Caption>
) : (
helper
)
) : null;
return (
<div class={cn('flex flex-col gap-2', className)}>
<div class="flex flex-col gap-1">
<Label htmlFor={htmlFor} required={required}>
{label}
</Label>
{helperView}
</div>
<div class="flex flex-col gap-1">
{children}
{error && (
<p class="flex items-center gap-1 text-red-600 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
{error}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormRowProps {
cols?: 1 | 2 | 3 | 4;
gap?: 2 | 3 | 4 | 5 | 6;
class?: string;
}
const colsStyles: Record<NonNullable<FormRowProps['cols']>, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
const gapStyles: Record<NonNullable<FormRowProps['gap']>, string> = {
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
};
export function FormRow(props: PropsWithChildren<FormRowProps>) {
const {cols = 2, gap = 4, children, class: className} = props;
return <div class={cn('grid', colsStyles[cols], gapStyles[gap], className)}>{children}</div>;
}

View File

@@ -0,0 +1,44 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export interface FormSectionProps {
title: string;
description?: string;
class?: string;
}
export function FormSection(props: PropsWithChildren<FormSectionProps>) {
const {title, description, children, class: className} = props;
return (
<div class={cn('space-y-4', className)}>
<div class="flex flex-col gap-1 border-gray-200 border-b pb-3">
<h3 class="font-semibold text-gray-900 text-lg">{title}</h3>
{description && <p class="text-gray-600 text-sm">{description}</p>}
</div>
<div class="space-y-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type GridCols = 1 | 2 | 3 | 4;
export type GridGap = 'sm' | 'md' | 'lg';
export interface GridProps {
cols?: GridCols;
gap?: GridGap;
class?: string;
}
const colsClasses: Record<GridCols, string> = {
1: 'md:grid-cols-1',
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
};
const gapClasses: Record<GridGap, string> = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function Grid({cols = 2, gap = 'md', class: className, children}: PropsWithChildren<GridProps>) {
const classes = cn('grid grid-cols-1', colsClasses[cols], gapClasses[gap], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child} from 'hono/jsx';
interface InlineStackProps {
gap?: number | string;
align?: 'start' | 'center' | 'end' | 'baseline';
children: Child;
class?: string;
}
export function InlineStack({gap = 2, align = 'center', children, class: className}: InlineStackProps) {
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
const alignClass = `items-${align}`;
return <div class={cn('flex', alignClass, gapClass, className)}>{children}</div>;
}

View File

@@ -0,0 +1,159 @@
/*
* 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 {Child, FC} from 'hono/jsx';
export type InputSize = 'sm' | 'md' | 'lg';
export type InputType = 'text' | 'email' | 'password' | 'tel' | 'number' | 'date' | 'datetime-local' | 'url' | 'search';
export interface InputProps {
type?: InputType;
name: string;
id?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
size?: InputSize;
error?: boolean;
fullWidth?: boolean;
leftIcon?: Child;
rightIcon?: Child;
autocomplete?: string;
min?: string | number;
max?: string | number;
step?: string | number;
pattern?: string;
class?: string;
}
const sizeClasses: Record<InputSize, string> = {
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-9 px-3 py-2 text-sm',
lg: 'h-10 px-4 py-2.5 text-base',
};
function toInputId(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
export const Input: FC<InputProps> = ({
type = 'text',
name,
id,
value,
placeholder,
disabled = false,
required = false,
readonly = false,
size = 'sm',
error = false,
fullWidth = true,
leftIcon,
rightIcon,
autocomplete,
min,
max,
step,
pattern,
class: extraClass,
}) => {
const inputId = id ?? toInputId(name);
const baseClasses = [
'rounded-lg',
'border',
'border-neutral-300',
'bg-white',
'text-neutral-900',
'placeholder:text-neutral-400',
'transition-all',
'focus:outline-none',
'focus:ring-2',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
];
const stateClasses = [
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'focus:border-brand-primary focus:ring-brand-primary/20',
fullWidth ? 'w-full' : '',
leftIcon ? 'pl-10' : '',
rightIcon ? 'pr-10' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], ...stateClasses, extraClass || ''].filter(Boolean).join(' ');
if (leftIcon || rightIcon) {
return (
<div class={`relative ${fullWidth ? 'w-full' : 'inline-flex'}`}>
{leftIcon && (
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-neutral-400">
{leftIcon}
</div>
)}
<input
type={type}
name={name}
id={inputId}
value={value}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
autocomplete={autocomplete}
min={min}
max={max}
step={step}
pattern={pattern}
class={classes}
/>
{rightIcon && (
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400">
{rightIcon}
</div>
)}
</div>
);
}
return (
<input
type={type}
name={name}
id={inputId}
value={value}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
autocomplete={autocomplete}
min={min}
max={max}
step={step}
pattern={pattern}
class={classes}
/>
);
};

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type BoxSpacing = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type BoxBackground = 'white' | 'gray-50' | 'gray-100' | 'transparent';
export type BoxBorder = 'none' | 'gray-200' | 'gray-300';
export type BoxRounded = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
export interface BoxProps {
p?: BoxSpacing;
m?: BoxSpacing;
bg?: BoxBackground;
border?: BoxBorder;
rounded?: BoxRounded;
}
const backgroundClasses: Record<BoxBackground, string> = {
white: 'bg-white',
'gray-50': 'bg-gray-50',
'gray-100': 'bg-gray-100',
transparent: 'bg-transparent',
};
const borderClasses: Record<BoxBorder, string> = {
none: '',
'gray-200': 'border border-gray-200',
'gray-300': 'border border-gray-300',
};
const roundedClasses: Record<BoxRounded, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full',
};
function getPaddingClass(p: BoxSpacing): string {
return `p-${p}`;
}
function getMarginClass(m: BoxSpacing): string {
return `m-${m}`;
}
export function Box({
p,
m,
bg = 'transparent',
border = 'none',
rounded = 'none',
children,
}: PropsWithChildren<BoxProps>) {
const classes = cn(
p !== undefined && getPaddingClass(p),
m !== undefined && getMarginClass(m),
backgroundClasses[bg],
borderClasses[border],
roundedClasses[rounded],
);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,39 @@
/*
* 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 {PageContainer} from '@fluxer/admin/src/components/ui/Layout/PageContainer';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface DetailPageLayoutProps {
header: Child;
tabs?: Child;
}
export function DetailPageLayout({header, tabs, children}: PropsWithChildren<DetailPageLayoutProps>) {
return (
<PageContainer>
{header}
{tabs}
{children}
</PageContainer>
);
}

View File

@@ -0,0 +1,94 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type FlexDirection = 'row' | 'col' | 'row-reverse' | 'col-reverse';
export type FlexAlign = 'start' | 'center' | 'end' | 'baseline' | 'stretch';
export type FlexJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export type FlexWrap = 'wrap' | 'nowrap' | 'wrap-reverse';
export interface FlexProps {
direction?: FlexDirection;
align?: FlexAlign;
justify?: FlexJustify;
gap?: number | string;
wrap?: FlexWrap;
}
const directionClasses: Record<FlexDirection, string> = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
const alignClasses: Record<FlexAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
const justifyClasses: Record<FlexJustify, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
const wrapClasses: Record<FlexWrap, string> = {
wrap: 'flex-wrap',
nowrap: 'flex-nowrap',
'wrap-reverse': 'flex-wrap-reverse',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function Flex({
direction = 'row',
align = 'stretch',
justify = 'start',
gap,
wrap = 'nowrap',
children,
}: PropsWithChildren<FlexProps>) {
const classes = cn(
'flex',
directionClasses[direction],
alignClasses[align],
justifyClasses[justify],
gap !== undefined && getGapClass(gap),
wrapClasses[wrap],
);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,44 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export interface FormGridProps {
cols?: 2 | 3 | 4;
gap?: 'sm' | 'md' | 'lg';
}
const colsClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function FormGrid({cols = 2, gap = 'md', children}: PropsWithChildren<FormGridProps>) {
return <div class={`grid grid-cols-1 ${colsClasses[cols]} ${gapClasses[gap]}`}>{children}</div>;
}

View File

@@ -0,0 +1,70 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type HStackAlign = 'start' | 'center' | 'end' | 'baseline' | 'stretch';
export type HStackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export interface HStackProps {
gap?: number | string;
align?: HStackAlign;
justify?: HStackJustify;
class?: string;
}
const alignClasses: Record<HStackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
const justifyClasses: Record<HStackJustify, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function HStack({
gap = 4,
align = 'center',
justify = 'start',
class: className,
children,
}: PropsWithChildren<HStackProps>) {
const classes = cn('flex flex-row', getGapClass(gap), alignClasses[align], justifyClasses[justify], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export interface PageContainerProps {
maxWidth?: 'full' | '7xl';
}
export function PageContainer({maxWidth = '7xl', children}: PropsWithChildren<PageContainerProps>) {
const widthClass = maxWidth === 'full' ? 'w-full' : 'max-w-7xl';
return <div class={`mx-auto ${widthClass} space-y-6`}>{children}</div>;
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface PageHeaderProps {
title: string;
description?: string;
actions?: Child;
}
export function PageHeader({title, description, actions, children}: PropsWithChildren<PageHeaderProps>) {
return (
<div>
<div class={cn('flex items-start justify-between', description ? 'mb-2' : 'mb-0')}>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<h1 class="font-bold text-3xl text-gray-900">{title}</h1>
{description && <p class="text-base text-gray-600">{description}</p>}
</div>
{actions && <div class="ml-4 flex-shrink-0">{actions}</div>}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type PageLayoutMaxWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl';
export interface PageLayoutProps {
maxWidth?: PageLayoutMaxWidth;
padding?: boolean;
}
const maxWidthClasses: Record<PageLayoutMaxWidth, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
};
export function PageLayout({maxWidth = '7xl', padding = false, children}: PropsWithChildren<PageLayoutProps>) {
const classes = cn('mx-auto w-full', maxWidthClasses[maxWidth], padding && 'px-4 sm:px-6 lg:px-8');
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {PageContainer} from '@fluxer/admin/src/components/ui/Layout/PageContainer';
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface SearchListPageLayoutProps {
title: string;
description?: string;
actions?: Child;
searchForm: Child;
}
export function SearchListPageLayout({
title,
description,
actions,
searchForm,
children,
}: PropsWithChildren<SearchListPageLayoutProps>) {
return (
<PageContainer>
<PageHeader title={title} description={description} actions={actions} />
{searchForm}
{children}
</PageContainer>
);
}

View File

@@ -0,0 +1,37 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export interface TwoColumnGridProps {
gap?: 'sm' | 'md' | 'lg';
}
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
export function TwoColumnGrid({gap = 'md', children}: PropsWithChildren<TwoColumnGridProps>) {
return <div class={`grid grid-cols-1 md:grid-cols-2 ${gapClasses[gap]}`}>{children}</div>;
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type VStackAlign = 'start' | 'center' | 'end' | 'stretch';
export interface VStackProps {
gap?: number | string;
align?: VStackAlign;
class?: string;
}
const alignClasses: Record<VStackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
};
function getGapClass(gap: number | string): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gap;
}
export function VStack({gap = 4, align = 'stretch', class: className, children}: PropsWithChildren<VStackProps>) {
const classes = cn('flex flex-col', getGapClass(gap), alignClasses[align], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
interface MetadataRowProps {
label: string;
value: string | number | any;
class?: string;
}
export function MetadataRow({label, value, class: className}: MetadataRowProps) {
return (
<div class={cn('flex gap-2', className)}>
<span class="text-neutral-500 text-sm">{label}:</span>
<span class="text-neutral-900 text-sm">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,39 @@
/*
* 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 {Child, FC} from 'hono/jsx';
interface NavLinkProps {
href: string;
children: Child;
class?: string;
}
export const NavLink: FC<NavLinkProps> = ({href, children, class: className = ''}) => {
const baseClass = `label rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50 ${className}`;
return (
<a href={href} class={baseClass}>
{children}
</a>
);
};

View File

@@ -0,0 +1,40 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface PillProps {
tone?: 'neutral' | 'success' | 'danger' | 'warning' | 'info';
}
export function Pill({tone = 'neutral', children}: PropsWithChildren<PillProps>) {
const classes = clsx('inline-block rounded-lg border px-3 py-2 font-medium text-sm shadow-sm', {
'border-gray-200 bg-neutral-100 text-neutral-700': tone === 'neutral',
'border-green-200 bg-green-50 text-green-700': tone === 'success',
'border-red-200 bg-red-50 text-red-700': tone === 'danger',
'border-neutral-200 bg-neutral-50 text-neutral-700': tone === 'warning',
'border-blue-200 bg-blue-50 text-blue-700': tone === 'info',
});
return <span class={classes}>{children}</span>;
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import type {Child, FC} from 'hono/jsx';
interface ResourceLinkProps {
config: Config;
resourceType: 'user' | 'guild';
resourceId: string;
children: Child;
size?: 'sm' | 'md';
class?: string;
}
export const ResourceLink: FC<ResourceLinkProps> = ({
config,
resourceType,
resourceId,
children,
size = 'sm',
class: className = '',
}) => {
const sizeClass = size === 'sm' ? 'text-sm' : '';
const baseClass = `text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 ${sizeClass} ${className}`;
const href = `${config.basePath}/${resourceType === 'user' ? 'users' : 'guilds'}/${resourceId}`;
return (
<a href={href} class={baseClass}>
{children}
</a>
);
};

View File

@@ -0,0 +1,109 @@
/*
* 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 {FC} from 'hono/jsx';
export type SelectSize = 'sm' | 'md' | 'lg';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps {
name: string;
id?: string;
value?: string;
options: Array<SelectOption>;
placeholder?: string;
disabled?: boolean;
required?: boolean;
size?: SelectSize;
error?: boolean;
fullWidth?: boolean;
class?: string;
}
const sizeClasses: Record<SelectSize, string> = {
sm: 'h-8 px-3 py-1.5 text-sm',
md: 'h-9 px-3 py-2 text-sm',
lg: 'h-10 px-4 py-2.5 text-base',
};
export const Select: FC<SelectProps> = ({
name,
id,
value,
options,
placeholder,
disabled = false,
required = false,
size = 'sm',
error = false,
fullWidth = true,
class: extraClass,
}) => {
const baseClasses = [
'rounded-lg',
'border',
'border-neutral-300',
'bg-white',
'text-neutral-900',
'transition-all',
'focus:outline-none',
'focus:border-brand-primary',
'focus:ring-2',
'focus:ring-brand-primary/20',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
'appearance-none',
"bg-[url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E\")]",
'bg-[length:1.1em_1.1em]',
'bg-[position:right_0.75rem_center]',
'bg-no-repeat',
'pr-10',
];
const stateClasses = [
error ? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20' : '',
fullWidth ? 'w-full' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], ...stateClasses, extraClass || ''].filter(Boolean).join(' ');
return (
<select name={name} id={id} disabled={disabled} required={required} class={classes}>
{placeholder && (
<option value="" disabled selected={!value}>
{placeholder}
</option>
)}
{options.map((option) => (
<option value={option.value} selected={option.value === value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
};

View File

@@ -0,0 +1,59 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {PropsWithChildren} from 'hono/jsx';
export type StackGap = 'sm' | 'md' | 'lg' | number;
export type StackAlign = 'start' | 'center' | 'end' | 'stretch';
export interface StackProps {
gap?: StackGap;
align?: StackAlign;
class?: string;
}
const gapClasses: Record<string, string> = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
};
const alignClasses: Record<StackAlign, string> = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
};
function getGapClass(gap: StackGap): string {
if (typeof gap === 'number') {
return `gap-${gap}`;
}
return gapClasses[gap] ?? gapClasses.md;
}
export function Stack({gap = 'md', align = 'stretch', class: className, children}: PropsWithChildren<StackProps>) {
const classes = cn('flex flex-col', getGapClass(gap), alignClasses[align], className);
return <div class={classes}>{children}</div>;
}

View File

@@ -0,0 +1,58 @@
/*
* 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 {BadgeProps} from '@fluxer/admin/src/components/ui/Badge';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
export interface StatusBadgeProps {
status: 'active' | 'inactive' | 'pending' | 'approved' | 'rejected' | 'banned';
size?: BadgeProps['size'];
}
const statusVariantMap: Record<StatusBadgeProps['status'], BadgeProps['variant']> = {
active: 'success',
inactive: 'neutral',
pending: 'warning',
approved: 'success',
rejected: 'danger',
banned: 'danger',
};
const statusLabelMap: Record<StatusBadgeProps['status'], string> = {
active: 'Active',
inactive: 'Inactive',
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
banned: 'Banned',
};
export function StatusBadge({status, size}: StatusBadgeProps) {
const variant = statusVariantMap[status];
const label = statusLabelMap[status];
return (
<Badge variant={variant} size={size}>
{label}
</Badge>
);
}

View File

@@ -0,0 +1,36 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableProps {
className?: string;
}
export function Table({children, className}: PropsWithChildren<TableProps>) {
return (
<table class={clsx('min-w-full border-collapse rounded-lg border border-neutral-200 bg-white', className)}>
{children}
</table>
);
}

View File

@@ -0,0 +1,27 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export function TableBody({children}: PropsWithChildren) {
return <tbody class="divide-y divide-neutral-200">{children}</tbody>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableCellProps {
align?: 'left' | 'center' | 'right';
variant?: 'default' | 'header';
colSpan?: number;
}
export function TableCell({align = 'left', variant = 'default', colSpan, children}: PropsWithChildren<TableCellProps>) {
const isHeader = variant === 'header';
return (
<td
class={clsx(
'px-6 py-4 text-sm',
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
isHeader ? 'bg-neutral-50 font-bold text-neutral-900' : 'text-neutral-900',
)}
colspan={colSpan}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,27 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export function TableContainer({children}: PropsWithChildren) {
return <div class="overflow-x-auto">{children}</div>;
}

View File

@@ -0,0 +1,27 @@
/*
* 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 {PropsWithChildren} from 'hono/jsx';
export function TableHeader({children}: PropsWithChildren) {
return <thead class="bg-neutral-50">{children}</thead>;
}

View File

@@ -0,0 +1,43 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableHeaderCellProps {
align?: 'left' | 'center' | 'right';
}
export function TableHeaderCell({align = 'left', children}: PropsWithChildren<TableHeaderCellProps>) {
return (
<th
class={clsx(
'whitespace-nowrap px-6 py-3 font-medium text-neutral-500 text-xs uppercase tracking-wider',
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
)}
>
{children}
</th>
);
}

View File

@@ -0,0 +1,49 @@
/*
* 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 clsx from 'clsx';
import type {PropsWithChildren} from 'hono/jsx';
export interface TableRowProps {
hover?: boolean;
selected?: boolean;
clickable?: boolean;
}
export function TableRow({
hover = true,
selected = false,
clickable = false,
children,
}: PropsWithChildren<TableRowProps>) {
return (
<tr
class={clsx(
hover && 'transition-colors hover:bg-neutral-50',
selected && 'bg-blue-50',
clickable && 'cursor-pointer',
)}
>
{children}
</tr>
);
}

View File

@@ -0,0 +1,48 @@
/*
* 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 {Child, FC} from 'hono/jsx';
interface TextLinkProps {
href: string;
children: Child;
external?: boolean;
mono?: boolean;
class?: string;
}
export const TextLink: FC<TextLinkProps> = ({
href,
children,
external = false,
mono = false,
class: className = '',
}) => {
const baseClass = `text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 ${mono ? 'font-mono' : ''} ${className}`;
const externalProps = external ? {target: '_blank', rel: 'noopener noreferrer'} : {};
return (
<a href={href} class={baseClass} {...externalProps}>
{children}
</a>
);
};

View File

@@ -0,0 +1,121 @@
/*
* 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 {FC} from 'hono/jsx';
export type TextareaSize = 'sm' | 'md' | 'lg';
export type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both';
export interface TextareaProps {
name: string;
id?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
readonly?: boolean;
rows?: number;
size?: TextareaSize;
error?: boolean;
fullWidth?: boolean;
maxlength?: number;
minlength?: number;
resize?: TextareaResize;
class?: string;
}
const sizeClasses: Record<TextareaSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-3 py-2 text-sm',
lg: 'px-4 py-3 text-base',
};
const resizeClasses: Record<TextareaResize, string> = {
none: 'resize-none',
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize',
};
function toTextareaId(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
export const Textarea: FC<TextareaProps> = ({
name,
id,
value,
placeholder,
disabled = false,
required = false,
readonly = false,
rows = 4,
size = 'sm',
error = false,
fullWidth = true,
maxlength,
minlength,
resize = 'vertical',
class: extraClass,
}) => {
const textareaId = id ?? toTextareaId(name);
const baseClasses = [
'rounded-lg',
'border',
'bg-white',
'text-neutral-900',
'placeholder:text-neutral-400',
'transition-all',
'focus:outline-none',
'focus:ring-2',
'focus:ring-brand-primary/20',
'disabled:opacity-50',
'disabled:cursor-not-allowed',
'disabled:bg-neutral-50',
];
const stateClasses = [
error ? 'border-red-500 focus:border-red-500' : 'border-neutral-300 focus:border-brand-primary',
fullWidth ? 'w-full' : '',
].filter(Boolean);
const classes = [...baseClasses, sizeClasses[size], resizeClasses[resize], ...stateClasses, extraClass || '']
.filter(Boolean)
.join(' ');
return (
<textarea
name={name}
id={textareaId}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
rows={rows}
maxlength={maxlength}
minlength={minlength}
class={classes}
>
{value}
</textarea>
);
};

View File

@@ -0,0 +1,158 @@
/*
* 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 {cn} from '@fluxer/admin/src/utils/ClassNames';
import type {Child, PropsWithChildren} from 'hono/jsx';
export interface HeadingProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
class?: string;
}
const headingSizes: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {
1: 'text-3xl font-bold',
2: 'text-2xl font-semibold',
3: 'text-xl font-semibold',
4: 'text-lg font-semibold',
5: 'text-base font-semibold',
6: 'text-sm font-semibold',
};
const customSizes: Record<NonNullable<HeadingProps['size']>, string> = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
};
export function Heading(props: PropsWithChildren<HeadingProps>) {
const {level, size, children, class: className} = props;
const classes = cn('text-gray-900 tracking-tight', size ? customSizes[size] : headingSizes[level], className);
if (level === 1) return <h1 class={classes}>{children}</h1>;
if (level === 2) return <h2 class={classes}>{children}</h2>;
if (level === 3) return <h3 class={classes}>{children}</h3>;
if (level === 4) return <h4 class={classes}>{children}</h4>;
if (level === 5) return <h5 class={classes}>{children}</h5>;
return <h6 class={classes}>{children}</h6>;
}
export interface TextProps {
size?: 'xs' | 'sm' | 'base' | 'lg';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
color?: 'default' | 'muted' | 'primary' | 'danger' | 'success';
class?: string;
}
const textSizes: Record<NonNullable<TextProps['size']>, string> = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
};
const textWeights: Record<NonNullable<TextProps['weight']>, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const textColors: Record<NonNullable<TextProps['color']>, string> = {
default: 'text-gray-900',
muted: 'text-neutral-500',
primary: 'text-brand-primary',
danger: 'text-red-600',
success: 'text-green-600',
};
export function Text(props: PropsWithChildren<TextProps>) {
const {size = 'base', weight = 'normal', color = 'default', children, class: className} = props;
const classes = cn(textSizes[size], textWeights[weight], textColors[color], className);
return <p class={classes}>{children}</p>;
}
export interface LabelProps {
htmlFor?: string;
required?: boolean;
class?: string;
}
export function Label(props: PropsWithChildren<LabelProps>) {
const {htmlFor, required = false, children, class: className} = props;
const classes = cn('block text-xs font-semibold uppercase tracking-wide text-neutral-500', className);
return (
<label for={htmlFor} class={classes}>
{children}
{required && <span class="ml-1 text-red-600">*</span>}
</label>
);
}
export interface CaptionProps {
variant?: 'default' | 'error' | 'success';
class?: string;
}
const captionVariants: Record<NonNullable<CaptionProps['variant']>, string> = {
default: 'text-gray-500',
error: 'text-red-600',
success: 'text-green-600',
};
export function Caption(props: PropsWithChildren<CaptionProps>) {
const {variant = 'default', children, class: className} = props;
const classes = cn('text-xs', captionVariants[variant], className);
return <p class={classes}>{children}</p>;
}
export interface SectionHeadingProps {
actions?: Child;
class?: string;
}
export function SectionHeading(props: PropsWithChildren<SectionHeadingProps>) {
const {actions, children, class: className} = props;
if (actions) {
return (
<div class={cn('mb-4 flex items-center justify-between', className)}>
<h2 class="font-semibold text-gray-900 text-xl">{children}</h2>
<div class="flex items-center gap-2">{actions}</div>
</div>
);
}
return <h2 class={cn('mb-4 font-semibold text-gray-900 text-xl', className)}>{children}</h2>;
}

View File

@@ -0,0 +1,47 @@
/*
* 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/>.
*/
export interface PaginationUrlParams {
q?: string;
status?: number | string;
type?: number | string;
category?: string;
target_type?: string;
target_id?: string;
admin_user_id?: string;
action?: string;
sort?: string;
limit?: number;
tab?: string;
}
export function buildPaginationUrl(page: number, params: PaginationUrlParams = {}): string {
const urlParams = new URLSearchParams();
urlParams.set('page', String(page));
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || String(value).trim() === '') {
return;
}
urlParams.set(key, String(value));
});
const queryString = urlParams.toString();
return queryString ? `?${queryString}` : '';
}

View File

@@ -0,0 +1,105 @@
/*
* 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 * as usersApi from '@fluxer/admin/src/api/Users';
import {createAdminOAuth2Client} from '@fluxer/admin/src/Oauth2';
import {parseSession} from '@fluxer/admin/src/Session';
import type {AppContext, Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {type Flash, serializeFlash} from '@fluxer/hono/src/Flash';
import {parseFlashFromCookie} from '@fluxer/ui/src/components/Flash';
import type {Next} from 'hono';
import {deleteCookie, getCookie, setCookie} from 'hono/cookie';
export async function getValidSession(c: AppContext, configOverride?: Config): Promise<Session | null> {
const config = configOverride ?? c.get('config');
const sessionCookie = getCookie(c, 'session');
if (!sessionCookie) return null;
const session = parseSession(sessionCookie, config.secretKeyBase);
if (!session) return null;
return session;
}
export function redirectToAuthorize(c: AppContext, config: Config): Response {
const oauth2Client = createAdminOAuth2Client(config);
const state = oauth2Client.generateState();
setCookie(c, 'oauth_state', state, {
httpOnly: true,
secure: config.env === 'production',
sameSite: 'Lax',
maxAge: 300,
path: '/',
});
return c.redirect(oauth2Client.createAuthorizationUrl(state));
}
export function redirectToLoginAndClearSession(c: AppContext, config: Config): Response {
deleteCookie(c, 'session', {path: '/'});
return c.redirect(`${config.basePath}/login`);
}
export function getFlash(c: AppContext): Flash | undefined {
const flashCookie = getCookie(c, 'flash');
if (flashCookie) {
deleteCookie(c, 'flash', {path: '/'});
return parseFlashFromCookie(flashCookie);
}
return undefined;
}
export function redirectWithFlash(c: AppContext, url: string, flash: Flash): Response {
setCookie(c, 'flash', serializeFlash(flash), {
httpOnly: true,
secure: c.get('config').env === 'production',
sameSite: 'Lax',
maxAge: 60,
path: '/',
});
return c.redirect(url);
}
export function createRequireAuth(config: Config, assetVersion: string) {
return async (c: AppContext, next: Next): Promise<Response | undefined> => {
const session = await getValidSession(c, config);
if (!session) {
return redirectToAuthorize(c, config);
}
const adminResult = await usersApi.getCurrentAdmin(config, session);
if (!adminResult.ok) {
if (adminResult.error.type === 'unauthorized') {
return redirectToLoginAndClearSession(c, config);
}
}
c.set('config', config);
c.set('session', session);
c.set('currentAdmin', adminResult.ok ? (adminResult.data ?? undefined) : undefined);
c.set('assetVersion', assetVersion);
await next();
return;
};
}

View File

@@ -0,0 +1,54 @@
/*
* 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 {AppContext} from '@fluxer/admin/src/types/App';
import {CSRF_FORM_FIELD} from '@fluxer/constants/src/Cookies';
import {type CsrfProtection, createCsrfProtection} from '@fluxer/hono/src/security/CsrfProtection';
import type {Next} from 'hono';
let csrfProtection: CsrfProtection | null = null;
export function initializeCsrf(secretKeyBase: string, secureCookie: boolean): void {
csrfProtection = createCsrfProtection({
secretKeyBase,
secureCookie,
ignoredPathSuffixes: ['/oauth2_callback', '/auth/start'],
});
}
function getCsrfProtectionOrThrow(): CsrfProtection {
if (!csrfProtection) {
throw new Error('CSRF not initialized');
}
return csrfProtection;
}
export function getCsrfToken(c: AppContext): string {
return getCsrfProtectionOrThrow().getToken(c);
}
export async function csrfMiddleware(c: AppContext, next: Next): Promise<Response | undefined> {
const response = await getCsrfProtectionOrThrow().middleware(c, next);
return response ?? undefined;
}
export const CSRF_FORM_FIELD_NAME = CSRF_FORM_FIELD;

View File

@@ -0,0 +1,106 @@
/*
* 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 {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
import type {HttpStatusCode} from '@fluxer/constants/src/HttpConstants';
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {captureException} from '@fluxer/sentry/src/Sentry';
import {ErrorPage} from '@fluxer/ui/src/pages/ErrorPage';
import type {Context, ErrorHandler} from 'hono';
const KNOWN_HTTP_STATUS_CODES: Array<HttpStatusCode> = Object.values(HttpStatus);
export function createAdminErrorHandler(logger: LoggerInterface, includeStack: boolean): ErrorHandler {
return createErrorHandler({
includeStack,
logError: (error, c) => {
const isExpectedError = error instanceof Error && 'isExpected' in error && error.isExpected;
if (!(error instanceof FluxerError || isExpectedError)) {
captureException(error);
}
logger.error(
{
error: error.message,
stack: error.stack,
path: c.req.path,
method: c.req.method,
},
'Request error',
);
},
customHandler: (error, c) => {
const status = getStatus(error) ?? 500;
if (status === 404) {
return renderNotFound(c);
}
return renderError(c, status);
},
});
}
function getStatus(error: Error): number | null {
const statusValue = Reflect.get(error, 'status');
return typeof statusValue === 'number' ? statusValue : null;
}
function renderNotFound(c: Context): Response | Promise<Response> {
c.status(404);
return c.html(
<ErrorPage
statusCode={404}
title="Page not found"
description="The page you are looking for does not exist or has been moved."
staticCdnEndpoint={CdnEndpoints.STATIC}
homeUrl="/admin"
homeLabel="Go to admin"
/>,
);
}
function renderError(c: Context, status: number): Response | Promise<Response> {
const statusCode = isHttpStatusCode(status) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
c.status(statusCode);
return c.html(
<ErrorPage
statusCode={statusCode}
title="Something went wrong"
description="An unexpected error occurred. Please try again later."
staticCdnEndpoint={CdnEndpoints.STATIC}
homeUrl="/admin"
homeLabel="Go to admin"
/>,
);
}
function isHttpStatusCode(value: number): value is HttpStatusCode {
for (const statusCode of KNOWN_HTTP_STATUS_CODES) {
if (statusCode === value) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,36 @@
/*
* 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/>.
*/
export interface NavigationContext {
selfHosted?: boolean;
inspectedVoiceRegionId?: string;
}
export interface NavItem {
title: string;
path: string;
activeKey: string;
requiredAcls: Array<string>;
hostedOnly?: boolean;
}
export interface NavSection {
title: string;
items: Array<NavItem>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More