Phase D
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 14:03:57 +02:00
parent ed8334328e
commit 333ad1cc7d
31 changed files with 964 additions and 15 deletions

View File

@@ -23,14 +23,16 @@ import {
twoFactorDisableRequestSchema,
twoFactorVerifyRequestSchema,
verifyEmailRequestSchema,
ssoConsumeRequestSchema,
} from '@hexahost/contracts';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AuthService } from './auth.service';
import { CurrentUser, Public } from './decorators/current-user.decorator';
import { Public, CurrentUser } from './decorators/current-user.decorator';
import { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service';
import { SsoService } from './sso.service';
@ApiTags('auth')
@Controller('auth')
@@ -40,6 +42,7 @@ export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService,
private readonly ssoService: SsoService,
) {}
@Public()
@@ -105,6 +108,24 @@ export class AuthController {
return this.authService.verifyEmail({ token }, request.ip);
}
@Public()
@Post('sso/consume')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Redeem a one-time WHMCS SSO ticket' })
consumeSso(
@Body(new ZodValidationPipe(ssoConsumeRequestSchema)) body: unknown,
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const payload = body as { ticket: string };
return this.ssoService.consumeTicket(
payload.ticket,
reply,
request.ip,
request.headers['user-agent'],
);
}
@UseGuards(SessionGuard)
@Post('resend-verification')
@HttpCode(HttpStatus.OK)

View File

@@ -17,6 +17,7 @@ import {
import { SessionGuard } from './guards/session.guard';
import { MeController } from './me.controller';
import { SessionService } from './session.service';
import { SsoService } from './sso.service';
@Module({
imports: [AppConfigModule, BillingModule],
@@ -24,6 +25,7 @@ import { SessionService } from './session.service';
providers: [
AuthService,
SessionService,
SsoService,
AuditService,
SessionGuard,
{
@@ -48,6 +50,6 @@ import { SessionService } from './session.service';
},
},
],
exports: [AuthService, SessionService, SessionGuard, AuditService],
exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService],
})
export class AuthModule {}

View File

@@ -26,8 +26,14 @@ export class MeController {
@Get()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'Current user' })
getMe(@CurrentUser() user: { id: string }) {
return { user };
getMe(
@CurrentUser() user: { id: string },
@CurrentSession() session: { impersonation?: { isImpersonation: boolean; impersonatorLabel?: string } },
) {
return {
user,
impersonation: session.impersonation,
};
}
@Get('sessions')

View File

@@ -17,6 +17,7 @@ import {
type SessionContext,
} from '@hexahost/auth';
import type { AppConfig } from '@hexahost/config';
import { Prisma } from '@hexahost/database';
import { APP_CONFIG } from '../config/config.constants';
import { PrismaService } from '../prisma/prisma.service';
@@ -32,6 +33,10 @@ export class SessionService {
userId: string,
ipAddress?: string,
userAgent?: string,
options?: {
isImpersonation?: boolean;
impersonationMetadata?: Record<string, unknown>;
},
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
const token = generateSecureToken();
const tokenHash = hashToken(token);
@@ -44,6 +49,8 @@ export class SessionService {
expiresAt,
ipAddress,
userAgent,
isImpersonation: options?.isImpersonation ?? false,
impersonationMetadata: options?.impersonationMetadata as Prisma.InputJsonValue | undefined,
},
});
@@ -107,6 +114,53 @@ export class SessionService {
issuedAt: session.createdAt,
expiresAt: session.expiresAt,
user: this.toAuthenticatedUser(session.user),
impersonation: session.isImpersonation
? {
isImpersonation: true,
source:
typeof session.impersonationMetadata === 'object' &&
session.impersonationMetadata !== null &&
'source' in session.impersonationMetadata
? String((session.impersonationMetadata as { source?: string }).source)
: undefined,
externalServiceId:
typeof session.impersonationMetadata === 'object' &&
session.impersonationMetadata !== null &&
'externalServiceId' in session.impersonationMetadata
? String(
(session.impersonationMetadata as { externalServiceId?: string })
.externalServiceId ?? '',
) || null
: null,
externalClientId:
typeof session.impersonationMetadata === 'object' &&
session.impersonationMetadata !== null &&
'externalClientId' in session.impersonationMetadata
? String(
(session.impersonationMetadata as { externalClientId?: string })
.externalClientId ?? '',
) || null
: null,
externalUserId:
typeof session.impersonationMetadata === 'object' &&
session.impersonationMetadata !== null &&
'externalUserId' in session.impersonationMetadata
? String(
(session.impersonationMetadata as { externalUserId?: string })
.externalUserId ?? '',
) || null
: null,
impersonatorLabel:
typeof session.impersonationMetadata === 'object' &&
session.impersonationMetadata !== null &&
'impersonatorLabel' in session.impersonationMetadata
? String(
(session.impersonationMetadata as { impersonatorLabel?: string })
.impersonatorLabel ?? '',
) || undefined
: undefined,
}
: undefined,
};
}
@@ -192,4 +246,15 @@ export class SessionService {
roles: roles.length > 0 ? roles : [UserRole.USER],
};
}
toAuthUserResponse(user: {
id: string;
email: string;
username: string;
displayName: string | null;
emailVerifiedAt: Date | null;
platformRoles: Array<{ role: { name: string } }>;
}) {
return this.toAuthenticatedUser(user);
}
}

