refactor progress
This commit is contained in:
32
packages/admin/src/AccessControlList.tsx
Normal file
32
packages/admin/src/AccessControlList.tsx
Normal 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));
|
||||
}
|
||||
225
packages/admin/src/AdminPackageConstants.tsx
Normal file
225
packages/admin/src/AdminPackageConstants.tsx
Normal 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
251
packages/admin/src/App.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {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
28
packages/admin/src/HonoJsx.d.ts
vendored
Normal 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 {}
|
||||
}
|
||||
}
|
||||
246
packages/admin/src/Navigation.tsx
Normal file
246
packages/admin/src/Navigation.tsx
Normal 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;
|
||||
}
|
||||
45
packages/admin/src/Oauth2.tsx
Normal file
45
packages/admin/src/Oauth2.tsx
Normal 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);
|
||||
}
|
||||
24
packages/admin/src/PublicDir.tsx
Normal file
24
packages/admin/src/PublicDir.tsx
Normal 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));
|
||||
}
|
||||
32
packages/admin/src/SelfHostedOverride.tsx
Normal file
32
packages/admin/src/SelfHostedOverride.tsx
Normal 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;
|
||||
}
|
||||
48
packages/admin/src/Session.tsx
Normal file
48
packages/admin/src/Session.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
49
packages/admin/src/api/AdminApiKeys.tsx
Normal file
49
packages/admin/src/api/AdminApiKeys.tsx
Normal 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}`);
|
||||
}
|
||||
86
packages/admin/src/api/Archives.tsx
Normal file
86
packages/admin/src/api/Archives.tsx
Normal 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`);
|
||||
}
|
||||
36
packages/admin/src/api/Assets.tsx
Normal file
36
packages/admin/src/api/Assets.tsx
Normal 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);
|
||||
}
|
||||
55
packages/admin/src/api/Audit.tsx
Normal file
55
packages/admin/src/api/Audit.tsx
Normal 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);
|
||||
}
|
||||
147
packages/admin/src/api/Bans.tsx
Normal file
147
packages/admin/src/api/Bans.tsx
Normal 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});
|
||||
}
|
||||
107
packages/admin/src/api/Bulk.tsx
Normal file
107
packages/admin/src/api/Bulk.tsx
Normal 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);
|
||||
}
|
||||
216
packages/admin/src/api/Client.tsx
Normal file
216
packages/admin/src/api/Client.tsx
Normal 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);
|
||||
}
|
||||
43
packages/admin/src/api/Codes.tsx
Normal file
43
packages/admin/src/api/Codes.tsx
Normal 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;
|
||||
}
|
||||
77
packages/admin/src/api/Discovery.tsx
Normal file
77
packages/admin/src/api/Discovery.tsx
Normal 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});
|
||||
}
|
||||
325
packages/admin/src/api/Errors.tsx
Normal file
325
packages/admin/src/api/Errors.tsx
Normal 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}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
44
packages/admin/src/api/GuildAssets.tsx
Normal file
44
packages/admin/src/api/GuildAssets.tsx
Normal 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`);
|
||||
}
|
||||
212
packages/admin/src/api/Guilds.tsx
Normal file
212
packages/admin/src/api/Guilds.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {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});
|
||||
}
|
||||
79
packages/admin/src/api/InstanceConfig.tsx
Normal file
79
packages/admin/src/api/InstanceConfig.tsx
Normal 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});
|
||||
}
|
||||
37
packages/admin/src/api/JsonTypes.tsx
Normal file
37
packages/admin/src/api/JsonTypes.tsx
Normal 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);
|
||||
}
|
||||
74
packages/admin/src/api/LimitConfig.tsx
Normal file
74
packages/admin/src/api/LimitConfig.tsx
Normal 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;
|
||||
}
|
||||
131
packages/admin/src/api/Messages.tsx
Normal file
131
packages/admin/src/api/Messages.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
94
packages/admin/src/api/Reports.tsx
Normal file
94
packages/admin/src/api/Reports.tsx
Normal 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}`);
|
||||
}
|
||||
62
packages/admin/src/api/Search.tsx
Normal file
62
packages/admin/src/api/Search.tsx
Normal 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});
|
||||
}
|
||||
53
packages/admin/src/api/System.tsx
Normal file
53
packages/admin/src/api/System.tsx
Normal 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');
|
||||
}
|
||||
68
packages/admin/src/api/SystemDm.tsx
Normal file
68
packages/admin/src/api/SystemDm.tsx
Normal 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`, {});
|
||||
}
|
||||
419
packages/admin/src/api/Users.tsx
Normal file
419
packages/admin/src/api/Users.tsx
Normal 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);
|
||||
}
|
||||
87
packages/admin/src/api/VisionarySlots.tsx
Normal file
87
packages/admin/src/api/VisionarySlots.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {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,
|
||||
});
|
||||
}
|
||||
217
packages/admin/src/api/Voice.tsx
Normal file
217
packages/admin/src/api/Voice.tsx
Normal 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);
|
||||
}
|
||||
51
packages/admin/src/components/ErrorDisplay.tsx
Normal file
51
packages/admin/src/components/ErrorDisplay.tsx
Normal 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>
|
||||
);
|
||||
91
packages/admin/src/components/Icons.tsx
Normal file
91
packages/admin/src/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
packages/admin/src/components/Layout.tsx
Normal file
388
packages/admin/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
packages/admin/src/components/MessageList.tsx
Normal file
204
packages/admin/src/components/MessageList.tsx
Normal 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'));
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
124
packages/admin/src/components/UserProfileBadges.tsx
Normal file
124
packages/admin/src/components/UserProfileBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
packages/admin/src/components/VoiceComponents.tsx
Normal file
103
packages/admin/src/components/VoiceComponents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
packages/admin/src/components/ui/Alert.tsx
Normal file
47
packages/admin/src/components/ui/Alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/admin/src/components/ui/Badge.tsx
Normal file
48
packages/admin/src/components/ui/Badge.tsx
Normal 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>;
|
||||
}
|
||||
53
packages/admin/src/components/ui/Card.tsx
Normal file
53
packages/admin/src/components/ui/Card.tsx
Normal 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>;
|
||||
}
|
||||
34
packages/admin/src/components/ui/CardBody.tsx
Normal file
34
packages/admin/src/components/ui/CardBody.tsx
Normal 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>;
|
||||
}
|
||||
34
packages/admin/src/components/ui/CardFooter.tsx
Normal file
34
packages/admin/src/components/ui/CardFooter.tsx
Normal 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>;
|
||||
}
|
||||
34
packages/admin/src/components/ui/CardHeader.tsx
Normal file
34
packages/admin/src/components/ui/CardHeader.tsx
Normal 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>;
|
||||
}
|
||||
49
packages/admin/src/components/ui/Chip.tsx
Normal file
49
packages/admin/src/components/ui/Chip.tsx
Normal 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>;
|
||||
}
|
||||
63
packages/admin/src/components/ui/CodeBlock.tsx
Normal file
63
packages/admin/src/components/ui/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
packages/admin/src/components/ui/Container.tsx
Normal file
44
packages/admin/src/components/ui/Container.tsx
Normal 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>;
|
||||
}
|
||||
40
packages/admin/src/components/ui/EmptyState.tsx
Normal file
40
packages/admin/src/components/ui/EmptyState.tsx
Normal 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>;
|
||||
}
|
||||
49
packages/admin/src/components/ui/Form/FormActions.tsx
Normal file
49
packages/admin/src/components/ui/Form/FormActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
packages/admin/src/components/ui/Form/FormCard.tsx
Normal file
47
packages/admin/src/components/ui/Form/FormCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
packages/admin/src/components/ui/Form/FormFieldGroup.tsx
Normal file
78
packages/admin/src/components/ui/Form/FormFieldGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
packages/admin/src/components/ui/Form/FormRow.tsx
Normal file
51
packages/admin/src/components/ui/Form/FormRow.tsx
Normal 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>;
|
||||
}
|
||||
44
packages/admin/src/components/ui/Form/FormSection.tsx
Normal file
44
packages/admin/src/components/ui/Form/FormSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
packages/admin/src/components/ui/Grid.tsx
Normal file
52
packages/admin/src/components/ui/Grid.tsx
Normal 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>;
|
||||
}
|
||||
38
packages/admin/src/components/ui/InlineStack.tsx
Normal file
38
packages/admin/src/components/ui/InlineStack.tsx
Normal 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>;
|
||||
}
|
||||
159
packages/admin/src/components/ui/Input.tsx
Normal file
159
packages/admin/src/components/ui/Input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
87
packages/admin/src/components/ui/Layout/Box.tsx
Normal file
87
packages/admin/src/components/ui/Layout/Box.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {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>;
|
||||
}
|
||||
39
packages/admin/src/components/ui/Layout/DetailPageLayout.tsx
Normal file
39
packages/admin/src/components/ui/Layout/DetailPageLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
packages/admin/src/components/ui/Layout/Flex.tsx
Normal file
94
packages/admin/src/components/ui/Layout/Flex.tsx
Normal 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>;
|
||||
}
|
||||
44
packages/admin/src/components/ui/Layout/FormGrid.tsx
Normal file
44
packages/admin/src/components/ui/Layout/FormGrid.tsx
Normal 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>;
|
||||
}
|
||||
70
packages/admin/src/components/ui/Layout/HStack.tsx
Normal file
70
packages/admin/src/components/ui/Layout/HStack.tsx
Normal 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>;
|
||||
}
|
||||
33
packages/admin/src/components/ui/Layout/PageContainer.tsx
Normal file
33
packages/admin/src/components/ui/Layout/PageContainer.tsx
Normal 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>;
|
||||
}
|
||||
45
packages/admin/src/components/ui/Layout/PageHeader.tsx
Normal file
45
packages/admin/src/components/ui/Layout/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
packages/admin/src/components/ui/Layout/PageLayout.tsx
Normal file
50
packages/admin/src/components/ui/Layout/PageLayout.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
37
packages/admin/src/components/ui/Layout/TwoColumnGrid.tsx
Normal file
37
packages/admin/src/components/ui/Layout/TwoColumnGrid.tsx
Normal 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>;
|
||||
}
|
||||
52
packages/admin/src/components/ui/Layout/VStack.tsx
Normal file
52
packages/admin/src/components/ui/Layout/VStack.tsx
Normal 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>;
|
||||
}
|
||||
38
packages/admin/src/components/ui/MetadataRow.tsx
Normal file
38
packages/admin/src/components/ui/MetadataRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
packages/admin/src/components/ui/NavLink.tsx
Normal file
39
packages/admin/src/components/ui/NavLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
packages/admin/src/components/ui/Pill.tsx
Normal file
40
packages/admin/src/components/ui/Pill.tsx
Normal 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>;
|
||||
}
|
||||
52
packages/admin/src/components/ui/ResourceLink.tsx
Normal file
52
packages/admin/src/components/ui/ResourceLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
packages/admin/src/components/ui/Select.tsx
Normal file
109
packages/admin/src/components/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
packages/admin/src/components/ui/Stack.tsx
Normal file
59
packages/admin/src/components/ui/Stack.tsx
Normal 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>;
|
||||
}
|
||||
58
packages/admin/src/components/ui/StatusBadge.tsx
Normal file
58
packages/admin/src/components/ui/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/admin/src/components/ui/Table.tsx
Normal file
36
packages/admin/src/components/ui/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
packages/admin/src/components/ui/TableBody.tsx
Normal file
27
packages/admin/src/components/ui/TableBody.tsx
Normal 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>;
|
||||
}
|
||||
49
packages/admin/src/components/ui/TableCell.tsx
Normal file
49
packages/admin/src/components/ui/TableCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
packages/admin/src/components/ui/TableContainer.tsx
Normal file
27
packages/admin/src/components/ui/TableContainer.tsx
Normal 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>;
|
||||
}
|
||||
27
packages/admin/src/components/ui/TableHeader.tsx
Normal file
27
packages/admin/src/components/ui/TableHeader.tsx
Normal 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>;
|
||||
}
|
||||
43
packages/admin/src/components/ui/TableHeaderCell.tsx
Normal file
43
packages/admin/src/components/ui/TableHeaderCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
packages/admin/src/components/ui/TableRow.tsx
Normal file
49
packages/admin/src/components/ui/TableRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/admin/src/components/ui/TextLink.tsx
Normal file
48
packages/admin/src/components/ui/TextLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
121
packages/admin/src/components/ui/Textarea.tsx
Normal file
121
packages/admin/src/components/ui/Textarea.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
packages/admin/src/components/ui/Typography.tsx
Normal file
158
packages/admin/src/components/ui/Typography.tsx
Normal 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>;
|
||||
}
|
||||
47
packages/admin/src/hooks/usePaginationUrl.ts
Normal file
47
packages/admin/src/hooks/usePaginationUrl.ts
Normal 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}` : '';
|
||||
}
|
||||
105
packages/admin/src/middleware/Auth.tsx
Normal file
105
packages/admin/src/middleware/Auth.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
54
packages/admin/src/middleware/Csrf.tsx
Normal file
54
packages/admin/src/middleware/Csrf.tsx
Normal 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;
|
||||
106
packages/admin/src/middleware/ErrorHandler.tsx
Normal file
106
packages/admin/src/middleware/ErrorHandler.tsx
Normal 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;
|
||||
}
|
||||
36
packages/admin/src/navigation/NavigationTypes.tsx
Normal file
36
packages/admin/src/navigation/NavigationTypes.tsx
Normal 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>;
|
||||
}
|
||||
356
packages/admin/src/pages/AdminApiKeysPage.tsx
Normal file
356
packages/admin/src/pages/AdminApiKeysPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
|
||||
import {ALL_ACLS} from '@fluxer/admin/src/AdminPackageConstants';
|
||||
import {listApiKeys} from '@fluxer/admin/src/api/AdminApiKeys';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
|
||||
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
|
||||
import {Input} from '@fluxer/admin/src/components/ui/Input';
|
||||
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
|
||||
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Caption, Heading, Label, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {CreateAdminApiKeyResponse, ListAdminApiKeyResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Alert} from '@fluxer/ui/src/components/Alert';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
|
||||
import {FlashMessage} from '@fluxer/ui/src/components/Flash';
|
||||
import {Checkbox} from '@fluxer/ui/src/components/Form';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
export interface AdminApiKeysPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
assetVersion: string;
|
||||
createdKey: CreateAdminApiKeyResponse | undefined;
|
||||
flashAfterAction: Flash | undefined;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
export async function AdminApiKeysPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
assetVersion,
|
||||
createdKey,
|
||||
flashAfterAction,
|
||||
csrfToken,
|
||||
}: AdminApiKeysPageProps) {
|
||||
const adminAcls = currentAdmin?.acls ?? [];
|
||||
const hasPermissionToManage = hasPermission(adminAcls, AdminACLs.ADMIN_API_KEY_MANAGE);
|
||||
|
||||
if (!hasPermissionToManage) {
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Admin API Keys"
|
||||
activePage="admin-api-keys"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<RenderAccessDenied />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeysResult = await listApiKeys(config, session);
|
||||
const apiKeys = apiKeysResult.ok ? apiKeysResult.data : undefined;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Admin API Keys"
|
||||
activePage="admin-api-keys"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<RenderKeyManagement
|
||||
config={config}
|
||||
createdKey={createdKey}
|
||||
flashAfterAction={flashAfterAction}
|
||||
apiKeys={apiKeys}
|
||||
adminAcls={adminAcls}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const RenderKeyManagement: FC<{
|
||||
config: Config;
|
||||
createdKey: CreateAdminApiKeyResponse | undefined;
|
||||
flashAfterAction: Flash | undefined;
|
||||
apiKeys: Array<ListAdminApiKeyResponse> | undefined;
|
||||
adminAcls: Array<string>;
|
||||
csrfToken: string;
|
||||
}> = ({config, createdKey, flashAfterAction, apiKeys, adminAcls, csrfToken}) => {
|
||||
return (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={6}>
|
||||
<RenderCreateForm config={config} createdKey={createdKey} adminAcls={adminAcls} csrfToken={csrfToken} />
|
||||
<RenderFlashAfterAction flash={flashAfterAction} />
|
||||
<RenderKeyListSection config={config} apiKeys={apiKeys} csrfToken={csrfToken} />
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderCreateForm: FC<{
|
||||
config: Config;
|
||||
createdKey: CreateAdminApiKeyResponse | undefined;
|
||||
adminAcls: Array<string>;
|
||||
csrfToken: string;
|
||||
}> = ({config, createdKey, adminAcls, csrfToken}) => {
|
||||
const createdKeyView = createdKey ? <RenderCreatedKey createdKey={createdKey} /> : null;
|
||||
const availableAcls = ALL_ACLS.filter((acl) => hasPermission(adminAcls, acl));
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={1} size="2xl">
|
||||
Create Admin API Key
|
||||
</Heading>
|
||||
{createdKeyView}
|
||||
<form id="create-key-form" method="post" action={`${config.basePath}/admin-api-keys?action=create`}>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="Key Name" helper="A descriptive name to help you identify this API key.">
|
||||
<Input id="api-key-name" type="text" name="name" required placeholder="Enter a descriptive name" />
|
||||
</FormFieldGroup>
|
||||
<VStack gap={3}>
|
||||
<VStack gap={1}>
|
||||
<Label>Permissions (ACLs)</Label>
|
||||
<Caption>
|
||||
Select the permissions to grant this API key. You can only grant permissions you have.
|
||||
</Caption>
|
||||
</VStack>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{availableAcls.map((acl) => (
|
||||
<Checkbox name="acls[]" value={acl} label={acl} checked />
|
||||
))}
|
||||
</div>
|
||||
</VStack>
|
||||
<Button type="submit" variant="primary">
|
||||
Create API Key
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderCreatedKey: FC<{createdKey: CreateAdminApiKeyResponse}> = ({createdKey}) => {
|
||||
return (
|
||||
<Alert variant="success">
|
||||
<VStack gap={2}>
|
||||
<HStack justify="between" align="center">
|
||||
<Heading level={3} size="lg">
|
||||
API Key Created Successfully
|
||||
</Heading>
|
||||
<Button type="button" variant="ghost" size="small" onclick="copyApiKey()">
|
||||
Copy Key
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text size="sm" color="success">
|
||||
Save this key now. You won't be able to see it again.
|
||||
</Text>
|
||||
<HStack gap={2} align="center" class="rounded-lg border border-green-200 bg-white p-3">
|
||||
<code id="api-key-value" class="flex-1 break-all font-mono text-green-900 text-sm">
|
||||
{createdKey.key}
|
||||
</code>
|
||||
<Caption variant="success">Key ID: {createdKey.key_id}</Caption>
|
||||
</HStack>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
function copyApiKey() {
|
||||
const keyElement = document.getElementById('api-key-value');
|
||||
const text = keyElement.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('API key copied to clipboard!');
|
||||
});
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderFlashAfterAction: FC<{flash: Flash | undefined}> = ({flash}) => {
|
||||
if (!flash) return null;
|
||||
return <FlashMessage flash={flash} />;
|
||||
};
|
||||
|
||||
const RenderKeyListSection: FC<{
|
||||
config: Config;
|
||||
apiKeys: Array<ListAdminApiKeyResponse> | undefined;
|
||||
csrfToken: string;
|
||||
}> = ({config, apiKeys, csrfToken}) => {
|
||||
if (apiKeys === undefined) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<EmptyState title="Loading API keys..." />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
return <RenderEmptyState />;
|
||||
}
|
||||
|
||||
return <RenderApiKeysList config={config} keys={apiKeys} csrfToken={csrfToken} />;
|
||||
};
|
||||
|
||||
const RenderApiKeysList: FC<{config: Config; keys: Array<ListAdminApiKeyResponse>; csrfToken: string}> = ({
|
||||
config,
|
||||
keys,
|
||||
csrfToken,
|
||||
}) => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={2} size="xl">
|
||||
Existing API Keys
|
||||
</Heading>
|
||||
<VStack gap={3}>
|
||||
{keys.map((key) => (
|
||||
<RenderApiKeyItem config={config} apiKey={key} csrfToken={csrfToken} />
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderApiKeyItem: FC<{config: Config; apiKey: ListAdminApiKeyResponse; csrfToken: string}> = ({
|
||||
config,
|
||||
apiKey,
|
||||
csrfToken,
|
||||
}) => {
|
||||
const formattedCreated = formatTimestamp(apiKey.created_at);
|
||||
const formattedLastUsed = apiKey.last_used_at ? formatTimestamp(apiKey.last_used_at) : 'Never used';
|
||||
const formattedExpires = apiKey.expires_at ? formatTimestamp(apiKey.expires_at) : 'Never expires';
|
||||
|
||||
return (
|
||||
<Card padding="sm" class="border border-neutral-200">
|
||||
<HStack justify="between" align="start">
|
||||
<VStack gap={1} class="flex-1">
|
||||
<Heading level={3} size="lg">
|
||||
{apiKey.name}
|
||||
</Heading>
|
||||
<Text size="sm" color="muted">
|
||||
Key ID: {apiKey.key_id}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
Created: {formattedCreated}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
Last used: {formattedLastUsed}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
Expires: {formattedExpires}
|
||||
</Text>
|
||||
<RenderAclsList acls={apiKey.acls} />
|
||||
</VStack>
|
||||
<form method="post" action={`${config.basePath}/admin-api-keys?action=revoke`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name="key_id" value={apiKey.key_id} />
|
||||
<Button type="submit" variant="danger" size="small">
|
||||
Revoke
|
||||
</Button>
|
||||
</form>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderAclsList: FC<{acls: Array<string>}> = ({acls}) => {
|
||||
if (acls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack gap={1} class="mt-2">
|
||||
<Text size="xs" weight="medium" color="muted">
|
||||
Permissions:
|
||||
</Text>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{acls.map((acl) => (
|
||||
<Badge size="sm" variant="neutral">
|
||||
{acl}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderEmptyState: FC = () => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={2} size="xl">
|
||||
Existing API Keys
|
||||
</Heading>
|
||||
<EmptyState title="No API keys found. Create one to get started." />
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderAccessDenied: FC = () => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<VStack gap={2}>
|
||||
<Heading level={1} size="2xl">
|
||||
Admin API Keys
|
||||
</Heading>
|
||||
<Text size="sm" color="muted">
|
||||
You do not have permission to manage admin API keys.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
return timestamp;
|
||||
}
|
||||
193
packages/admin/src/pages/ArchivesPage.tsx
Normal file
193
packages/admin/src/pages/ArchivesPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import type {Archive} from '@fluxer/admin/src/api/Archives';
|
||||
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
|
||||
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
|
||||
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow} from '@fluxer/ui/src/components/Table';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
export interface ArchivesPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
csrfToken: string;
|
||||
subjectType: string;
|
||||
subjectId: string | undefined;
|
||||
archives: Array<Archive>;
|
||||
error: string | undefined;
|
||||
assetVersion: string;
|
||||
}
|
||||
|
||||
function formatTimestampLocal(timestamp: string): string {
|
||||
try {
|
||||
return formatTimestamp(timestamp, 'en-US');
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(archive: Archive): string {
|
||||
if (archive.failed_at) {
|
||||
return 'Failed';
|
||||
}
|
||||
if (archive.completed_at) {
|
||||
return 'Completed';
|
||||
}
|
||||
return 'In Progress';
|
||||
}
|
||||
|
||||
const ArchiveTable: FC<{archives: Array<Archive>; config: Config}> = ({archives, config}) => {
|
||||
return (
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<tr>
|
||||
<TableHeaderCell label="Subject" />
|
||||
<TableHeaderCell label="Requested By" />
|
||||
<TableHeaderCell label="Requested At" />
|
||||
<TableHeaderCell label="Status" />
|
||||
<TableHeaderCell label="Actions" />
|
||||
</tr>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{archives.map((archive) => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<VStack gap={0} class="whitespace-nowrap">
|
||||
<Text weight="semibold" size="sm">
|
||||
{archive.subject_type} {archive.subject_id}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
Archive ID: {archive.archive_id}
|
||||
</Text>
|
||||
</VStack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm">{archive.requested_by}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm">{formatTimestampLocal(archive.requested_at)}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VStack gap={1}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge size="sm" variant="neutral">
|
||||
{getStatusLabel(archive)}
|
||||
</Badge>
|
||||
<Text size="xs" color="muted">
|
||||
{archive.progress_percent}%
|
||||
</Text>
|
||||
</div>
|
||||
{archive.progress_step && !archive.completed_at && !archive.failed_at && (
|
||||
<Text size="xs" color="muted">
|
||||
{archive.progress_step}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{archive.completed_at ? (
|
||||
<Button
|
||||
href={`${config.basePath}/archives/download?subject_type=${archive.subject_type}&subject_id=${archive.subject_id}&archive_id=${archive.archive_id}`}
|
||||
variant="primary"
|
||||
size="small"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="sm" color="muted">
|
||||
Not ready
|
||||
</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArchivesEmptyState: FC<{filterHint: string}> = ({filterHint}) => {
|
||||
return (
|
||||
<EmptyState
|
||||
title={`No archives found${filterHint}.`}
|
||||
message="This page lists all user and guild archives you've requested. Request an archive from a user or guild detail page."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export async function ArchivesPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
csrfToken,
|
||||
subjectType,
|
||||
subjectId,
|
||||
archives,
|
||||
error,
|
||||
assetVersion,
|
||||
}: ArchivesPageProps) {
|
||||
const filterHint = subjectId ? ` for ${subjectType} ${subjectId}` : '';
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Archives"
|
||||
activePage="archives"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={4}>
|
||||
<PageHeader title={`Archives${filterHint}`} />
|
||||
|
||||
{error ? (
|
||||
<ErrorAlert error={error} />
|
||||
) : archives.length === 0 ? (
|
||||
<ArchivesEmptyState filterHint={filterHint} />
|
||||
) : (
|
||||
<ArchiveTable archives={archives} config={config} />
|
||||
)}
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
212
packages/admin/src/pages/AssetPurgePage.tsx
Normal file
212
packages/admin/src/pages/AssetPurgePage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
|
||||
import {Input} from '@fluxer/admin/src/components/ui/Input';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {
|
||||
PurgeGuildAssetError,
|
||||
PurgeGuildAssetResult,
|
||||
PurgeGuildAssetsResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
export interface AssetPurgePageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
result?: PurgeGuildAssetsResponse;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
function hasPermission(acls: Array<string>, permission: string): boolean {
|
||||
return acls.includes(permission) || acls.includes('*');
|
||||
}
|
||||
|
||||
const PurgeForm: FC<{config: Config; csrfToken: string}> = ({config, csrfToken}) => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Purge Assets
|
||||
</Heading>
|
||||
<Text color="muted" size="sm" class="mb-4">
|
||||
Enter the emoji or sticker IDs that should be removed from S3 and CDN caches.
|
||||
</Text>
|
||||
<form method="post" action={`${config.basePath}/asset-purge?action=purge-assets`}>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="IDs" helper="Separate multiple IDs with commas or line breaks.">
|
||||
<Textarea
|
||||
id="asset-purge-ids"
|
||||
name="asset_ids"
|
||||
required
|
||||
placeholder={'123456789012345678\n876543210987654321'}
|
||||
rows={4}
|
||||
size="sm"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Audit Log Reason (optional)">
|
||||
<Input
|
||||
id="asset-purge-audit-log-reason"
|
||||
type="text"
|
||||
name="audit_log_reason"
|
||||
placeholder="DMCA takedown request"
|
||||
size="sm"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="danger">
|
||||
Purge Assets
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PermissionNotice: FC = () => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Permission required
|
||||
</Heading>
|
||||
<Text color="muted" size="sm">
|
||||
You need the asset:purge ACL to use this tool.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessedTable: FC<{items: Array<PurgeGuildAssetResult>}> = ({items}) => {
|
||||
return (
|
||||
<VStack gap={0} class="overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="min-w-full text-left text-neutral-700 text-sm">
|
||||
<thead class="bg-neutral-50 text-neutral-500 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">ID</th>
|
||||
<th class="px-4 py-2 font-medium">Type</th>
|
||||
<th class="px-4 py-2 font-medium">In DB</th>
|
||||
<th class="px-4 py-2 font-medium">Guild ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr class="border-neutral-100 border-t">
|
||||
<td class="break-words px-4 py-3">{item.id}</td>
|
||||
<td class="px-4 py-3">{item.asset_type}</td>
|
||||
<td class="px-4 py-3">{item.found_in_db ? 'Yes' : 'No'}</td>
|
||||
<td class="px-4 py-3">{item.guild_id ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorsList: FC<{errors: Array<PurgeGuildAssetError>}> = ({errors}) => {
|
||||
return (
|
||||
<VStack gap={2} class="mt-4">
|
||||
{errors.map((err) => (
|
||||
<Text color="danger" size="sm">
|
||||
{err.id}: {err.error}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const PurgeResult: FC<{result: PurgeGuildAssetsResponse}> = ({result}) => {
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Purge Result
|
||||
</Heading>
|
||||
<Text color="muted" size="sm" class="mb-4">
|
||||
Processed {result.processed.length} ID(s); {result.errors.length} error(s).
|
||||
</Text>
|
||||
<ProcessedTable items={result.processed} />
|
||||
{result.errors.length > 0 && <ErrorsList errors={result.errors} />}
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export async function AssetPurgePage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
result,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
}: AssetPurgePageProps) {
|
||||
const hasAssetPurgePermission = currentAdmin ? hasPermission(currentAdmin.acls, AdminACLs.ASSET_PURGE) : false;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Asset Purge"
|
||||
activePage="asset-purge"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<VStack gap={6}>
|
||||
<VStack gap={2}>
|
||||
<Heading level={1}>Asset Purge</Heading>
|
||||
<Text color="muted" size="sm">
|
||||
Purge emojis or stickers from the storage and CDN. Provide one or more IDs. Separate multiple IDs with
|
||||
commas.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{result && <PurgeResult result={result} />}
|
||||
|
||||
{hasAssetPurgePermission ? <PurgeForm config={config} csrfToken={csrfToken} /> : <PermissionNotice />}
|
||||
</VStack>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAssetIds(input: string): Array<string> {
|
||||
const normalized = input.replace(/\n/g, ',').replace(/\r/g, ',');
|
||||
return normalized
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0);
|
||||
}
|
||||
501
packages/admin/src/pages/AuditLogsPage.tsx
Normal file
501
packages/admin/src/pages/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {searchAuditLogs} from '@fluxer/admin/src/api/Audit';
|
||||
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
|
||||
import {lookupUsersByIds} from '@fluxer/admin/src/api/Users';
|
||||
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
|
||||
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
|
||||
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {ResourceLink} from '@fluxer/admin/src/components/ui/ResourceLink';
|
||||
import {Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {AdminAuditLogResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Pill} from '@fluxer/ui/src/components/Badge';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
|
||||
import {Pagination} from '@fluxer/ui/src/components/Pagination';
|
||||
import {type SearchField, SearchForm} from '@fluxer/ui/src/components/SearchForm';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from '@fluxer/ui/src/components/Table';
|
||||
import type {ColorTone} from '@fluxer/ui/src/utils/ColorVariants';
|
||||
import {formatUserTag} from '@fluxer/ui/src/utils/FormatUser';
|
||||
import type {FC} from 'hono/jsx';
|
||||
import type {z} from 'zod';
|
||||
|
||||
type AuditLog = z.infer<typeof AdminAuditLogResponseSchema>;
|
||||
|
||||
interface AuditLogsPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
csrfToken: string;
|
||||
query: string | undefined;
|
||||
adminUserIdFilter: string | undefined;
|
||||
targetId: string | undefined;
|
||||
currentPage: number;
|
||||
assetVersion: string;
|
||||
}
|
||||
|
||||
interface UserMap {
|
||||
[userId: string]: UserAdminResponse;
|
||||
}
|
||||
|
||||
function formatTimestampLocal(timestamp: string): string {
|
||||
return formatTimestamp(timestamp, 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getActionTone(action: string): ColorTone {
|
||||
switch (action) {
|
||||
case 'temp_ban':
|
||||
case 'disable_suspicious_activity':
|
||||
case 'schedule_deletion':
|
||||
case 'ban_ip':
|
||||
case 'ban_email':
|
||||
case 'ban_phone':
|
||||
return 'danger';
|
||||
case 'unban':
|
||||
case 'cancel_deletion':
|
||||
case 'unban_ip':
|
||||
case 'unban_email':
|
||||
case 'unban_phone':
|
||||
return 'success';
|
||||
case 'update_flags':
|
||||
case 'update_features':
|
||||
case 'set_acls':
|
||||
case 'update_settings':
|
||||
return 'info';
|
||||
case 'delete_message':
|
||||
return 'orange';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function capitalise(s: string): string {
|
||||
if (s.length === 0) return s;
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
const FiltersSection: FC<{
|
||||
config: Config;
|
||||
query: string | undefined;
|
||||
adminUserIdFilter: string | undefined;
|
||||
targetId: string | undefined;
|
||||
}> = ({config, query, adminUserIdFilter, targetId}) => {
|
||||
const fields: Array<SearchField> = [
|
||||
{
|
||||
name: 'q',
|
||||
type: 'text',
|
||||
label: 'Search',
|
||||
placeholder: 'Search by action, reason, or metadata...',
|
||||
value: query ?? '',
|
||||
},
|
||||
{
|
||||
name: 'target_id',
|
||||
type: 'text',
|
||||
label: 'Target ID',
|
||||
placeholder: 'Filter by target ID...',
|
||||
value: targetId ?? '',
|
||||
},
|
||||
{
|
||||
name: 'admin_user_id',
|
||||
type: 'text',
|
||||
label: 'Admin User ID',
|
||||
placeholder: 'Filter by admin user ID...',
|
||||
value: adminUserIdFilter ?? '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SearchForm
|
||||
action="/audit-logs"
|
||||
method="get"
|
||||
fields={fields}
|
||||
submitLabel="Search"
|
||||
showClear={true}
|
||||
clearHref="/audit-logs"
|
||||
clearLabel="Clear"
|
||||
layout="vertical"
|
||||
padding="sm"
|
||||
basePath={config.basePath}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminCell: FC<{config: Config; admin_user_id: string; userMap: UserMap}> = ({config, admin_user_id, userMap}) => {
|
||||
if (admin_user_id === '') {
|
||||
return (
|
||||
<TableCell>
|
||||
<Text size="sm" color="muted" class="italic">
|
||||
-
|
||||
</Text>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
const user = userMap[admin_user_id];
|
||||
return (
|
||||
<TableCell>
|
||||
<ResourceLink config={config} resourceType="user" resourceId={admin_user_id}>
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{user ? formatUserTag(user.username, user.discriminator) : 'Unknown User'}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{admin_user_id}
|
||||
</Text>
|
||||
</VStack>
|
||||
</ResourceLink>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
const TargetCell: FC<{config: Config; target_type: string; target_id: string; userMap: UserMap}> = ({
|
||||
config,
|
||||
target_type,
|
||||
target_id,
|
||||
userMap,
|
||||
}) => {
|
||||
if (target_type === 'user') {
|
||||
const user = userMap[target_id];
|
||||
return (
|
||||
<TableCell>
|
||||
<ResourceLink config={config} resourceType="user" resourceId={target_id}>
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{user ? formatUserTag(user.username, user.discriminator) : 'Unknown User'}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{target_id}
|
||||
</Text>
|
||||
</VStack>
|
||||
</ResourceLink>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (target_type === 'guild') {
|
||||
return (
|
||||
<TableCell>
|
||||
<ResourceLink config={config} resourceType="guild" resourceId={target_id}>
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
Guild
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{target_id}
|
||||
</Text>
|
||||
</VStack>
|
||||
</ResourceLink>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell>
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{capitalise(target_type)}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{target_id}
|
||||
</Text>
|
||||
</VStack>
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailsExpanded: FC<{reason: string | null; metadata: Record<string, string>}> = ({reason, metadata}) => {
|
||||
const entries = Object.entries(metadata);
|
||||
const hasContent = reason || entries.length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<Text size="sm" color="muted" class="italic">
|
||||
No additional details
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{reason && (
|
||||
<HStack gap={2} align="start">
|
||||
<Text size="sm" weight="semibold" class="min-w-[120px]">
|
||||
Reason
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
{reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{entries.map(([key, value]) => (
|
||||
<HStack gap={2} align="start">
|
||||
<Text size="sm" weight="semibold" class="min-w-[120px]">
|
||||
{key}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const LogRow: FC<{config: Config; log: AuditLog; userMap: UserMap}> = ({config, log, userMap}) => {
|
||||
const expandedId = `expanded-${log.log_id}`;
|
||||
const hasDetails = log.audit_log_reason || Object.keys(log.metadata).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell muted>
|
||||
<Text size="sm" class="whitespace-nowrap">
|
||||
{formatTimestampLocal(log.created_at)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill label={formatAction(log.action)} tone={getActionTone(log.action)} />
|
||||
</TableCell>
|
||||
<AdminCell config={config} admin_user_id={log.admin_user_id} userMap={userMap} />
|
||||
<TargetCell config={config} target_type={log.target_type} target_id={log.target_id} userMap={userMap} />
|
||||
<TableCell muted>
|
||||
{hasDetails ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onclick={`document.getElementById('${expandedId}').classList.toggle('hidden')`}
|
||||
>
|
||||
Show details
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="sm" color="muted" class="italic">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{hasDetails && (
|
||||
<tr id={expandedId} class="hidden bg-neutral-50">
|
||||
<td colspan={5} class="px-6 py-4">
|
||||
<DetailsExpanded reason={log.audit_log_reason} metadata={log.metadata} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LogsTable: FC<{config: Config; logs: Array<AuditLog>; userMap: UserMap}> = ({config, logs, userMap}) => (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<tr>
|
||||
<TableHeaderCell label="Timestamp" />
|
||||
<TableHeaderCell label="Action" />
|
||||
<TableHeaderCell label="Admin" />
|
||||
<TableHeaderCell label="Target" />
|
||||
<TableHeaderCell label="Details" />
|
||||
</tr>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<LogRow config={config} log={log} userMap={userMap} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
const AuditLogsPagination: FC<{
|
||||
config: Config;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
query: string | undefined;
|
||||
adminUserIdFilter: string | undefined;
|
||||
targetId: string | undefined;
|
||||
}> = ({config, currentPage, totalPages, query, adminUserIdFilter, targetId}) => {
|
||||
const buildUrl = (page: number) => {
|
||||
return `/audit-logs${buildPaginationUrl(page, {
|
||||
q: query,
|
||||
admin_user_id: adminUserIdFilter,
|
||||
target_id: targetId,
|
||||
})}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
buildUrlFn={buildUrl}
|
||||
basePath={config.basePath}
|
||||
previousLabel="Previous"
|
||||
nextLabel="Next"
|
||||
pageInfo={`Page ${currentPage + 1} of ${totalPages}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AuditLogsEmptyState: FC = () => (
|
||||
<EmptyState title="No audit logs found" message="Try adjusting your filters or check back later" />
|
||||
);
|
||||
|
||||
function collectUserIds(logs: Array<AuditLog>): Array<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const log of logs) {
|
||||
if (log.admin_user_id !== '') {
|
||||
ids.add(log.admin_user_id);
|
||||
}
|
||||
if (log.target_type === 'user' && log.target_id !== '') {
|
||||
ids.add(log.target_id);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export async function AuditLogsPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
csrfToken,
|
||||
query,
|
||||
adminUserIdFilter,
|
||||
targetId,
|
||||
currentPage,
|
||||
assetVersion,
|
||||
}: AuditLogsPageProps) {
|
||||
const limit = 50;
|
||||
const offset = currentPage * limit;
|
||||
|
||||
const result = await searchAuditLogs(config, session, {
|
||||
query,
|
||||
admin_user_id: adminUserIdFilter,
|
||||
target_id: targetId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const userMap: UserMap = {};
|
||||
if (result.ok && result.data.logs.length > 0) {
|
||||
const userIds = collectUserIds(result.data.logs);
|
||||
if (userIds.length > 0) {
|
||||
const usersResult = await lookupUsersByIds(config, session, userIds);
|
||||
if (usersResult.ok) {
|
||||
for (const user of usersResult.data) {
|
||||
userMap[user.id] = user;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = result.ok ? (
|
||||
(() => {
|
||||
const totalPages = Math.ceil(result.data.total / limit);
|
||||
return (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={6}>
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
actions={
|
||||
<Text size="sm" color="muted">
|
||||
Showing {result.data.logs.length} of {result.data.total} entries
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiltersSection config={config} query={query} adminUserIdFilter={adminUserIdFilter} targetId={targetId} />
|
||||
|
||||
{result.data.logs.length === 0 ? (
|
||||
<AuditLogsEmptyState />
|
||||
) : (
|
||||
<LogsTable config={config} logs={result.data.logs} userMap={userMap} />
|
||||
)}
|
||||
|
||||
{result.data.total > limit && (
|
||||
<AuditLogsPagination
|
||||
config={config}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
query={query}
|
||||
adminUserIdFilter={adminUserIdFilter}
|
||||
targetId={targetId}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={6}>
|
||||
<PageHeader title="Audit Logs" />
|
||||
<ErrorAlert error={getErrorMessage(result.error)} />
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Audit Logs"
|
||||
activePage="audit-logs"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
{content}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
309
packages/admin/src/pages/BanManagementPage.tsx
Normal file
309
packages/admin/src/pages/BanManagementPage.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import {Input} from '@fluxer/ui/src/components/Form';
|
||||
import {Grid, Stack} from '@fluxer/ui/src/components/Layout';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
export type BanType = 'ip' | 'email' | 'phone';
|
||||
|
||||
interface BanConfig {
|
||||
title: string;
|
||||
route: string;
|
||||
inputLabel: string;
|
||||
inputName: string;
|
||||
inputType: 'text' | 'email' | 'tel';
|
||||
placeholder: string;
|
||||
entityName: string;
|
||||
activePage: string;
|
||||
}
|
||||
|
||||
export function getBanConfig(banType: BanType): BanConfig {
|
||||
switch (banType) {
|
||||
case 'ip':
|
||||
return {
|
||||
title: 'IP Bans',
|
||||
route: '/ip-bans',
|
||||
inputLabel: 'IP Address or CIDR',
|
||||
inputName: 'ip',
|
||||
inputType: 'text',
|
||||
placeholder: '192.168.1.1 or 192.168.0.0/16',
|
||||
entityName: 'IP/CIDR',
|
||||
activePage: 'ip-bans',
|
||||
};
|
||||
case 'email':
|
||||
return {
|
||||
title: 'Email Bans',
|
||||
route: '/email-bans',
|
||||
inputLabel: 'Email Address',
|
||||
inputName: 'email',
|
||||
inputType: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
entityName: 'Email',
|
||||
activePage: 'email-bans',
|
||||
};
|
||||
case 'phone':
|
||||
return {
|
||||
title: 'Phone Bans',
|
||||
route: '/phone-bans',
|
||||
inputLabel: 'Phone Number',
|
||||
inputName: 'phone',
|
||||
inputType: 'tel',
|
||||
placeholder: '+1234567890',
|
||||
entityName: 'Phone',
|
||||
activePage: 'phone-bans',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface BanManagementPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
assetVersion: string;
|
||||
banType: BanType;
|
||||
csrfToken: string;
|
||||
listResult?: {ok: true; bans: Array<{value: string; reverseDns?: string | null}>} | {ok: false; errorMessage: string};
|
||||
}
|
||||
|
||||
const BanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({config, banConfig, csrfToken}) => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Stack gap="4">
|
||||
<Heading level={3} size="base">
|
||||
Ban {banConfig.inputLabel}
|
||||
</Heading>
|
||||
<form method="post" action={`${config.basePath}${banConfig.route}?action=ban`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
label={banConfig.inputLabel}
|
||||
name={banConfig.inputName}
|
||||
type={banConfig.inputType}
|
||||
required={true}
|
||||
placeholder={banConfig.placeholder}
|
||||
/>
|
||||
<Input
|
||||
label="Private reason (audit log, optional)"
|
||||
name="audit_log_reason"
|
||||
type="text"
|
||||
required={false}
|
||||
placeholder="Why is this ban being applied?"
|
||||
/>
|
||||
<Button type="submit" variant="primary">
|
||||
Ban {banConfig.entityName}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckBanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({
|
||||
config,
|
||||
banConfig,
|
||||
csrfToken,
|
||||
}) => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Stack gap="4">
|
||||
<Heading level={3} size="base">
|
||||
Check {banConfig.inputLabel} Ban Status
|
||||
</Heading>
|
||||
<form method="post" action={`${config.basePath}${banConfig.route}?action=check`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
label={banConfig.inputLabel}
|
||||
name={banConfig.inputName}
|
||||
type={banConfig.inputType}
|
||||
required={true}
|
||||
placeholder={banConfig.placeholder}
|
||||
/>
|
||||
<Button type="submit" variant="primary">
|
||||
Check Status
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const UnbanCard: FC<{config: Config; banConfig: BanConfig; csrfToken: string}> = ({config, banConfig, csrfToken}) => {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Stack gap="4">
|
||||
<Heading level={3} size="base">
|
||||
Remove {banConfig.inputLabel} Ban
|
||||
</Heading>
|
||||
<form method="post" action={`${config.basePath}${banConfig.route}?action=unban`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
label={banConfig.inputLabel}
|
||||
name={banConfig.inputName}
|
||||
type={banConfig.inputType}
|
||||
required={true}
|
||||
placeholder={banConfig.placeholder}
|
||||
/>
|
||||
<Input
|
||||
label="Private reason (audit log, optional)"
|
||||
name="audit_log_reason"
|
||||
type="text"
|
||||
required={false}
|
||||
placeholder="Why is this ban being removed?"
|
||||
/>
|
||||
<Button type="submit" variant="danger">
|
||||
Unban {banConfig.entityName}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const BanListCard: FC<{
|
||||
config: Config;
|
||||
banType: BanType;
|
||||
banConfig: BanConfig;
|
||||
listResult: BanManagementPageProps['listResult'];
|
||||
csrfToken: string;
|
||||
}> = ({config, banType, banConfig, listResult, csrfToken}) => {
|
||||
if (!listResult) return null;
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<Stack gap="4">
|
||||
<Heading level={3} size="base">
|
||||
Current bans
|
||||
</Heading>
|
||||
{!listResult.ok ? (
|
||||
<Text size="sm" color="muted">
|
||||
Failed to load bans list: {listResult.errorMessage}
|
||||
</Text>
|
||||
) : listResult.bans.length === 0 ? (
|
||||
<Text size="sm" color="muted">
|
||||
No {banConfig.entityName.toLowerCase()} bans found
|
||||
</Text>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-neutral-200 border-b text-left text-neutral-600">
|
||||
<th class="px-3 py-2">{banConfig.inputLabel}</th>
|
||||
{banType === 'ip' && <th class="px-3 py-2">Reverse DNS</th>}
|
||||
<th class="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listResult.bans.map((ban) => (
|
||||
<tr class="border-neutral-200 border-b">
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-mono">{ban.value}</span>
|
||||
<a
|
||||
href={`${config.basePath}/users?q=${encodeURIComponent(ban.value)}`}
|
||||
class="ml-2 text-blue-600 text-xs no-underline hover:underline"
|
||||
>
|
||||
Search users
|
||||
</a>
|
||||
</td>
|
||||
{banType === 'ip' && <td class="px-3 py-2 text-neutral-700">{ban.reverseDns ?? '—'}</td>}
|
||||
<td class="px-3 py-2">
|
||||
<form
|
||||
method="post"
|
||||
action={`${config.basePath}${banConfig.route}?action=unban`}
|
||||
onsubmit={`return confirm('Unban ${banConfig.entityName} ${ban.value}?')`}
|
||||
>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name={banConfig.inputName} value={ban.value} />
|
||||
<Button type="submit" variant="danger" size="small">
|
||||
Unban
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const BanManagementPage: FC<BanManagementPageProps> = ({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
assetVersion,
|
||||
banType,
|
||||
csrfToken,
|
||||
listResult,
|
||||
}) => {
|
||||
const banConfig = getBanConfig(banType);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title={banConfig.title}
|
||||
activePage={banConfig.activePage}
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<Stack gap="6">
|
||||
<Heading level={1}>{banConfig.title}</Heading>
|
||||
|
||||
<Grid cols="2" gap="6">
|
||||
<BanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
|
||||
<CheckBanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
|
||||
</Grid>
|
||||
|
||||
<UnbanCard config={config} banConfig={banConfig} csrfToken={csrfToken} />
|
||||
<BanListCard
|
||||
config={config}
|
||||
banType={banType}
|
||||
banConfig={banConfig}
|
||||
listResult={listResult}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
</Stack>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
426
packages/admin/src/pages/BulkActionsPage.tsx
Normal file
426
packages/admin/src/pages/BulkActionsPage.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {PATCHABLE_FLAGS} from '@fluxer/admin/src/AdminPackageConstants';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
|
||||
import {Input} from '@fluxer/admin/src/components/ui/Input';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Select} from '@fluxer/admin/src/components/ui/Select';
|
||||
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {BulkOperationResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import {Checkbox} from '@fluxer/ui/src/components/Form';
|
||||
import type {FC} from 'hono/jsx';
|
||||
import type {z} from 'zod';
|
||||
|
||||
type BulkOperationResponseType = z.infer<typeof BulkOperationResponse>;
|
||||
|
||||
interface GuildFeature {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const GUILD_FEATURES: Array<GuildFeature> = [
|
||||
{value: 'ANIMATED_ICON'},
|
||||
{value: 'ANIMATED_BANNER'},
|
||||
{value: 'BANNER'},
|
||||
{value: 'INVITE_SPLASH'},
|
||||
{value: 'INVITES_DISABLED'},
|
||||
{value: 'MORE_EMOJI'},
|
||||
{value: 'MORE_STICKERS'},
|
||||
{value: 'UNLIMITED_EMOJI'},
|
||||
{value: 'UNLIMITED_STICKERS'},
|
||||
{value: 'TEXT_CHANNEL_FLEXIBLE_NAMES'},
|
||||
{value: 'UNAVAILABLE_FOR_EVERYONE'},
|
||||
{value: 'UNAVAILABLE_FOR_EVERYONE_BUT_STAFF'},
|
||||
{value: 'VANITY_URL'},
|
||||
{value: 'VERIFIED'},
|
||||
{value: 'VIP_VOICE'},
|
||||
{value: 'DETACHED_BANNER'},
|
||||
{value: 'EXPRESSION_PURGE_ALLOWED'},
|
||||
{value: 'LARGE_GUILD_OVERRIDE'},
|
||||
{value: 'VERY_LARGE_GUILD'},
|
||||
];
|
||||
|
||||
const DELETION_REASONS: Array<[number, string]> = [
|
||||
[1, 'User requested'],
|
||||
[2, 'Other'],
|
||||
[3, 'Spam'],
|
||||
[4, 'Cheating or exploitation'],
|
||||
[5, 'Coordinated raiding or manipulation'],
|
||||
[6, 'Automation or self-bot usage'],
|
||||
[7, 'Nonconsensual sexual content'],
|
||||
[8, 'Scam or social engineering'],
|
||||
[9, 'Child sexual content'],
|
||||
[10, 'Privacy violation or doxxing'],
|
||||
[11, 'Harassment or bullying'],
|
||||
[12, 'Payment fraud'],
|
||||
[13, 'Child safety violation'],
|
||||
[14, 'Billing dispute or abuse'],
|
||||
[15, 'Unsolicited explicit content'],
|
||||
[16, 'Graphic violence'],
|
||||
[17, 'Ban evasion'],
|
||||
[18, 'Token or credential scam'],
|
||||
[19, 'Inactivity'],
|
||||
[20, 'Hate speech or extremist content'],
|
||||
[21, 'Malicious links or malware distribution'],
|
||||
[22, 'Impersonation or fake identity'],
|
||||
];
|
||||
|
||||
export interface BulkActionsPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
assetVersion: string;
|
||||
adminAcls: Array<string>;
|
||||
result?: BulkOperationResponseType;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
function hasPermission(acls: Array<string>, permission: string): boolean {
|
||||
return acls.includes('*') || acls.includes(permission);
|
||||
}
|
||||
|
||||
const OperationResult: FC<{response: BulkOperationResponseType}> = ({response}) => {
|
||||
const successCount = response.successful.length;
|
||||
const failCount = response.failed.length;
|
||||
|
||||
return (
|
||||
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Operation Result
|
||||
</Heading>
|
||||
<VStack gap={3}>
|
||||
<Text size="sm">
|
||||
<span class="font-medium text-green-600 text-sm">Successful: </span>
|
||||
{successCount}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<span class="font-medium text-red-600 text-sm">Failed: </span>
|
||||
{failCount}
|
||||
</Text>
|
||||
{response.failed.length > 0 && (
|
||||
<div class="mt-4">
|
||||
<Heading level={3} size="sm" class="mb-2">
|
||||
Errors:
|
||||
</Heading>
|
||||
<ul>
|
||||
<VStack gap={1}>
|
||||
{response.failed.map((error) => (
|
||||
<li>
|
||||
<Text color="danger" size="sm">
|
||||
{error.id}: {error.error}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</VStack>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BulkUpdateUserFlags: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Bulk Update User Flags
|
||||
</Heading>
|
||||
<form method="post" action={`${basePath}/bulk-actions?action=bulk-update-user-flags`}>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="User IDs (one per line)">
|
||||
<Textarea
|
||||
id="bulk-user-flags-user-ids"
|
||||
name="user_ids"
|
||||
placeholder={'123456789\n987654321'}
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<div>
|
||||
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
|
||||
Flags to Add
|
||||
</Text>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{PATCHABLE_FLAGS.map((flag) => (
|
||||
<Checkbox name="add_flags[]" value={flag.value.toString()} label={flag.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
|
||||
Flags to Remove
|
||||
</Text>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{PATCHABLE_FLAGS.map((flag) => (
|
||||
<Checkbox name="remove_flags[]" value={flag.value.toString()} label={flag.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormFieldGroup label="Audit Log Reason (optional)">
|
||||
<Input
|
||||
id="bulk-user-flags-audit-log-reason"
|
||||
type="text"
|
||||
name="audit_log_reason"
|
||||
placeholder="Reason for this bulk operation"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="primary" size="medium">
|
||||
Update User Flags
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const BulkUpdateGuildFeatures: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Bulk Update Guild Features
|
||||
</Heading>
|
||||
<form method="post" action={`${basePath}/bulk-actions?action=bulk-update-guild-features`}>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="Guild IDs (one per line)">
|
||||
<Textarea
|
||||
id="bulk-guild-features-guild-ids"
|
||||
name="guild_ids"
|
||||
placeholder={'123456789\n987654321'}
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<div>
|
||||
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
|
||||
Features to Add
|
||||
</Text>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{GUILD_FEATURES.map((feature) => (
|
||||
<Checkbox name="add_features[]" value={feature.value} label={feature.value} />
|
||||
))}
|
||||
</div>
|
||||
<FormFieldGroup label="Custom features" htmlFor="bulk-guild-features-custom-add-features" class="mt-3">
|
||||
<Input
|
||||
id="bulk-guild-features-custom-add-features"
|
||||
type="text"
|
||||
name="custom_add_features"
|
||||
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
</div>
|
||||
<div>
|
||||
<Text weight="medium" size="sm" class="mb-2 block text-neutral-700">
|
||||
Features to Remove
|
||||
</Text>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{GUILD_FEATURES.map((feature) => (
|
||||
<Checkbox name="remove_features[]" value={feature.value} label={feature.value} />
|
||||
))}
|
||||
</div>
|
||||
<FormFieldGroup label="Custom features" htmlFor="bulk-guild-features-custom-remove-features" class="mt-3">
|
||||
<Input
|
||||
id="bulk-guild-features-custom-remove-features"
|
||||
type="text"
|
||||
name="custom_remove_features"
|
||||
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
</div>
|
||||
<FormFieldGroup label="Audit Log Reason (optional)">
|
||||
<Input
|
||||
id="bulk-guild-features-audit-log-reason"
|
||||
type="text"
|
||||
name="audit_log_reason"
|
||||
placeholder="Reason for this bulk operation"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="primary" size="medium">
|
||||
Update Guild Features
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const BulkAddGuildMembers: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Bulk Add Guild Members
|
||||
</Heading>
|
||||
<form method="post" action={`${basePath}/bulk-actions?action=bulk-add-guild-members`}>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="Guild ID">
|
||||
<Input id="bulk-add-guild-members-guild-id" type="text" name="guild_id" placeholder="123456789" required />
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="User IDs (one per line)">
|
||||
<Textarea
|
||||
id="bulk-add-guild-members-user-ids"
|
||||
name="user_ids"
|
||||
placeholder={'123456789\n987654321'}
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Audit Log Reason (optional)">
|
||||
<Input
|
||||
id="bulk-add-guild-members-audit-log-reason"
|
||||
type="text"
|
||||
name="audit_log_reason"
|
||||
placeholder="Reason for this bulk operation"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="primary" size="medium">
|
||||
Add Members
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const BULK_DELETION_DAYS_SCRIPT = `
|
||||
(function () {
|
||||
var reasonSelect = document.getElementById('bulk-deletion-reason');
|
||||
var daysInput = document.getElementById('bulk-deletion-days');
|
||||
if (!reasonSelect || !daysInput) return;
|
||||
|
||||
reasonSelect.addEventListener('change', function () {
|
||||
var reason = parseInt(this.value, 10);
|
||||
if (reason === 9 || reason === 13) {
|
||||
daysInput.value = '0';
|
||||
daysInput.min = '0';
|
||||
} else {
|
||||
if (parseInt(daysInput.value, 10) < 14) {
|
||||
daysInput.value = '14';
|
||||
}
|
||||
daysInput.min = '14';
|
||||
}
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const BulkScheduleUserDeletion: FC<{basePath: string; csrfToken: string}> = ({basePath, csrfToken}) => (
|
||||
<Card padding="md">
|
||||
<Heading level={3} size="base" class="mb-4">
|
||||
Bulk Schedule User Deletion
|
||||
</Heading>
|
||||
<form
|
||||
method="post"
|
||||
action={`${basePath}/bulk-actions?action=bulk-schedule-user-deletion`}
|
||||
onsubmit="return confirm('Are you sure you want to schedule these users for deletion?')"
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<FormFieldGroup label="User IDs (one per line)">
|
||||
<Textarea
|
||||
id="bulk-user-deletion-user-ids"
|
||||
name="user_ids"
|
||||
placeholder={'123456789\n987654321'}
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Deletion Reason">
|
||||
<Select
|
||||
id="bulk-deletion-reason"
|
||||
name="reason_code"
|
||||
required
|
||||
options={DELETION_REASONS.map(([code, label]) => ({value: String(code), label}))}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Public Reason (optional)">
|
||||
<Input
|
||||
id="bulk-user-deletion-public-reason"
|
||||
type="text"
|
||||
name="public_reason"
|
||||
placeholder="Terms of service violation"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Days Until Deletion">
|
||||
<Input type="number" id="bulk-deletion-days" name="days_until_deletion" value="14" min="14" required />
|
||||
</FormFieldGroup>
|
||||
<FormFieldGroup label="Audit Log Reason (optional)">
|
||||
<Input
|
||||
id="bulk-user-deletion-audit-log-reason"
|
||||
type="text"
|
||||
name="audit_log_reason"
|
||||
placeholder="Reason for this bulk operation"
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="danger" size="medium">
|
||||
Schedule Deletion
|
||||
</Button>
|
||||
<script defer dangerouslySetInnerHTML={{__html: BULK_DELETION_DAYS_SCRIPT}} />
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export function BulkActionsPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
assetVersion,
|
||||
adminAcls,
|
||||
result,
|
||||
csrfToken,
|
||||
}: BulkActionsPageProps) {
|
||||
const canUpdateUserFlags = hasPermission(adminAcls, 'bulk:update_user_flags');
|
||||
const canUpdateGuildFeatures = hasPermission(adminAcls, 'bulk:update_guild_features');
|
||||
const canAddGuildMembers = hasPermission(adminAcls, 'bulk:add_guild_members');
|
||||
const canDeleteUsers = hasPermission(adminAcls, 'bulk:delete_users');
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Bulk Actions"
|
||||
activePage="bulk-actions"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<VStack gap={6}>
|
||||
<Heading level={1}>Bulk Actions</Heading>
|
||||
|
||||
{result && <OperationResult response={result} />}
|
||||
|
||||
{canUpdateUserFlags && <BulkUpdateUserFlags basePath={config.basePath} csrfToken={csrfToken} />}
|
||||
{canUpdateGuildFeatures && <BulkUpdateGuildFeatures basePath={config.basePath} csrfToken={csrfToken} />}
|
||||
{canAddGuildMembers && <BulkAddGuildMembers basePath={config.basePath} csrfToken={csrfToken} />}
|
||||
{canDeleteUsers && <BulkScheduleUserDeletion basePath={config.basePath} csrfToken={csrfToken} />}
|
||||
</VStack>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
309
packages/admin/src/pages/DiscoveryPage.tsx
Normal file
309
packages/admin/src/pages/DiscoveryPage.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
|
||||
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Table} from '@fluxer/admin/src/components/ui/Table';
|
||||
import {TableBody} from '@fluxer/admin/src/components/ui/TableBody';
|
||||
import {TableCell} from '@fluxer/admin/src/components/ui/TableCell';
|
||||
import {TableContainer} from '@fluxer/admin/src/components/ui/TableContainer';
|
||||
import {TableHeader} from '@fluxer/admin/src/components/ui/TableHeader';
|
||||
import {TableHeaderCell} from '@fluxer/admin/src/components/ui/TableHeaderCell';
|
||||
import {TableRow} from '@fluxer/admin/src/components/ui/TableRow';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {type DiscoveryCategory, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import type {FC} from 'hono/jsx';
|
||||
import type {z} from 'zod';
|
||||
|
||||
type Application = z.infer<typeof DiscoveryApplicationResponse>;
|
||||
|
||||
interface StatusTabProps {
|
||||
currentStatus: string;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
function StatusTabs({currentStatus, basePath}: StatusTabProps) {
|
||||
const statuses = ['pending', 'approved', 'rejected', 'removed'];
|
||||
return (
|
||||
<div class="flex gap-2">
|
||||
{statuses.map((status) => {
|
||||
const isActive = currentStatus === status;
|
||||
const classes = isActive
|
||||
? 'px-4 py-2 rounded-md text-sm font-medium bg-neutral-800 text-white'
|
||||
: 'px-4 py-2 rounded-md text-sm font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200';
|
||||
return (
|
||||
<a key={status} href={`${basePath}/discovery?status=${status}`} class={classes}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusBadgeVariant(status: string): 'success' | 'danger' | 'warning' | 'neutral' {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'approved':
|
||||
return 'success';
|
||||
case 'rejected':
|
||||
return 'danger';
|
||||
case 'removed':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryLabel(categoryId: number): string {
|
||||
return DiscoveryCategoryLabels[categoryId as DiscoveryCategory] ?? 'Unknown';
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export interface DiscoveryPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
adminAcls: Array<string>;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
applications: Array<Application>;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export const DiscoveryPage: FC<DiscoveryPageProps> = ({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
adminAcls,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
applications,
|
||||
currentStatus,
|
||||
}) => {
|
||||
const hasReviewPermission = hasPermission(adminAcls, AdminACLs.DISCOVERY_REVIEW);
|
||||
const hasRemovePermission = hasPermission(adminAcls, AdminACLs.DISCOVERY_REMOVE);
|
||||
const canTakeAction =
|
||||
(currentStatus === 'pending' && hasReviewPermission) || (currentStatus === 'approved' && hasRemovePermission);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Discovery"
|
||||
activePage="discovery"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
{hasReviewPermission ? (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={6}>
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={1} size="2xl">
|
||||
Discovery Management
|
||||
</Heading>
|
||||
<Text size="sm" color="muted">
|
||||
Review discovery applications and manage listed communities.
|
||||
</Text>
|
||||
<StatusTabs currentStatus={currentStatus} basePath={config.basePath} />
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={2} size="xl">
|
||||
{currentStatus.charAt(0).toUpperCase() + currentStatus.slice(1)} Applications ({applications.length})
|
||||
</Heading>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<Text color="muted">No {currentStatus} applications found.</Text>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Guild ID</TableHeaderCell>
|
||||
<TableHeaderCell>Category</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Applied</TableHeaderCell>
|
||||
{currentStatus !== 'pending' && <TableHeaderCell>Reviewed</TableHeaderCell>}
|
||||
{currentStatus !== 'pending' && <TableHeaderCell>Reason</TableHeaderCell>}
|
||||
{canTakeAction && <TableHeaderCell>Actions</TableHeaderCell>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{applications.map((app) => (
|
||||
<TableRow key={app.guild_id}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`${config.basePath}/guilds?query=${app.guild_id}`}
|
||||
class="font-mono text-blue-600 text-sm hover:underline"
|
||||
>
|
||||
{app.guild_id}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{getCategoryLabel(app.category_id)}</TableCell>
|
||||
<TableCell>
|
||||
<span class="block max-w-xs truncate" title={app.description}>
|
||||
{app.description}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusBadgeVariant(app.status)} size="sm">
|
||||
{app.status.charAt(0).toUpperCase() + app.status.slice(1)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(app.applied_at)}</TableCell>
|
||||
{currentStatus !== 'pending' && (
|
||||
<TableCell>{app.reviewed_at ? formatDate(app.reviewed_at) : '—'}</TableCell>
|
||||
)}
|
||||
{currentStatus !== 'pending' && (
|
||||
<TableCell>
|
||||
<span class="block max-w-xs truncate" title={app.review_reason ?? undefined}>
|
||||
{app.review_reason ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{canTakeAction && (
|
||||
<TableCell>
|
||||
<div class="flex gap-2">
|
||||
{currentStatus === 'pending' && hasReviewPermission && (
|
||||
<>
|
||||
<form
|
||||
method="post"
|
||||
action={`${config.basePath}/discovery/approve`}
|
||||
class="inline"
|
||||
>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name="guild_id" value={app.guild_id} />
|
||||
<Button type="submit" variant="primary" size="small">
|
||||
Approve
|
||||
</Button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action={`${config.basePath}/discovery/reject`}
|
||||
class="inline"
|
||||
onclick={`return promptReason(this, 'Reject this application?')`}
|
||||
>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name="guild_id" value={app.guild_id} />
|
||||
<input type="hidden" name="reason" value="" />
|
||||
<Button type="submit" variant="danger" size="small">
|
||||
Reject
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{currentStatus === 'approved' && hasRemovePermission && (
|
||||
<form
|
||||
method="post"
|
||||
action={`${config.basePath}/discovery/remove`}
|
||||
class="inline"
|
||||
onclick={`return promptReason(this, 'Remove this guild from discovery?')`}
|
||||
>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name="guild_id" value={app.guild_id} />
|
||||
<input type="hidden" name="reason" value="" />
|
||||
<Button type="submit" variant="danger" size="small">
|
||||
Remove
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
) : (
|
||||
<Card padding="md">
|
||||
<Heading level={1} size="2xl">
|
||||
Discovery
|
||||
</Heading>
|
||||
<Text color="muted" size="sm" class="mt-2">
|
||||
You do not have permission to view discovery applications.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<script
|
||||
defer
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: PROMPT_REASON_SCRIPT,
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const PROMPT_REASON_SCRIPT = `
|
||||
function promptReason(form, message) {
|
||||
var reason = prompt(message + '\\n\\nPlease provide a reason:');
|
||||
if (reason === null) return false;
|
||||
if (reason.trim() === '') {
|
||||
alert('A reason is required.');
|
||||
return false;
|
||||
}
|
||||
var reasonInput = form.querySelector('input[name="reason"]');
|
||||
if (reasonInput) {
|
||||
reasonInput.value = reason.trim();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
`;
|
||||
281
packages/admin/src/pages/GatewayPage.tsx
Normal file
281
packages/admin/src/pages/GatewayPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
|
||||
import {getGuildMemoryStats, getNodeStats} from '@fluxer/admin/src/api/System';
|
||||
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import {formatNumber} from '@fluxer/number_utils/src/NumberFormatting';
|
||||
import type {GuildMemoryStatsResponse, NodeStatsResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Alert} from '@fluxer/ui/src/components/Alert';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {CardElevated} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
interface GatewayPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
adminAcls: Array<string>;
|
||||
reloadResult?: number | undefined;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
function formatNumberLocal(n: number): string {
|
||||
return formatNumber(n, {locale: 'en-US'});
|
||||
}
|
||||
|
||||
function formatMemory(memoryMb: number): string {
|
||||
if (memoryMb < 1.0) {
|
||||
const kb = memoryMb * 1024.0;
|
||||
return `${kb.toFixed(2)} KB`;
|
||||
}
|
||||
if (memoryMb < 1024.0) {
|
||||
return `${memoryMb.toFixed(2)} MB`;
|
||||
}
|
||||
const gb = memoryMb / 1024.0;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatMemoryFromBytes(bytesStr: string): string {
|
||||
const bytes = BigInt(bytesStr);
|
||||
const mbWith2Decimals = Number((bytes * 100n) / 1_048_576n) / 100;
|
||||
return formatMemory(mbWith2Decimals);
|
||||
}
|
||||
|
||||
function getFirstChar(s: string): string {
|
||||
if (s === '') return '?';
|
||||
return s.charAt(0);
|
||||
}
|
||||
|
||||
function getGuildIconUrl(
|
||||
mediaEndpoint: string,
|
||||
guildId: string,
|
||||
guildIcon: string | null,
|
||||
forceStatic: boolean,
|
||||
): string | null {
|
||||
if (!guildIcon) return null;
|
||||
const isAnimated = guildIcon.startsWith('a_');
|
||||
const extension = isAnimated && !forceStatic ? 'gif' : 'webp';
|
||||
return `${mediaEndpoint}/icons/${guildId}/${guildIcon}.${extension}?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}`;
|
||||
}
|
||||
|
||||
const StatCard: FC<{label: string; value: string}> = ({label, value}) => (
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
|
||||
<div class="mb-1 text-neutral-600 text-xs uppercase tracking-wider">{label}</div>
|
||||
<div class="font-semibold text-base text-neutral-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type ProcessMemoryStats = GuildMemoryStatsResponse['guilds'][number];
|
||||
|
||||
const NodeStatsSection: FC<{stats: NodeStatsResponse}> = ({stats}) => (
|
||||
<CardElevated padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={2}>Gateway Statistics</Heading>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard label="Sessions" value={formatNumberLocal(stats.sessions)} />
|
||||
<StatCard label="Guilds" value={formatNumberLocal(stats.guilds)} />
|
||||
<StatCard label="Presences" value={formatNumberLocal(stats.presences)} />
|
||||
<StatCard label="Calls" value={formatNumberLocal(stats.calls)} />
|
||||
<StatCard label="Total RAM" value={formatMemoryFromBytes(stats.memory.total)} />
|
||||
</div>
|
||||
</VStack>
|
||||
</CardElevated>
|
||||
);
|
||||
|
||||
const GuildRow: FC<{config: Config; guild: ProcessMemoryStats; rank: number}> = ({config, guild, rank}) => {
|
||||
const iconUrl = guild.guild_id ? getGuildIconUrl(config.mediaEndpoint, guild.guild_id, guild.guild_icon, true) : null;
|
||||
|
||||
return (
|
||||
<tr class="transition-colors hover:bg-neutral-50">
|
||||
<td class="whitespace-nowrap px-6 py-4 font-medium text-neutral-900 text-sm">#{rank}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
{guild.guild_id ? (
|
||||
<a href={`${config.basePath}/guilds/${guild.guild_id}`} class="flex items-center gap-2">
|
||||
{iconUrl ? (
|
||||
<img src={iconUrl} alt={guild.guild_name} class="h-10 w-10 rounded-full" />
|
||||
) : (
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-200 font-medium text-neutral-600 text-sm">
|
||||
{getFirstChar(guild.guild_name)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 text-sm">{guild.guild_name}</div>
|
||||
<div class="text-neutral-500 text-xs">{guild.guild_id}</div>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-200 font-medium text-neutral-600 text-sm">
|
||||
?
|
||||
</div>
|
||||
<span class="text-neutral-600 text-sm">{guild.guild_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right font-medium text-neutral-900 text-sm">
|
||||
{formatMemoryFromBytes(guild.memory)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
|
||||
{formatNumberLocal(guild.member_count)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
|
||||
{formatNumberLocal(guild.session_count)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-neutral-900 text-sm">
|
||||
{formatNumberLocal(guild.presence_count)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const GuildTable: FC<{config: Config; guilds: Array<ProcessMemoryStats>}> = ({config, guilds}) => {
|
||||
if (guilds.length === 0) {
|
||||
return <div class="p-6 text-center text-neutral-600">No guilds in memory</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="border-neutral-200 border-b bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider">Rank</th>
|
||||
<th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider">Guild</th>
|
||||
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">RAM Usage</th>
|
||||
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Members</th>
|
||||
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase tracking-wider">Presences</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200">
|
||||
{guilds.map((guild, index) => (
|
||||
<GuildRow config={config} guild={guild} rank={index + 1} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SuccessView: FC<{
|
||||
config: Config;
|
||||
adminAcls: Array<string>;
|
||||
nodeStats: NodeStatsResponse | null;
|
||||
guilds: Array<ProcessMemoryStats>;
|
||||
reloadResult: number | undefined;
|
||||
csrfToken: string;
|
||||
}> = ({config, adminAcls, nodeStats, guilds, reloadResult, csrfToken}) => {
|
||||
const canReloadAll = adminAcls.includes('gateway:reload_all') || adminAcls.includes('*');
|
||||
|
||||
return (
|
||||
<VStack gap={6}>
|
||||
<PageHeader
|
||||
title="Gateway"
|
||||
actions={
|
||||
canReloadAll && (
|
||||
<form method="post" action={`${config.basePath}/gateway?action=reload_all`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
onclick="return confirm('Are you sure you want to reload all guilds in memory? This may take several minutes.');"
|
||||
>
|
||||
Reload All Guilds
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{reloadResult !== undefined && <Alert variant="success">Successfully reloaded {reloadResult} guilds!</Alert>}
|
||||
|
||||
{nodeStats && <NodeStatsSection stats={nodeStats} />}
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white shadow-sm">
|
||||
<VStack gap={2} class="border-neutral-200 border-b p-6">
|
||||
<Heading level={2}>Guild Memory Leaderboard (Top 100)</Heading>
|
||||
<Text size="sm" color="muted">
|
||||
Guilds ranked by memory usage, showing the top 100 consumers
|
||||
</Text>
|
||||
</VStack>
|
||||
<GuildTable config={config} guilds={guilds} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export async function GatewayPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
adminAcls,
|
||||
reloadResult,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
}: GatewayPageProps) {
|
||||
const nodeStatsResult = await getNodeStats(config, session);
|
||||
const guildStatsResult = await getGuildMemoryStats(config, session, 100);
|
||||
|
||||
const content = guildStatsResult.ok ? (
|
||||
<SuccessView
|
||||
config={config}
|
||||
adminAcls={adminAcls}
|
||||
nodeStats={nodeStatsResult.ok ? nodeStatsResult.data : null}
|
||||
guilds={guildStatsResult.data.guilds}
|
||||
reloadResult={reloadResult}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Heading level={1}>Gateway</Heading>
|
||||
<ErrorAlert error={getErrorMessage(guildStatsResult.error)} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Gateway"
|
||||
activePage="gateway"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
{content}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
152
packages/admin/src/pages/GiftCodesPage.tsx
Normal file
152
packages/admin/src/pages/GiftCodesPage.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
|
||||
import {PageLayout} from '@fluxer/admin/src/components/ui/Layout/PageLayout';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Select} from '@fluxer/admin/src/components/ui/Select';
|
||||
import {Textarea} from '@fluxer/admin/src/components/ui/Textarea';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import {SliderInput} from '@fluxer/ui/src/components/SliderInput';
|
||||
import type {FC} from 'hono/jsx';
|
||||
|
||||
const MAX_GIFT_CODES = 100;
|
||||
const DEFAULT_GIFT_COUNT = 10;
|
||||
|
||||
const GIFT_PRODUCT_OPTIONS: Array<{value: string; label: string}> = [
|
||||
{value: 'gift_1_month', label: 'Gift - 1 Month subscription'},
|
||||
{value: 'gift_1_year', label: 'Gift - 1 Year subscription'},
|
||||
{value: 'gift_visionary', label: 'Gift - Visionary lifetime'},
|
||||
];
|
||||
|
||||
export interface GiftCodesPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
adminAcls: Array<string>;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
generatedCodes?: Array<string>;
|
||||
}
|
||||
|
||||
export const GiftCodesPage: FC<GiftCodesPageProps> = ({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
adminAcls,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
generatedCodes,
|
||||
}) => {
|
||||
const hasGeneratePermission = hasPermission(adminAcls, AdminACLs.GIFT_CODES_GENERATE);
|
||||
const codesValue = generatedCodes ? generatedCodes.join('\n') : '';
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Gift Codes"
|
||||
activePage="gift-codes"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
{hasGeneratePermission ? (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<VStack gap={6}>
|
||||
<Card padding="md">
|
||||
<VStack gap={2}>
|
||||
<Heading level={1} size="2xl">
|
||||
Generate Gift Codes
|
||||
</Heading>
|
||||
</VStack>
|
||||
<form id="gift-form" class="mt-4" method="post" action={`${config.basePath}/gift-codes`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<VStack gap={4}>
|
||||
<VStack gap={4}>
|
||||
<VStack gap={1}>
|
||||
<SliderInput
|
||||
id="gift-count-slider"
|
||||
name="count"
|
||||
label="How many codes"
|
||||
min={1}
|
||||
max={MAX_GIFT_CODES}
|
||||
value={DEFAULT_GIFT_COUNT}
|
||||
rangeText={`Range: 1-${MAX_GIFT_CODES}`}
|
||||
/>
|
||||
<Text size="xs" color="muted">
|
||||
Select the number of gift codes to generate.
|
||||
</Text>
|
||||
</VStack>
|
||||
<FormFieldGroup
|
||||
label="Product"
|
||||
helper="Generated codes are rendered as https://fluxer.gift/<code>."
|
||||
>
|
||||
<Select id="gift-product-type" name="product_type" options={GIFT_PRODUCT_OPTIONS} />
|
||||
</FormFieldGroup>
|
||||
<Button type="submit" variant="primary">
|
||||
Generate Gift Codes
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
<VStack gap={2} class="mt-4">
|
||||
<FormFieldGroup label="Generated URLs" helper="Copy one URL per line when sharing codes.">
|
||||
<Textarea
|
||||
id="gift-generated-urls"
|
||||
name="generated_urls"
|
||||
readonly
|
||||
rows={10}
|
||||
placeholder="Full gift URLs will appear here after generation."
|
||||
value={codesValue}
|
||||
/>
|
||||
</FormFieldGroup>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card>
|
||||
</VStack>
|
||||
</PageLayout>
|
||||
) : (
|
||||
<Card padding="md">
|
||||
<Heading level={1} size="2xl">
|
||||
Gift Codes
|
||||
</Heading>
|
||||
<Text color="muted" size="sm" class="mt-2">
|
||||
You do not have permission to generate gift codes.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
521
packages/admin/src/pages/GuildDetailPage.tsx
Normal file
521
packages/admin/src/pages/GuildDetailPage.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
|
||||
import type {Archive} from '@fluxer/admin/src/api/Archives';
|
||||
import {listArchives} from '@fluxer/admin/src/api/Archives';
|
||||
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
|
||||
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
|
||||
import {lookupGuild} from '@fluxer/admin/src/api/Guilds';
|
||||
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
|
||||
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import {EmojisTab} from '@fluxer/admin/src/pages/guild_detail/tabs/EmojisTab';
|
||||
import {FeaturesTab} from '@fluxer/admin/src/pages/guild_detail/tabs/FeaturesTab';
|
||||
import {MembersTab} from '@fluxer/admin/src/pages/guild_detail/tabs/MembersTab';
|
||||
import {ModerationTab} from '@fluxer/admin/src/pages/guild_detail/tabs/ModerationTab';
|
||||
import {OverviewTab} from '@fluxer/admin/src/pages/guild_detail/tabs/OverviewTab';
|
||||
import {SettingsTab} from '@fluxer/admin/src/pages/guild_detail/tabs/SettingsTab';
|
||||
import {StickersTab} from '@fluxer/admin/src/pages/guild_detail/tabs/StickersTab';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {formatTimestamp} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
|
||||
import {getGuildIconUrl, getInitials as getInitialsFromName} from '@fluxer/ui/src/utils/FormatUser';
|
||||
import type {Child, FC} from 'hono/jsx';
|
||||
|
||||
interface GuildDetailPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
guildId: string;
|
||||
referrer?: string | undefined;
|
||||
tab?: string | undefined;
|
||||
page?: string | undefined;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
path: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function canViewArchives(adminAcls: Array<string>): boolean {
|
||||
return adminAcls.some(
|
||||
(acl) =>
|
||||
acl === AdminACLs.ARCHIVE_VIEW_ALL || acl === AdminACLs.ARCHIVE_TRIGGER_GUILD || acl === AdminACLs.WILDCARD,
|
||||
);
|
||||
}
|
||||
|
||||
function canManageAssets(adminAcls: Array<string>): boolean {
|
||||
return hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
|
||||
}
|
||||
|
||||
function getStatusText(archive: Archive): string {
|
||||
if (archive.failed_at) {
|
||||
return 'Failed';
|
||||
}
|
||||
if (archive.completed_at) {
|
||||
return 'Completed';
|
||||
}
|
||||
return archive.progress_step ?? 'In Progress';
|
||||
}
|
||||
|
||||
const RenderTabs: FC<{config: Config; tabs: Array<Tab>}> = ({config, tabs}) => {
|
||||
return (
|
||||
<VStack gap={0} class="mb-6 border-neutral-200 border-b">
|
||||
<nav class="-mb-px flex flex-wrap gap-x-4 sm:gap-x-6">
|
||||
{tabs.map((tab) => (
|
||||
<a
|
||||
href={`${config.basePath}${tab.path}`}
|
||||
class={
|
||||
tab.active
|
||||
? 'border-neutral-900 border-b-2 px-1 py-3 font-medium text-neutral-900 text-sm'
|
||||
: 'px-1 py-3 font-medium text-neutral-500 text-sm hover:border-neutral-300 hover:border-b-2 hover:text-neutral-700'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderArchiveTable: FC<{config: Config; archives: Array<Archive>}> = ({config, archives}) => {
|
||||
if (archives.length === 0) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<EmptyState title="No archives yet for this guild." />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={0} class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-neutral-200">
|
||||
<thead class="bg-neutral-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">
|
||||
Requested At
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-neutral-700 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200">
|
||||
{archives.map((archive) => (
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-neutral-900 text-sm">{formatTimestamp(archive.requested_at)}</td>
|
||||
<td class="px-4 py-3 text-neutral-900 text-sm">
|
||||
{getStatusText(archive)} ({archive.progress_percent}%)
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{archive.completed_at ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
href={`${config.basePath}/archives/download?subject_type=guild&subject_id=${archive.subject_id}&archive_id=${archive.archive_id}`}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="sm" color="muted">
|
||||
Pending
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const ArchivesTab: FC<{
|
||||
config: Config;
|
||||
session: Session;
|
||||
guildId: string;
|
||||
csrfToken: string;
|
||||
}> = async ({config, session, guildId, csrfToken}) => {
|
||||
const result = await listArchives(config, session, 'guild', guildId, false);
|
||||
|
||||
return (
|
||||
<VStack gap={6}>
|
||||
<HStack gap={3} class="flex-wrap items-center justify-between">
|
||||
<Heading level={2}>Guild Archives</Heading>
|
||||
<form method="post" action={`${config.basePath}/guilds/${guildId}?tab=archives&action=trigger_archive`}>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<Button type="submit" variant="primary">
|
||||
Trigger Archive
|
||||
</Button>
|
||||
</form>
|
||||
</HStack>
|
||||
{result.ok ? (
|
||||
<RenderArchiveTable config={config} archives={result.data.archives} />
|
||||
) : (
|
||||
<ErrorCard title="Failed to load archives" message={getErrorMessage(result.error)} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderGuildHeader: FC<{
|
||||
config: Config;
|
||||
guild: GuildLookupResult;
|
||||
}> = ({config, guild}) => {
|
||||
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
|
||||
|
||||
return (
|
||||
<VStack gap={0} class="mb-6 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<HStack gap={6} class="flex-col items-start sm:flex-row">
|
||||
{iconUrl ? (
|
||||
<VStack gap={0} class="flex flex-shrink-0 items-center justify-center sm:block">
|
||||
<img src={iconUrl} alt={guild.name} class="h-24 w-24 rounded-full" />
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={0} class="flex flex-shrink-0 items-center justify-center sm:block">
|
||||
<VStack
|
||||
gap={0}
|
||||
align="center"
|
||||
class="h-24 w-24 justify-center rounded-full bg-neutral-200 text-center font-semibold text-base text-neutral-600"
|
||||
>
|
||||
{getInitialsFromName(guild.name)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
<VStack gap={3} class="min-w-0 flex-1">
|
||||
<Heading level={1} size="xl">
|
||||
{guild.name}
|
||||
</Heading>
|
||||
<VStack gap={2} class="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" class="font-medium" color="muted">
|
||||
Guild ID:
|
||||
</Text>
|
||||
<Text size="sm" class="break-all">
|
||||
{guild.id}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" class="font-medium" color="muted">
|
||||
Owner ID:
|
||||
</Text>
|
||||
<a
|
||||
href={`${config.basePath}/users/${guild.owner_id}`}
|
||||
class="block text-neutral-900 text-sm hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{guild.owner_id}
|
||||
</a>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderTabContentProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
guild: GuildLookupResult;
|
||||
adminAcls: Array<string>;
|
||||
guildId: string;
|
||||
activeTab: string;
|
||||
currentPage: number;
|
||||
assetVersion: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
async function renderTabContent({
|
||||
config,
|
||||
session,
|
||||
guild,
|
||||
adminAcls,
|
||||
guildId,
|
||||
activeTab,
|
||||
currentPage,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
}: RenderTabContentProps) {
|
||||
switch (activeTab) {
|
||||
case 'members':
|
||||
return await MembersTab({
|
||||
config,
|
||||
session,
|
||||
guildId,
|
||||
adminAcls,
|
||||
page: currentPage,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
});
|
||||
case 'settings':
|
||||
return (
|
||||
<SettingsTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
|
||||
);
|
||||
case 'features':
|
||||
return (
|
||||
<FeaturesTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
|
||||
);
|
||||
case 'moderation':
|
||||
return (
|
||||
<ModerationTab config={config} guild={guild} guildId={guildId} adminAcls={adminAcls} csrfToken={csrfToken} />
|
||||
);
|
||||
case 'archives':
|
||||
return <ArchivesTab config={config} session={session} guildId={guildId} csrfToken={csrfToken} />;
|
||||
case 'emojis':
|
||||
return await EmojisTab({config, session, guildId, adminAcls, csrfToken});
|
||||
case 'stickers':
|
||||
return await StickersTab({config, session, guildId, adminAcls, csrfToken});
|
||||
default:
|
||||
return <OverviewTab config={config} guild={guild} csrfToken={csrfToken} />;
|
||||
}
|
||||
}
|
||||
|
||||
const RenderGuildContent: FC<{
|
||||
config: Config;
|
||||
guild: GuildLookupResult;
|
||||
adminAcls: Array<string>;
|
||||
guildId: string;
|
||||
referrer: string | undefined;
|
||||
activeTab: string;
|
||||
tabContent: Child | null;
|
||||
}> = ({config, guild, adminAcls, guildId, referrer, activeTab, tabContent}) => {
|
||||
const tabList: Array<Tab> = [
|
||||
{
|
||||
label: 'Overview',
|
||||
path: `/guilds/${guildId}?tab=overview`,
|
||||
active: activeTab === 'overview',
|
||||
},
|
||||
{
|
||||
label: 'Members',
|
||||
path: `/guilds/${guildId}?tab=members`,
|
||||
active: activeTab === 'members',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
path: `/guilds/${guildId}?tab=settings`,
|
||||
active: activeTab === 'settings',
|
||||
},
|
||||
{
|
||||
label: 'Features',
|
||||
path: `/guilds/${guildId}?tab=features`,
|
||||
active: activeTab === 'features',
|
||||
},
|
||||
{
|
||||
label: 'Moderation',
|
||||
path: `/guilds/${guildId}?tab=moderation`,
|
||||
active: activeTab === 'moderation',
|
||||
},
|
||||
];
|
||||
|
||||
if (canViewArchives(adminAcls)) {
|
||||
tabList.push({
|
||||
label: 'Archives',
|
||||
path: `/guilds/${guildId}?tab=archives`,
|
||||
active: activeTab === 'archives',
|
||||
});
|
||||
}
|
||||
|
||||
if (canManageAssets(adminAcls)) {
|
||||
tabList.push({
|
||||
label: 'Emojis',
|
||||
path: `/guilds/${guildId}?tab=emojis`,
|
||||
active: activeTab === 'emojis',
|
||||
});
|
||||
tabList.push({
|
||||
label: 'Stickers',
|
||||
path: `/guilds/${guildId}?tab=stickers`,
|
||||
active: activeTab === 'stickers',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} class="mx-auto max-w-7xl">
|
||||
<VStack gap={0} class="mb-6">
|
||||
<a
|
||||
href={`${config.basePath}${referrer ?? '/guilds'}`}
|
||||
class="inline-flex items-center gap-2 text-neutral-600 transition-colors hover:text-neutral-900"
|
||||
>
|
||||
<span class="text-lg">←</span>
|
||||
Back to Guilds
|
||||
</a>
|
||||
</VStack>
|
||||
<RenderGuildHeader config={config} guild={guild} />
|
||||
<RenderTabs config={config} tabs={tabList} />
|
||||
{tabContent}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderNotFoundContent: FC<{config: Config}> = ({config}) => {
|
||||
return (
|
||||
<VStack gap={0} class="mx-auto max-w-4xl">
|
||||
<VStack gap={6} class="rounded-lg border border-neutral-200 bg-white p-12 text-center">
|
||||
<Heading level={2} size="base">
|
||||
Guild Not Found
|
||||
</Heading>
|
||||
<Text color="muted">The requested guild could not be found.</Text>
|
||||
<Button variant="primary" size="small" href={`${config.basePath}/guilds`}>
|
||||
<span class="text-lg">←</span>
|
||||
Back to Guilds
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderApiError: FC<{config: Config; errorMessage: string}> = ({config, errorMessage}) => {
|
||||
return (
|
||||
<VStack gap={0} class="mx-auto max-w-4xl">
|
||||
<VStack gap={6} class="rounded-lg border border-neutral-200 bg-white p-12 text-center">
|
||||
<Heading level={2} size="base">
|
||||
Error
|
||||
</Heading>
|
||||
<Text color="muted">{errorMessage}</Text>
|
||||
<Button variant="primary" size="small" href={`${config.basePath}/guilds`}>
|
||||
<span class="text-lg">←</span>
|
||||
Back to Guilds
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export async function GuildDetailPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
guildId,
|
||||
referrer,
|
||||
tab,
|
||||
page,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
}: GuildDetailPageProps) {
|
||||
const result = await lookupGuild(config, session, guildId);
|
||||
|
||||
const adminAcls = currentAdmin?.acls ?? [];
|
||||
|
||||
let activeTab = tab ?? 'overview';
|
||||
const validTabs = ['overview', 'settings', 'features', 'moderation', 'members', 'archives', 'emojis', 'stickers'];
|
||||
if (!validTabs.includes(activeTab)) {
|
||||
activeTab = 'overview';
|
||||
}
|
||||
|
||||
if (activeTab === 'archives' && !canViewArchives(adminAcls)) {
|
||||
activeTab = 'overview';
|
||||
}
|
||||
if ((activeTab === 'emojis' || activeTab === 'stickers') && !canManageAssets(adminAcls)) {
|
||||
activeTab = 'overview';
|
||||
}
|
||||
|
||||
let currentPage = 0;
|
||||
if (page) {
|
||||
const parsed = parseInt(page, 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0) {
|
||||
currentPage = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Guild Details"
|
||||
activePage="guilds"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<RenderApiError config={config} errorMessage={getErrorMessage(result.error)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Guild Not Found"
|
||||
activePage="guilds"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<RenderNotFoundContent config={config} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = result.data;
|
||||
const tabContent = await renderTabContent({
|
||||
config,
|
||||
session,
|
||||
guild,
|
||||
adminAcls,
|
||||
guildId,
|
||||
activeTab,
|
||||
currentPage,
|
||||
assetVersion,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Guild Details"
|
||||
activePage="guilds"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
<RenderGuildContent
|
||||
config={config}
|
||||
guild={guild}
|
||||
adminAcls={adminAcls}
|
||||
guildId={guildId}
|
||||
referrer={referrer}
|
||||
activeTab={activeTab}
|
||||
tabContent={tabContent}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
251
packages/admin/src/pages/GuildsPage.tsx
Normal file
251
packages/admin/src/pages/GuildsPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
|
||||
import {searchGuilds} from '@fluxer/admin/src/api/Guilds';
|
||||
import {ErrorAlert} from '@fluxer/admin/src/components/ErrorDisplay';
|
||||
import {Layout} from '@fluxer/admin/src/components/Layout';
|
||||
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
|
||||
import {PageHeader} from '@fluxer/admin/src/components/ui/Layout/PageHeader';
|
||||
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
|
||||
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
|
||||
import type {Session} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
|
||||
import type {Flash} from '@fluxer/hono/src/Flash';
|
||||
import type {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {EmptyState} from '@fluxer/ui/src/components/EmptyState';
|
||||
import {Pagination} from '@fluxer/ui/src/components/Pagination';
|
||||
import {SearchForm} from '@fluxer/ui/src/components/SearchForm';
|
||||
import {getGuildIconUrl, getInitials as getInitialsFromName} from '@fluxer/ui/src/utils/FormatUser';
|
||||
import type {FC} from 'hono/jsx';
|
||||
import type {z} from 'zod';
|
||||
|
||||
interface GuildsPageProps {
|
||||
config: Config;
|
||||
session: Session;
|
||||
currentAdmin: UserAdminResponse | undefined;
|
||||
flash: Flash | undefined;
|
||||
csrfToken: string;
|
||||
searchQuery: string | undefined;
|
||||
page: number;
|
||||
assetVersion: string;
|
||||
}
|
||||
|
||||
const GuildCard: FC<{config: Config; guild: z.infer<typeof GuildAdminResponse>}> = ({config, guild}) => {
|
||||
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
|
||||
|
||||
return (
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-colors hover:border-neutral-300">
|
||||
<div class="p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{iconUrl ? (
|
||||
<div class="flex flex-shrink-0 items-center justify-center sm:block">
|
||||
<img src={iconUrl} alt={guild.name} class="h-16 w-16 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex flex-shrink-0 items-center justify-center sm:block">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-200 font-medium text-base text-neutral-600">
|
||||
{getInitialsFromName(guild.name)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Heading level={2} size="base">
|
||||
{guild.name}
|
||||
</Heading>
|
||||
{guild.features.length > 0 && (
|
||||
<span class="rounded bg-purple-100 px-2 py-0.5 text-purple-700 text-xs uppercase">Featured</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<Text size="sm" color="muted" class="break-all">
|
||||
ID: {guild.id}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
Members: {guild.member_count}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
Owner:{' '}
|
||||
<a
|
||||
href={`${config.basePath}/users/${guild.owner_id}`}
|
||||
class="transition-colors hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{guild.owner_id}
|
||||
</a>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" size="small" href={`${config.basePath}/guilds/${guild.id}`}>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GuildsGrid: FC<{config: Config; guilds: Array<z.infer<typeof GuildAdminResponse>>}> = ({config, guilds}) => {
|
||||
return (
|
||||
<Grid cols={1} gap="md">
|
||||
{guilds.map((guild) => (
|
||||
<GuildCard config={config} guild={guild} />
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const GuildsEmptyState: FC = () => {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Enter a search query to find guilds"
|
||||
message="Search by Guild ID, Guild Name, Vanity URL, or other attributes"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptySearchResults: FC = () => {
|
||||
return <EmptyState title="No guilds found" message="Try adjusting your search query" />;
|
||||
};
|
||||
|
||||
export async function GuildsPage({
|
||||
config,
|
||||
session,
|
||||
currentAdmin,
|
||||
flash,
|
||||
csrfToken,
|
||||
searchQuery,
|
||||
page,
|
||||
assetVersion,
|
||||
}: GuildsPageProps) {
|
||||
const limit = 50;
|
||||
const offset = page * limit;
|
||||
|
||||
let content = <div />;
|
||||
|
||||
if (searchQuery && searchQuery.trim() !== '') {
|
||||
const result = await searchGuilds(config, session, searchQuery.trim(), limit, offset);
|
||||
|
||||
if (result.ok) {
|
||||
const {guilds, total} = result.data;
|
||||
|
||||
content = (
|
||||
<div class="mx-auto max-w-7xl space-y-6">
|
||||
<PageHeader
|
||||
title="Guilds"
|
||||
actions={
|
||||
<Text size="sm" color="muted">
|
||||
Found {total} results (showing {guilds.length})
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<SearchForm
|
||||
action="/guilds"
|
||||
basePath={config.basePath}
|
||||
fields={[
|
||||
{
|
||||
name: 'q',
|
||||
type: 'text',
|
||||
value: searchQuery,
|
||||
placeholder: 'Search by ID, guild name, or vanity URL...',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
]}
|
||||
layout="horizontal"
|
||||
/>
|
||||
{guilds.length === 0 ? (
|
||||
<EmptySearchResults />
|
||||
) : (
|
||||
<>
|
||||
<GuildsGrid config={config} guilds={guilds} />
|
||||
<Pagination
|
||||
basePath={config.basePath}
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil(total / limit)}
|
||||
buildUrlFn={(p) => `/guilds${buildPaginationUrl(p, {q: searchQuery})}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div class="mx-auto max-w-7xl space-y-6">
|
||||
<PageHeader title="Guilds" />
|
||||
<SearchForm
|
||||
action="/guilds"
|
||||
basePath={config.basePath}
|
||||
fields={[
|
||||
{
|
||||
name: 'q',
|
||||
type: 'text',
|
||||
value: searchQuery,
|
||||
placeholder: 'Search by ID, guild name, or vanity URL...',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
]}
|
||||
layout="horizontal"
|
||||
/>
|
||||
<ErrorAlert error={getErrorMessage(result.error)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
content = (
|
||||
<div class="mx-auto max-w-7xl space-y-6">
|
||||
<PageHeader title="Guilds" />
|
||||
<SearchForm
|
||||
action="/guilds"
|
||||
basePath={config.basePath}
|
||||
fields={[
|
||||
{
|
||||
name: 'q',
|
||||
type: 'text',
|
||||
value: searchQuery,
|
||||
placeholder: 'Search by ID, guild name, or vanity URL...',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
]}
|
||||
layout="horizontal"
|
||||
/>
|
||||
<GuildsEmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
csrfToken={csrfToken}
|
||||
title="Guilds"
|
||||
activePage="guilds"
|
||||
config={config}
|
||||
session={session}
|
||||
currentAdmin={currentAdmin}
|
||||
flash={flash}
|
||||
assetVersion={assetVersion}
|
||||
>
|
||||
{content}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user