completely cleanup of the legacy hono router to leaner svelte remote functions

This commit is contained in:
user
2026-03-01 04:21:55 +02:00
parent ca056b817d
commit 596dcc78fc
18 changed files with 610 additions and 1379 deletions

View File

@@ -21,19 +21,16 @@
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@pkg/db": "workspace:*",
"@pkg/logger": "workspace:*",
"@pkg/logic": "workspace:*",
"@pkg/result": "workspace:*",
"@pkg/settings": "workspace:*",
"@tanstack/svelte-query": "^6.0.18",
"better-auth": "^1.4.20",
"date-fns": "^4.1.0",
"hono": "^4.11.1",
"import-in-the-middle": "^3.0.0",
"marked": "^17.0.1",
"nanoid": "^5.1.6",
"neverthrow": "^8.2.0",
"qrcode": "^1.5.4",

View File

@@ -1,13 +0,0 @@
import { usersRouter } from "@pkg/logic/domains/user/router";
import { twofaRouter } from "@pkg/logic/domains/2fa/router";
import { Hono } from "hono";
const baseRouter = new Hono()
.route("/users", usersRouter)
.route("/twofactor", twofaRouter);
export const apiBasePath = "/api/v1";
export const api = new Hono().route(apiBasePath, baseRouter);
export type Router = typeof baseRouter;

View File

@@ -1,183 +0,0 @@
<script lang="ts">
import hljs from "highlight.js/lib/core";
import { marked } from "marked";
// Import common languages for syntax highlighting
import bash from "highlight.js/lib/languages/bash";
import css from "highlight.js/lib/languages/css";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
// Register languages
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("json", json);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("html", xml); // HTML is an alias for XML
hljs.registerLanguage("js", javascript); // JS is an alias for JavaScript
hljs.registerLanguage("ts", typescript); // TS is an alias for TypeScript
hljs.registerLanguage("sh", bash); // SH is an alias for Bash
hljs.registerLanguage("shell", bash); // alias
interface Props {
content: string;
class?: string;
}
let { content, class: className = "" }: Props = $props();
// Configure marked with custom extensions
marked.use({
renderer: {
codespan(token: any) {
return `<code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono">${token.text}</code>`;
},
blockquote(token: any) {
return `<blockquote class="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-700 dark:text-gray-300 my-4">${this.parser.parseInline(token.tokens)}</blockquote>`;
},
table(token: any) {
let header = "";
let body = "";
// Process header
if (token.header && token.header.length > 0) {
header = "<thead><tr>";
for (const cell of token.header) {
header += `<th class="px-4 py-2 bg-secondary dark:bg-secondary font-semibold text-left">${this.parser.parseInline(cell.tokens)}</th>`;
}
header += "</tr></thead>";
}
// Process rows
if (token.rows && token.rows.length > 0) {
body = "<tbody>";
for (const row of token.rows) {
body += "<tr class='border-b border-border";
for (const cell of row) {
body += `<td class="px-4 py-2">${this.parser.parseInline(cell.tokens)}</td>`;
}
body += "</tr>";
}
body += "</tbody>";
}
return `<div class="overflow-x-auto my-4"><table class="min-w-full border border-border">${header}${body}</table></div>`;
},
},
gfm: true,
breaks: true,
});
</script>
<!-- Include highlight.js CSS for syntax highlighting -->
<svelte:head>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
media="(prefers-color-scheme: dark)"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"
media="(prefers-color-scheme: light)"
/>
</svelte:head>
<div
class="prose prose-sm dark:prose-invert max-w-none {className}"
class:prose-gray={true}
>
{@html marked(content)}
</div>
<style>
@reference "../../../app.css";
:global(.prose h1) {
@apply mt-6 mb-4 text-2xl font-bold text-gray-900 dark:text-gray-100;
}
:global(.prose h2) {
@apply mt-5 mb-3 text-xl font-semibold text-gray-900 dark:text-gray-100;
}
:global(.prose h3) {
@apply mt-4 mb-2 text-lg font-medium text-gray-900 dark:text-gray-100;
}
:global(.prose p) {
@apply mb-3 leading-relaxed text-gray-700 dark:text-gray-300;
}
:global(.prose a) {
@apply text-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300;
}
:global(.prose strong) {
@apply font-semibold text-gray-900 dark:text-gray-100;
}
:global(.prose em) {
@apply text-gray-700 italic dark:text-gray-300;
}
:global(.prose ul) {
@apply my-4 ml-4 list-inside list-disc space-y-1;
}
:global(.prose ol) {
@apply my-4 ml-4 list-inside list-decimal space-y-1;
}
:global(.prose li) {
@apply leading-relaxed text-gray-700 dark:text-gray-300;
}
:global(.prose ul ul) {
@apply mt-1 mb-1 ml-4;
}
:global(.prose ol ol) {
@apply mt-1 mb-1 ml-4;
}
:global(.prose ul ol) {
@apply mt-1 mb-1 ml-4;
}
:global(.prose ol ul) {
@apply mt-1 mb-1 ml-4;
}
/* Code block scrollbar styling */
:global(.prose pre) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
:global(.prose pre::-webkit-scrollbar) {
height: 8px;
}
:global(.prose pre::-webkit-scrollbar-track) {
background: transparent;
}
:global(.prose pre::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
:global(.prose pre::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.5);
}
</style>