View File

@@ -0,0 +1,65 @@
import { BadRequestException } from '@nestjs/common';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ALLOWED_EXACT_PATHS = new Set(['/dashboard', '/servers']);
const ALLOWED_SERVER_SUBPAGES = new Set([
'console',
'files',
'properties',
'players',
'worlds',
'backups',
'addons',
]);
export function resolveSsoTargetPath(
requestedPath: string | undefined,
serverId: string,
): string {
const defaultPath = `/servers/${serverId}`;
if (!requestedPath || requestedPath.trim() === '') {
return defaultPath;
}
const trimmed = requestedPath.trim();
if (
trimmed.includes('://') ||
trimmed.startsWith('//') ||
trimmed.includes('\\') ||
trimmed.includes('\0')
) {
throw new BadRequestException('Target path must be a relative application path');
}
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
if (ALLOWED_EXACT_PATHS.has(path)) {
return path;
}
if (!path.startsWith(`/servers/${serverId}`)) {
throw new BadRequestException('Target path must reference the linked server');
}
if (path === `/servers/${serverId}`) {
return path;
}
const suffix = path.slice(`/servers/${serverId}`.length);
const subpage = suffix.startsWith('/') ? suffix.slice(1) : suffix;
if (subpage && ALLOWED_SERVER_SUBPAGES.has(subpage)) {
return `/servers/${serverId}/${subpage}`;
}
throw new BadRequestException('Target path is not allowlisted');
}
export function isUuid(value: string): boolean {
return UUID_PATTERN.test(value);
}

View File

