fix: various fixes to sentry-reported errors and more
This commit is contained in:
@@ -166,7 +166,7 @@ export function createAdminApp(options: CreateAdminAppOptions): AdminAppResult {
|
||||
}
|
||||
});
|
||||
|
||||
app.onError(createAdminErrorHandler(logger, config.env === 'development'));
|
||||
app.onError(createAdminErrorHandler(logger, config.env === 'development', config.basePath));
|
||||
|
||||
app.get('/_health', (c) => c.json({status: 'ok'}));
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
LookupUserResponse,
|
||||
UserAdminResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {WebAuthnCredentialListResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
export async function getCurrentAdmin(config: Config, session: Session): Promise<ApiResult<UserAdminResponse | null>> {
|
||||
const client = new ApiClient(config, session);
|
||||
@@ -417,3 +418,29 @@ export async function sendPasswordReset(
|
||||
const client = new ApiClient(config, session);
|
||||
return client.postVoid('/admin/users/send-password-reset', {user_id: userId}, auditLogReason);
|
||||
}
|
||||
|
||||
export async function listWebAuthnCredentials(
|
||||
config: Config,
|
||||
session: Session,
|
||||
userId: string,
|
||||
): Promise<ApiResult<WebAuthnCredentialListResponse>> {
|
||||
const client = new ApiClient(config, session);
|
||||
return client.post<WebAuthnCredentialListResponse>('/admin/users/list-webauthn-credentials', {
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(
|
||||
config: Config,
|
||||
session: Session,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
auditLogReason?: string,
|
||||
): Promise<ApiResult<void>> {
|
||||
const client = new ApiClient(config, session);
|
||||
return client.postVoid(
|
||||
'/admin/users/delete-webauthn-credential',
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,13 @@ import type {Context, ErrorHandler} from 'hono';
|
||||
|
||||
const KNOWN_HTTP_STATUS_CODES: Array<HttpStatusCode> = Object.values(HttpStatus);
|
||||
|
||||
export function createAdminErrorHandler(logger: LoggerInterface, includeStack: boolean): ErrorHandler {
|
||||
export function createAdminErrorHandler(
|
||||
logger: LoggerInterface,
|
||||
includeStack: boolean,
|
||||
basePath: string,
|
||||
): ErrorHandler {
|
||||
const homeUrl = basePath || '/';
|
||||
|
||||
return createErrorHandler({
|
||||
includeStack,
|
||||
logError: (error, c) => {
|
||||
@@ -55,9 +61,9 @@ export function createAdminErrorHandler(logger: LoggerInterface, includeStack: b
|
||||
customHandler: (error, c) => {
|
||||
const status = getStatus(error) ?? 500;
|
||||
if (status === 404) {
|
||||
return renderNotFound(c);
|
||||
return renderNotFound(c, homeUrl);
|
||||
}
|
||||
return renderError(c, status);
|
||||
return renderError(c, status, homeUrl);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -67,7 +73,7 @@ function getStatus(error: Error): number | null {
|
||||
return typeof statusValue === 'number' ? statusValue : null;
|
||||
}
|
||||
|
||||
function renderNotFound(c: Context): Response | Promise<Response> {
|
||||
function renderNotFound(c: Context, homeUrl: string): Response | Promise<Response> {
|
||||
c.status(404);
|
||||
return c.html(
|
||||
<ErrorPage
|
||||
@@ -75,13 +81,13 @@ function renderNotFound(c: Context): Response | Promise<Response> {
|
||||
title="Page not found"
|
||||
description="The page you are looking for does not exist or has been moved."
|
||||
staticCdnEndpoint={CdnEndpoints.STATIC}
|
||||
homeUrl="/admin"
|
||||
homeUrl={homeUrl}
|
||||
homeLabel="Go to admin"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderError(c: Context, status: number): Response | Promise<Response> {
|
||||
function renderError(c: Context, status: number, homeUrl: string): Response | Promise<Response> {
|
||||
const statusCode = isHttpStatusCode(status) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
c.status(statusCode);
|
||||
return c.html(
|
||||
@@ -90,7 +96,7 @@ function renderError(c: Context, status: number): Response | Promise<Response> {
|
||||
title="Something went wrong"
|
||||
description="An unexpected error occurred. Please try again later."
|
||||
staticCdnEndpoint={CdnEndpoints.STATIC}
|
||||
homeUrl="/admin"
|
||||
homeUrl={homeUrl}
|
||||
homeLabel="Go to admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -105,6 +105,14 @@ const ReindexControls: FC<{config: Config; csrfToken: string}> = ({config, csrfT
|
||||
<ReindexButton config={config} title="Guilds" indexType="guilds" csrfToken={csrfToken} />
|
||||
<ReindexButton config={config} title="Reports" indexType="reports" csrfToken={csrfToken} />
|
||||
<ReindexButton config={config} title="Audit Logs" indexType="audit_logs" csrfToken={csrfToken} />
|
||||
<Heading level={3} class="subtitle mt-6 text-neutral-900">
|
||||
Discovery Index
|
||||
</Heading>
|
||||
<Text color="muted" size="sm" class="mb-3">
|
||||
Rebuilds the discovery search index for all approved discoverable communities. This syncs guild metadata,
|
||||
descriptions, categories, and online counts.
|
||||
</Text>
|
||||
<ReindexButton config={config} title="Discovery Index" indexType="discovery" csrfToken={csrfToken} />
|
||||
<Heading level={3} class="subtitle mt-6 text-neutral-900">
|
||||
Guild-specific Search Indexes
|
||||
</Heading>
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
ListUserSessionsResponse,
|
||||
UserAdminResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {WebAuthnCredentialListResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {BackButton, NotFoundView} from '@fluxer/ui/src/components/Navigation';
|
||||
import {formatDiscriminator, getUserAvatarUrl, getUserBannerUrl} from '@fluxer/ui/src/utils/FormatUser';
|
||||
import type {FC} from 'hono/jsx';
|
||||
@@ -170,6 +171,7 @@ export const UserDetailPage: FC<UserDetailPageProps> = async ({
|
||||
let sessionsResult: {ok: true; data: ListUserSessionsResponse} | {ok: false; error: ApiError} | null = null;
|
||||
let guildsResult: {ok: true; data: ListUserGuildsResponse} | {ok: false; error: ApiError} | null = null;
|
||||
let dmChannelsResult: {ok: true; data: ListUserDmChannelsResponse} | {ok: false; error: ApiError} | null = null;
|
||||
let webAuthnCredentials: WebAuthnCredentialListResponse | null = null;
|
||||
let messageShredStatusResult:
|
||||
| {ok: true; data: messagesApi.MessageShredStatusResponse}
|
||||
| {ok: false; error: ApiError}
|
||||
@@ -182,6 +184,13 @@ export const UserDetailPage: FC<UserDetailPageProps> = async ({
|
||||
|
||||
if (activeTab === 'account') {
|
||||
sessionsResult = await usersApi.listUserSessions(config, session, userId);
|
||||
const hasWebAuthn = user.authenticator_types.includes(2);
|
||||
if (hasWebAuthn) {
|
||||
const credResult = await usersApi.listWebAuthnCredentials(config, session, userId);
|
||||
if (credResult.ok) {
|
||||
webAuthnCredentials = credResult.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTab === 'guilds') {
|
||||
@@ -304,6 +313,7 @@ export const UserDetailPage: FC<UserDetailPageProps> = async ({
|
||||
user={user}
|
||||
userId={userId}
|
||||
sessionsResult={sessionsResult}
|
||||
webAuthnCredentials={webAuthnCredentials}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,10 @@ import type {
|
||||
UserAdminResponse,
|
||||
UserSessionResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {
|
||||
WebAuthnCredentialListResponse,
|
||||
WebAuthnCredentialResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {Button} from '@fluxer/ui/src/components/Button';
|
||||
import {Card} from '@fluxer/ui/src/components/Card';
|
||||
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
|
||||
@@ -43,10 +47,18 @@ interface AccountTabProps {
|
||||
user: UserAdminResponse;
|
||||
userId: string;
|
||||
sessionsResult: {ok: true; data: ListUserSessionsResponse} | {ok: false; error: ApiError} | null;
|
||||
webAuthnCredentials: WebAuthnCredentialListResponse | null;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
export function AccountTab({config: _config, user, userId: _userId, sessionsResult, csrfToken}: AccountTabProps) {
|
||||
export function AccountTab({
|
||||
config: _config,
|
||||
user,
|
||||
userId: _userId,
|
||||
sessionsResult,
|
||||
webAuthnCredentials,
|
||||
csrfToken,
|
||||
}: AccountTabProps) {
|
||||
return (
|
||||
<VStack gap={6}>
|
||||
<Card padding="md">
|
||||
@@ -266,10 +278,82 @@ export function AccountTab({config: _config, user, userId: _userId, sessionsResu
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{webAuthnCredentials && webAuthnCredentials.length > 0 && (
|
||||
<Card padding="md">
|
||||
<VStack gap={4}>
|
||||
<Heading level={2} size="base">
|
||||
WebAuthn Credentials
|
||||
</Heading>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-neutral-200 border-b text-left">
|
||||
<th class="pb-2 font-medium text-neutral-600">Name</th>
|
||||
<th class="pb-2 font-medium text-neutral-600">Created</th>
|
||||
<th class="pb-2 font-medium text-neutral-600">Last Used</th>
|
||||
<th class="pb-2 font-medium text-neutral-600" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webAuthnCredentials.map((credential) => (
|
||||
<WebAuthnCredentialRow credential={credential} csrfToken={csrfToken} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const WebAuthnCredentialRow: FC<{credential: WebAuthnCredentialResponse; csrfToken: string}> = ({
|
||||
credential,
|
||||
csrfToken,
|
||||
}) => {
|
||||
function formatTimestamp(value: string): string {
|
||||
const [datePart, timePartRaw] = value.split('T');
|
||||
if (!datePart || !timePartRaw) return value;
|
||||
const timePart = timePartRaw.replace('Z', '').split('.')[0] ?? timePartRaw;
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr class="border-neutral-100 border-b">
|
||||
<td class="py-2 pr-4">
|
||||
<Text size="sm" class="text-neutral-900">
|
||||
{credential.name}
|
||||
</Text>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<Text size="sm" class="text-neutral-900">
|
||||
{formatTimestamp(credential.created_at)}
|
||||
</Text>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<Text size="sm" class="text-neutral-900">
|
||||
{credential.last_used_at ? formatTimestamp(credential.last_used_at) : 'Never'}
|
||||
</Text>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<form
|
||||
method="post"
|
||||
action="?action=delete_webauthn_credential&tab=account"
|
||||
onsubmit={`return confirm('Are you sure you want to delete the WebAuthn credential "${credential.name}"?')`}
|
||||
>
|
||||
<CsrfInput token={csrfToken} />
|
||||
<input type="hidden" name="credential_id" value={credential.id} />
|
||||
<Button type="submit" variant="primary" size="small">
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const SessionCard: FC<{session: UserSessionResponse}> = ({session}) => {
|
||||
function formatSessionTimestamp(value: string): string {
|
||||
const [datePart, timePartRaw] = value.split('T');
|
||||
|
||||
@@ -462,6 +462,23 @@ export function createUsersRoutes({config, assetVersion, requireAuth}: RouteFact
|
||||
});
|
||||
}
|
||||
|
||||
case 'delete_webauthn_credential': {
|
||||
const credentialId = getRequiredString(formData, 'credential_id');
|
||||
|
||||
if (!credentialId) {
|
||||
return redirectWithFlash(c, redirectUrl, {
|
||||
message: 'Credential ID is required',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await usersApi.deleteWebAuthnCredential(config, session, userId, credentialId);
|
||||
return redirectWithFlash(c, redirectUrl, {
|
||||
message: result.ok ? 'WebAuthn credential deleted successfully' : 'Failed to delete WebAuthn credential',
|
||||
type: result.ok ? 'success' : 'error',
|
||||
});
|
||||
}
|
||||
|
||||
case 'send_password_reset': {
|
||||
const result = await usersApi.sendPasswordReset(config, session, userId);
|
||||
return redirectWithFlash(c, redirectUrl, {
|
||||
|
||||
Reference in New Issue
Block a user