View File

@@ -1,4 +1,5 @@
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import type { Err } from "@pkg/result";
export async function getFlowExecCtxForRemoteFuncs(
locals: App.Locals,
@@ -9,3 +10,16 @@ export async function getFlowExecCtxForRemoteFuncs(
sessionId: locals.session?.id,
};
}
export function unauthorized(fctx: FlowExecCtx) {
return {
data: null,
error: {
flowId: fctx.flowId,
code: "UNAUTHORIZED",
message: "User not authenticated",
description: "Please log in",
detail: "No user ID found in session",
} as Err,
};
}

View File

@@ -1,5 +0,0 @@
export function formatCurrency(amount: number, currency = "EUR"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
);
}

View File

@@ -1,22 +1,168 @@
import { ensureAccountExistsSchema } from "@pkg/logic/domains/user/data";
import {
banUserSchema,
checkUsernameSchema,
ensureAccountExistsSchema,
rotatePasswordSchema,
} from "@pkg/logic/domains/user/data";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { getUserController } from "@pkg/logic/domains/user/controller";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const uc = getUserController();
export const getMyInfoSQ = query(async () => {
const event = getRequestEvent();
// .... do stuff usually done in a router here
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
return { data: "testing" };
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getUserInfo(fctx, fctx.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const getUsersInfoByIdSQ = query(async () => {
// .... do stuff usually done in a router here
return { data: "testing" };
});
export const getUserInfoByIdSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
export const ensureAccountExistsSQ = command(
ensureAccountExistsSchema,
async (payload) => {
// .... do stuff usually done in a router here
return { data: "testing" };
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getUserInfo(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const ensureAccountExistsSC = command(
ensureAccountExistsSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.ensureAccountExists(fctx, payload.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const checkUsernameSC = command(checkUsernameSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.isUsernameAvailable(fctx, payload.username);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const update2faVerifiedSC = command(
v.object({ userId: v.string() }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.updateLastVerified2FaAtToNow(fctx, payload.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const banUserSC = command(banUserSchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.banUser(
fctx,
payload.userId,
payload.reason,
payload.banExpiresAt,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const isUserBannedSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.isUserBanned(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const getBanInfoSQ = query(
v.object({ userId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.getBanInfo(fctx, input.userId);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const rotatePasswordSC = command(
rotatePasswordSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await uc.rotatePassword(
fctx,
payload.userId,
payload.password,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);

View File

@@ -1,10 +1,11 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { User } from "@pkg/logic/domains/user/data";
import { apiClient, user as userStore } from "$lib/global.stores";
import { user as userStore } from "$lib/global.stores";
import { rotatePasswordSC } from "./account.remote";
import { authClient } from "$lib/auth.client";
import type { Err } from "@pkg/result";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class AccountViewModel {
loading = $state(false);
@@ -20,22 +21,20 @@ class AccountViewModel {
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ??
"Failed to update profile picture",
description:
response.error.statusText ??
"Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
).andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ??
"Failed to update profile picture",
description:
response.error.statusText ?? "Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
return result.match(
() => {
@@ -43,7 +42,8 @@ class AccountViewModel {
return true;
},
(error) => {
this.errorMessage = error.message ?? "Failed to update profile picture";
this.errorMessage =
error.message ?? "Failed to update profile picture";
toast.error(this.errorMessage, {
description: error.description,
});
@@ -71,21 +71,19 @@ class AccountViewModel {
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ?? "Failed to update profile",
description:
response.error.statusText ??
"Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
).andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ?? "Failed to update profile",
description:
response.error.statusText ?? "Please try again later",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
const user = result.match(
(data) => {
@@ -117,52 +115,20 @@ class AccountViewModel {
toast.error(this.errorMessage);
return false;
}
const client = get(apiClient);
if (!client) {
this.passwordLoading = false;
this.errorMessage = "API client not initialized";
toast.error(this.errorMessage);
return false;
}
const result = await ResultAsync.fromPromise(
client.users["rotate-password"].$put({
json: { userId: currentUser.id, password },
}),
rotatePasswordSC({ userId: currentUser.id, password }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to change password",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to change password",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail:
error instanceof Error
? error.message
: String(error),
}),
).andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
});
).andThen((apiResult: any) => {
if (apiResult?.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult?.data);
});
const success = result.match(
() => {
@@ -170,7 +136,8 @@ class AccountViewModel {
return true;
},
(error) => {
this.errorMessage = error.message ?? "Failed to change password";
this.errorMessage =
(error.message as string) ?? "Failed to change password";
toast.error(this.errorMessage, {
description: error.description,
});

View File

@@ -3,27 +3,15 @@ import {
getNotificationsSchema,
} from "@pkg/logic/domains/notifications/data";
import { getNotificationController } from "@pkg/logic/domains/notifications/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { getFlowExecCtxForRemoteFuncs } from "$lib/core/server.utils";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import type { Err } from "@pkg/result";
import * as v from "valibot";
const nc = getNotificationController();
export async function unauthorized(fctx: FlowExecCtx) {
return {
data: null,
error: {
flowId: fctx.flowId,
code: "UNAUTHORIZED",
message: "User not authenticated",
description: "Please log in",
detail: "No user found in request locals",
} as Err,
};
}
export const getNotificationsSQ = query(
getNotificationsSchema,
async (input) => {

View File

@@ -1,10 +1,12 @@
import { apiClient, session, user } from "$lib/global.stores";
import { session, user } from "$lib/global.stores";
import { authClient } from "$lib/auth.client";
import {
startVerificationSessionSC,
verifySessionCodeSC,
} from "$lib/domains/security/twofa.remote";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { page } from "$app/state";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class TwoFactorVerifyViewModel {
verifying = $state(false);
@@ -32,64 +34,33 @@ class TwoFactorVerifyViewModel {
return;
}
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor["start-verification-session"].$post({
json: { userId: uid, sessionId: sid },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to start verification",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to start verification",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
if (!apiResult.data?.verificationToken) {
return errAsync({
code: "API_ERROR",
message: "No verification token received",
description: "Invalid response data",
detail: "Missing verificationToken in response",
});
}
return okAsync(apiResult.data.verificationToken);
try {
const result = await startVerificationSessionSC({
userId: uid,
sessionId: sid,
});
result.match(
(token) => {
this.verificationToken = token;
},
(error) => {
this.errorMessage = error.message || "Failed to start verification";
if (result?.error || !result?.data?.verificationToken) {
this.errorMessage =
result?.error?.message || "Failed to start verification";
toast.error("Failed to start verification", {
description: this.errorMessage || "Failed to start verification",
description: this.errorMessage,
});
},
);
return;
}
this.startingVerification = false;
this.verificationToken = result.data.verificationToken;
} catch (error) {
this.errorMessage = "Failed to start verification";
toast.error("Failed to start verification", {
description:
error instanceof Error
? error.message
: "Failed to start verification",
});
} finally {
this.startingVerification = false;
}
}
async verifyCode() {
@@ -106,69 +77,22 @@ class TwoFactorVerifyViewModel {
this.verifying = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor["verify-session-code"].$post({
json: {
verificationToken: this.verificationToken,
code: this.verificationCode,
},
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Verification failed",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Verification failed",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
if (!apiResult.data?.success) {
return errAsync({
code: "VALIDATION_ERROR",
message: "Invalid verification code",
description: "Verification failed",
detail: "Code verification unsuccessful",
});
}
return okAsync(apiResult.data);
try {
const result = await verifySessionCodeSC({
verificationToken: this.verificationToken,
code: this.verificationCode,
});
result.match(
() => {
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
},
(error) => {
this.errorMessage = error.message || "Failed to verify code";
if (result?.error || !result?.data?.success) {
this.errorMessage =
result?.error?.message || "Failed to verify code";
if (error.code === "BANNED") {
authClient.signOut();
if (result?.error?.code === "BANNED") {
await authClient.signOut();
window.location.href = "/auth/login";
return;
}
// Don't show toast for invalid codes, just show in UI
if (
this.errorMessage &&
!this.errorMessage.includes("Invalid") &&
@@ -178,10 +102,20 @@ class TwoFactorVerifyViewModel {
description: this.errorMessage,
});
}
},
);
return;
}
this.verifying = false;
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
} catch (error) {
this.errorMessage = "Failed to verify code";
toast.error("Verification failed", {
description:
error instanceof Error ? error.message : this.errorMessage,
});
} finally {
this.verifying = false;
}
}
async handleBackupCode() {
@@ -193,65 +127,25 @@ class TwoFactorVerifyViewModel {
this.verifying = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor["verify-session-code"].$post({
json: {
verificationToken: this.verificationToken,
code: this.verificationCode,
},
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Verification failed",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Verification failed",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
if (!apiResult.data?.success) {
return errAsync({
code: "VALIDATION_ERROR",
message: "Invalid backup code",
description: "Verification failed",
detail: "Backup code verification unsuccessful",
});
}
return okAsync(apiResult.data);
try {
const result = await verifySessionCodeSC({
verificationToken: this.verificationToken,
code: this.verificationCode,
});
result.match(
() => {
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
},
(error) => {
this.errorMessage = error.message || "Invalid backup code";
},
);
if (result?.error || !result?.data?.success) {
this.errorMessage = result?.error?.message || "Invalid backup code";
return;
}
this.verifying = false;
const redirectUrl = page.url.searchParams.get("redirect") || "/";
window.location.href = redirectUrl;
} catch (error) {
this.errorMessage =
error instanceof Error ? error.message : "Invalid backup code";
} finally {
this.verifying = false;
}
}
reset() {

View File

@@ -1,9 +1,11 @@
import { apiClient } from "$lib/global.stores";
import {
disableTwoFactorSC,
generateBackupCodesSQ,
setupTwoFactorSC,
verifyAndEnableTwoFactorSC,
} from "$lib/domains/security/twofa.remote";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import QRCode from "qrcode";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class TwoFactorViewModel {
twoFactorEnabled = $state(false);
@@ -20,86 +22,38 @@ class TwoFactorViewModel {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor.setup.$post({
json: { code: "" },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to set up two-factor authentication.",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to set up two-factor authentication.",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
if (!apiResult.data || !apiResult.data.totpURI) {
return errAsync({
code: "API_ERROR",
message: "Failed to get 2FA setup information",
description: "Invalid response data",
detail: "Missing totpURI in response",
});
}
return okAsync(apiResult.data);
})
.andThen((data) => {
return ResultAsync.fromPromise(
QRCode.toDataURL(data.totpURI, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
}),
(error): Err => ({
code: "PROCESSING_ERROR",
message: "Failed to generate QR code.",
description: "QR code generation failed",
detail: error instanceof Error ? error.message : String(error),
}),
).map((qrCodeDataUrl) => ({
...data,
qrCodeDataUrl,
}));
try {
const result = await setupTwoFactorSC({});
if (result?.error || !result?.data?.totpURI) {
this.errorMessage =
result?.error?.message || "Could not enable 2FA";
toast.error(this.errorMessage, {
description:
result?.error?.description || "Please try again later",
});
return;
}
const qrCodeDataUrl = await QRCode.toDataURL(result.data.totpURI, {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
});
result.match(
({ qrCodeDataUrl, secret, totpURI }) => {
this.qrCodeUrl = qrCodeDataUrl;
this.twoFactorSetupInProgress = true;
this.twoFactorSecret = secret;
this.twoFactorVerificationCode = "";
toast("Setup enabled");
},
(error) => {
this.errorMessage = error.message || "Could not enable 2FA";
toast.error(this.errorMessage || "Could not enable 2FA", {
description: error.description || "Please try again later",
});
},
);
this.isLoading = false;
this.qrCodeUrl = qrCodeDataUrl;
this.twoFactorSetupInProgress = true;
this.twoFactorSecret = result.data.secret;
this.twoFactorVerificationCode = "";
toast("Setup enabled");
} catch (error) {
this.errorMessage = "Could not enable 2FA";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
async completeTwoFactorSetup() {
@@ -112,233 +66,109 @@ class TwoFactorViewModel {
this.isLoading = true;
this.errorMessage = null;
const verifyResult = await ResultAsync.fromPromise(
get(apiClient).twofactor["verify-and-enable"].$post({
json: { code: this.twoFactorVerificationCode },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to verify code.",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to verify code.",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
try {
const verifyResult = await verifyAndEnableTwoFactorSC({
code: this.twoFactorVerificationCode,
});
verifyResult.match(
async () => {
const backupCodesResult = await ResultAsync.fromPromise(
get(apiClient).twofactor["generate-backup-codes"].$get(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to generate backup codes",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to generate backup codes",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data || []);
});
backupCodesResult.match(
(backupCodes) => {
this.backupCodes = backupCodes;
this.showingBackupCodes = true;
},
() => {
toast.error(
"2FA enabled, but failed to generate backup codes",
{
description: "You can generate them later in settings",
},
);
},
);
this.twoFactorEnabled = true;
this.twoFactorSetupInProgress = false;
this.twoFactorVerificationCode = "";
toast.success("Two-factor authentication enabled", {
description: "Your account is now more secure",
if (verifyResult?.error) {
this.errorMessage =
verifyResult.error.message || "Invalid verification code";
toast.error(this.errorMessage, {
description:
verifyResult.error.description || "Please try again",
});
},
(error) => {
this.errorMessage = error.message || "Invalid verification code";
toast.error(this.errorMessage || "Invalid verification code", {
description: error.description || "Please try again",
});
},
);
return;
}
this.isLoading = false;
const backupCodesResult = await generateBackupCodesSQ();
if (backupCodesResult?.error || !Array.isArray(backupCodesResult?.data)) {
toast.error("2FA enabled, but failed to generate backup codes", {
description: "You can generate them later in settings",
});
} else {
this.backupCodes = backupCodesResult.data;
this.showingBackupCodes = true;
}
this.twoFactorEnabled = true;
this.twoFactorSetupInProgress = false;
this.twoFactorVerificationCode = "";
toast.success("Two-factor authentication enabled", {
description: "Your account is now more secure",
});
} catch (error) {
this.errorMessage = "Invalid verification code";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
this.isLoading = false;
}
}
async disableTwoFactor() {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor.disable.$delete({
json: { code: "" },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to disable two-factor authentication.",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to disable two-factor authentication.",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
result.match(
() => {
this.twoFactorEnabled = false;
this.backupCodes = [];
this.qrCodeUrl = null;
this.showingBackupCodes = false;
this.twoFactorSecret = null;
toast.success("Two-factor authentication disabled");
},
(error) => {
this.errorMessage = error.message || "Failed to disable 2FA";
toast.error(this.errorMessage || "Failed to disable 2FA", {
description:
error.description || "Please try again later",
try {
const result = await disableTwoFactorSC({ code: "" });
if (result?.error) {
this.errorMessage = result.error.message || "Failed to disable 2FA";
toast.error(this.errorMessage, {
description: result.error.description || "Please try again later",
});
},
);
return;
}
this.isLoading = false;
this.twoFactorEnabled = false;
this.backupCodes = [];
this.qrCodeUrl = null;
this.showingBackupCodes = false;
this.twoFactorSecret = null;
toast.success("Two-factor authentication disabled");
} catch (error) {
this.errorMessage = "Failed to disable 2FA";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
async generateNewBackupCodes() {
this.isLoading = true;
this.errorMessage = null;
const result = await ResultAsync.fromPromise(
get(apiClient).twofactor["generate-backup-codes"].$get(),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to generate new backup codes.",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to generate new backup codes.",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail: error instanceof Error ? error.message : String(error),
}),
);
})
.andThen((apiResult) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data || []);
});
result.match(
(backupCodes) => {
this.backupCodes = backupCodes;
this.showingBackupCodes = true;
toast.success("New backup codes generated", {
description: "Your previous backup codes are now invalid",
});
},
(error) => {
try {
const result = await generateBackupCodesSQ();
if (result?.error || !Array.isArray(result?.data)) {
this.errorMessage =
error.message || "Failed to generate new backup codes";
toast.error(this.errorMessage || "Failed to generate new backup codes", {
result?.error?.message || "Failed to generate new backup codes";
toast.error(this.errorMessage, {
description:
error.description || "Please try again later",
result?.error?.description || "Please try again later",
});
},
);
return;
}
this.isLoading = false;
this.backupCodes = result.data;
this.showingBackupCodes = true;
toast.success("New backup codes generated", {
description: "Your previous backup codes are now invalid",
});
} catch (error) {
this.errorMessage = "Failed to generate new backup codes";
toast.error(this.errorMessage, {
description:
error instanceof Error ? error.message : "Please try again later",
});
} finally {
this.isLoading = false;
}
}
copyAllBackupCodes() {

View File

@@ -0,0 +1,205 @@
import {
disable2FASchema,
enable2FACodeSchema,
startVerificationSchema,
verifyCodeSchema,
} from "@pkg/logic/domains/2fa/data";
import {
getFlowExecCtxForRemoteFuncs,
unauthorized,
} from "$lib/core/server.utils";
import { getTwofaController } from "@pkg/logic/domains/2fa/controller";
import { command, getRequestEvent, query } from "$app/server";
import { auth } from "@pkg/logic/domains/auth/config.base";
import type { User } from "@pkg/logic/domains/user/data";
import * as v from "valibot";
const tc = getTwofaController();
function buildIpAddress(headers: Headers) {
return (
headers.get("x-forwarded-for") ?? headers.get("x-real-ip") ?? "unknown"
);
}
function buildUserAgent(headers: Headers) {
return headers.get("user-agent") ?? "unknown";
}
export const setupTwoFactorSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.setup2FA(fctx, currentUser as User);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const verifyAndEnableTwoFactorSC = command(
enable2FACodeSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.verifyAndEnable2FA(
fctx,
currentUser as User,
payload.code,
event.request.headers,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const generateBackupCodesSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.generateBackupCodes(fctx, currentUser as User);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const disableTwoFactorSC = command(disable2FASchema, async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.disable(fctx, currentUser as User, payload.code);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const requiresVerificationSQ = query(
v.object({ sessionId: v.string() }),
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.requiresInitialVerification(
fctx,
currentUser as User,
input.sessionId,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const requiresSensitiveActionSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
const currentUser = event.locals.user;
if (!fctx.userId || !currentUser) {
return unauthorized(fctx);
}
const res = await tc.requiresSensitiveActionVerification(
fctx,
currentUser as User,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const startVerificationSessionSC = command(
startVerificationSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await tc.startVerification(fctx, {
userId: payload.userId,
sessionId: payload.sessionId,
ipAddress: buildIpAddress(event.request.headers),
userAgent: buildUserAgent(event.request.headers),
});
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const verifySessionCodeSC = command(
verifyCodeSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
let currentUser = event.locals.user;
if (!currentUser) {
const sess = await auth.api.getSession({
headers: event.request.headers,
});
currentUser = sess?.user as User | undefined;
}
const res = await tc.verifyCode(
fctx,
{
verificationSessToken: payload.verificationToken,
code: payload.code,
},
currentUser as User | undefined,
);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const cleanupExpiredSessionsSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await tc.cleanupExpiredSessions(fctx);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});

View File

@@ -2,13 +2,7 @@
import { goto } from "$app/navigation";
import { mainNavTree } from "$lib/core/constants";
import { breadcrumbs } from "$lib/global.stores";
import { makeClient } from "$lib/make-client.js";
import { onMount } from "svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const client = makeClient(fetch);
breadcrumbs.set([mainNavTree[0]]);

View File

@@ -1,25 +1,11 @@
<script lang="ts">
import { browser } from "$app/environment";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { ModeWatcher } from "mode-watcher";
import { apiClient, breadcrumbs } from "$lib/global.stores";
import { makeClient } from "$lib/make-client";
import { onMount } from "svelte";
import { breadcrumbs } from "$lib/global.stores";
import "./layout.css";
let { children }: { children: any } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: { enabled: browser },
},
});
onMount(() => {
apiClient.set(makeClient(fetch));
});
</script>
<svelte:head>
@@ -31,6 +17,4 @@
<ModeWatcher />
<Toaster />
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
{@render children()}