@@ -0,0 +1,255 @@
import { randomUUID } from 'node:crypto';
import {
ForbiddenException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { generateSecureToken, hashToken } from '@hexahost/auth';
import type {
SsoConsumeResponse,
WhmcsCreateSsoRequest,
WhmcsCreateSsoResponse,
} from '@hexahost/contracts';
import type { AppConfig } from '@hexahost/config';
import { APP_CONFIG } from '../config/config.constants';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from './audit.service';
import { resolveSsoTargetPath } from './sso-target.util';
import { SessionService } from './session.service';
const SSO_TICKET_TTL_MS = 60_000;
type InstallationRef = {
id: string;
integrationId: string;
};
@Injectable()
export class SsoService {
constructor(
private readonly prisma: PrismaService,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async createWhmcsServiceSso(
installation: InstallationRef,
externalServiceId: string,
input: WhmcsCreateSsoRequest,
idempotencyKey: string,
): Promise<WhmcsCreateSsoResponse> {
const existingOp = await this.prisma.integrationOperation.findUnique({
where: { idempotencyKey },
});
if (existingOp?.result) {
return existingOp.result as WhmcsCreateSsoResponse;
}
const serviceLink = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId,
},
},
include: {
server: true,
},
});
if (!serviceLink) {
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
}
if (serviceLink.status === 'SUSPENDED' || serviceLink.status === 'TERMINATED') {
throw new ForbiddenException('Service is not eligible for SSO');
}
if (serviceLink.server.billingSuspendedAt) {
throw new ForbiddenException('Server is billing suspended');
}
const clientLink = await this.prisma.whmcsClientLink.findUnique({
where: {
installationId_externalClientId: {
installationId: installation.id,
externalClientId: input.externalClientId,
},
},
});
if (!clientLink || clientLink.userId !== serviceLink.server.userId) {
throw new ForbiddenException('WHMCS client is not linked to this service');
}
const isAdminSso = input.kind === 'admin';
const targetPath = resolveSsoTargetPath(
input.targetPath,
serviceLink.serverId,
);
const plainToken = generateSecureToken();
const tokenHash = hashToken(plainToken);
const expiresAt = new Date(Date.now() + SSO_TICKET_TTL_MS);
const audience = this.config.APP_URL.replace(/\/$/, '');
const ticket = await this.prisma.ssoTicket.create({
data: {
installationId: installation.id,
tokenHash,
kind: isAdminSso ? 'ADMIN' : 'SERVICE',
userId: serviceLink.server.userId,
serverId: serviceLink.serverId,
externalServiceId,
externalClientId: input.externalClientId,
externalUserId: input.externalUserId,
targetPath,
issuer: installation.integrationId,
audience,
nonce: randomUUID(),
expiresAt,
},
});
const redirectUrl = `${audience}/auth/sso?ticket=${encodeURIComponent(plainToken)}`;
const response: WhmcsCreateSsoResponse = {
ticketId: ticket.id,
redirectUrl,
expiresAt: expiresAt.toISOString(),
targetPath,
};
await this.prisma.integrationOperation.create({
data: {
installationId: installation.id,
idempotencyKey,
operation: isAdminSso ? 'sso:admin' : 'sso:service',
externalRef: externalServiceId,
status: 'COMPLETED',
result: response,
},
});
await this.auditService.record({
action: isAdminSso ? 'whmcs.sso.admin_created' : 'whmcs.sso.service_created',
userId: serviceLink.server.userId,
entityType: 'game_server',
entityId: serviceLink.serverId,
metadata: {
externalServiceId,
externalClientId: input.externalClientId,
externalUserId: input.externalUserId,
ticketId: ticket.id,
targetPath,
},
});
return response;
}
async consumeTicket(
ticket: string,
reply: FastifyReply,
ipAddress?: string,
userAgent?: string,
): Promise<SsoConsumeResponse> {
const tokenHash = hashToken(ticket);
const redeemed = await this.prisma.$transaction(async (tx) => {
const record = await tx.ssoTicket.findUnique({
where: { tokenHash },
include: {
user: {
include: {
platformRoles: { include: { role: true } },
},
},
},
});
if (!record || record.usedAt || record.expiresAt <= new Date()) {
throw new UnauthorizedException('Invalid or expired SSO ticket');
}
if (record.audience !== this.config.APP_URL.replace(/\/$/, '')) {
throw new UnauthorizedException('SSO ticket audience mismatch');
}
const updated = await tx.ssoTicket.updateMany({
where: {
id: record.id,
usedAt: null,
expiresAt: { gt: new Date() },
},
data: { usedAt: new Date() },
});
if (updated.count !== 1) {
throw new UnauthorizedException('SSO ticket already used');
}
return record;
});
const isImpersonation = redeemed.kind === 'ADMIN';
const impersonationMetadata = isImpersonation
? {
source: 'whmcs',
externalServiceId: redeemed.externalServiceId,
externalClientId: redeemed.externalClientId,
externalUserId: redeemed.externalUserId,
impersonatorLabel: `WHMCS admin user ${redeemed.externalUserId ?? 'unknown'}`,
}
: undefined;
const { token, expiresAt } = await this.sessionService.createSession(
redeemed.userId,
ipAddress,
userAgent,
{
isImpersonation,
impersonationMetadata,
},
);
this.sessionService.setSessionCookie(reply, token, expiresAt);
await this.auditService.record({
action: isImpersonation ? 'whmcs.sso.admin_consumed' : 'whmcs.sso.service_consumed',
userId: redeemed.userId,
entityType: redeemed.serverId ? 'game_server' : 'user',
entityId: redeemed.serverId ?? redeemed.userId,
ipAddress,
metadata: {
ticketId: redeemed.id,
externalServiceId: redeemed.externalServiceId,
externalUserId: redeemed.externalUserId,
targetPath: redeemed.targetPath,
isImpersonation,
},
});
return {
redirectPath: redeemed.targetPath,
user: this.sessionService.toAuthUserResponse(redeemed.user),
impersonation: isImpersonation
? {
isImpersonation: true,
label:
impersonationMetadata?.impersonatorLabel ??
'Support impersonation session',
}
: undefined,
};
}
}

View File

@@ -13,12 +13,15 @@ import { SkipThrottle } from '@nestjs/throttler';
import {
whmcsChangePackageRequestSchema,
whmcsCreateSsoRequestSchema,
whmcsProvisionServiceRequestSchema,
whmcsSuspendRequestSchema,
whmcsUpsertClientRequestSchema,
} from '@hexahost/contracts';
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
import { SsoService } from '../../auth/sso.service';
import {
WhmcsIntegrationGuard,
type WhmcsAuthenticatedRequest,
@@ -29,7 +32,10 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
@UseGuards(WhmcsIntegrationGuard)
@SkipThrottle()
export class WhmcsIntegrationController {
constructor(private readonly whmcs: WhmcsIntegrationService) {}
constructor(
private readonly whmcs: WhmcsIntegrationService,
private readonly sso: SsoService,
) {}
@Get('health')
health(@Req() request: WhmcsAuthenticatedRequest) {
@@ -127,6 +133,21 @@ export class WhmcsIntegrationController {
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/sso')
createServiceSso(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = whmcsCreateSsoRequestSchema.parse(body);
return this.sso.createWhmcsServiceSso(
request.whmcsInstallation!,
externalServiceId,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
}
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {

View File

@@ -4,6 +4,7 @@ import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { BillingModule } from '../../billing/billing.module';
import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../../servers/servers.module';
import { WhmcsIntegrationController } from './whmcs-integration.controller';
@@ -14,7 +15,7 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
@Module({
imports: [ServersModule, BillingModule],
imports: [ServersModule, BillingModule, AuthModule],
controllers: [WhmcsIntegrationController],
providers: [
WhmcsIntegrationService,

View File

@@ -0,0 +1,42 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('whmcs phase d sso contract', () => {
it('documents SSO create and consume routes', () => {
const whmcsSource = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
'utf8',
);
assert.ok(whmcsSource.includes("'services/:externalServiceId/sso'"));
const authSource = readFileSync(
join(process.cwd(), 'src', 'auth/auth.controller.ts'),
'utf8',
);
assert.ok(authSource.includes("'sso/consume'"));
const ssoServiceSource = readFileSync(
join(process.cwd(), 'src', 'auth/sso.service.ts'),
'utf8',
);
assert.ok(ssoServiceSource.includes('createWhmcsServiceSso'));
assert.ok(ssoServiceSource.includes('consumeTicket'));
assert.ok(ssoServiceSource.includes('isImpersonation'));
});
it('documents WHMCS PHP SSO hooks', () => {
const phpSource = readFileSync(
join(
process.cwd(),
'..',
'..',
'integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php',
),
'utf8',
);
assert.ok(phpSource.includes('ServiceSingleSignOn'));
assert.ok(phpSource.includes('AdminSingleSignOn'));
});
});

View File

@@ -86,6 +86,12 @@
"securityTitle": "Sicherheit",
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
"backToLogin": "Zurück zur Anmeldung",
"ssoTitle": "Anmeldung wird vorbereitet",
"ssoLoading": "Sicherer Anmeldelink wird geprüft…",
"ssoError": "Anmeldung fehlgeschlagen. Der Link ist abgelaufen oder wurde bereits verwendet.",
"ssoMissingTicket": "Es wurde kein Anmeldeticket übergeben.",
"impersonationBanner": "Support-Sitzung aktiv: {label}",
"impersonationDefaultLabel": "WHMCS-Admin-Impersonation",
"sessionNone": "Sitzung: keine",
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."

View File

@@ -86,6 +86,12 @@
"securityTitle": "Security",
"securitySubtitle": "Manage authentication and security settings for your account.",
"backToLogin": "Back to sign in",
"ssoTitle": "Signing you in",
"ssoLoading": "Validating your secure sign-in link…",
"ssoError": "Sign-in failed. The link may be expired or already used.",
"ssoMissingTicket": "No sign-in ticket was provided.",
"impersonationBanner": "Support session active: {label}",
"impersonationDefaultLabel": "WHMCS admin impersonation",
"sessionNone": "session: none",
"errors": {
"generic": "Something went wrong. Please try again."

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { SsoContent } from "@/components/auth/sso-content";
interface SsoPageProps {
params: Promise<{ locale: string }>;
}
export default async function SsoPage({ params }: SsoPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<Suspense>
<SsoContent />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { useAuth } from "@/components/providers/auth-provider";
import { useTranslations } from "next-intl";
export function ImpersonationBanner() {
const { user } = useAuth();
const t = useTranslations("auth");
const impersonation = user?.impersonation;
if (!impersonation?.isImpersonation) {
return null;
}
return (
<div
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
role="status"
>
{t("impersonationBanner", {
label: impersonation.label ?? t("impersonationDefaultLabel"),
})}
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, consumeSsoTicket } from "@/lib/api/auth";
import { useAuth } from "@/components/providers/auth-provider";
type SsoState = "idle" | "loading" | "success" | "error";
export function SsoContent() {
const t = useTranslations("auth");
const searchParams = useSearchParams();
const router = useRouter();
const { refresh } = useAuth();
const ticket = searchParams.get("ticket");
const [state, setState] = useState<SsoState>(ticket ? "loading" : "idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!ticket) {
return;
}
let cancelled = false;
async function redeem() {
try {
const result = await consumeSsoTicket(ticket!);
await refresh();
if (!cancelled) {
setState("success");
router.replace(result.redirectPath);
}
} catch (error) {
if (!cancelled) {
setState("error");
if (error instanceof ApiError) {
setErrorMessage(error.detail ?? error.title);
} else {
setErrorMessage(t("errors.generic"));
}
}
}
}
void redeem();
return () => {
cancelled = true;
};
}, [ticket, refresh, router, t]);
if (!ticket) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoMissingTicket")}</CardDescription>
</CardHeader>
<CardContent>
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</CardContent>
</Card>
);
}
if (state === "loading" || state === "idle" || state === "success") {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoLoading")}</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoError")}</CardDescription>
</CardHeader>
<CardContent>
{errorMessage ? (
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
{errorMessage}
</p>
) : null}
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</CardContent>
</Card>
);
}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { ImpersonationBanner } from "@/components/auth/impersonation-banner";
import { Footer } from "./footer";
import { Header } from "./header";
@@ -10,6 +11,7 @@ export function SiteLayout({ children }: SiteLayoutProps) {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<ImpersonationBanner />
<main className="flex-1">{children}</main>
<Footer />
</div>

View File

@@ -48,8 +48,23 @@ export interface CurrentUser {
username: string;
displayName?: string | null;
emailVerifiedAt?: string | null;
emailVerified?: boolean;
roles?: string[];
twoFactorEnabled?: boolean;
impersonation?: {
isImpersonation: boolean;
label?: string;
impersonatorLabel?: string;
};
}
export interface MeResponse {
user: CurrentUser;
impersonation?: {
isImpersonation: boolean;
impersonatorLabel?: string;
label?: string;
};
}
export interface TwoFactorSetupResponse {
@@ -149,8 +164,30 @@ export async function submitLogout(): Promise<void> {
});
}
export async function getCurrentUser(): Promise<CurrentUser> {
return apiFetch<CurrentUser>("/me");
export async function getCurrentUser(): Promise<CurrentUser | null> {
try {
const response = await apiFetch<MeResponse>("/me");
return {
...response.user,
impersonation: response.impersonation
? {
isImpersonation: response.impersonation.isImpersonation,
label:
response.impersonation.label ??
response.impersonation.impersonatorLabel,
}
: undefined,
};
} catch {
return null;
}
}
export async function consumeSsoTicket(ticket: string): Promise<{ redirectPath: string }> {
return apiFetch<{ redirectPath: string }>("/auth/sso/consume", {
method: "POST",
body: JSON.stringify({ ticket }),
});
}
export async function verifyEmail(token: string): Promise<void> {

File diff suppressed because one or more lines are too long