completely cleanup of the legacy hono router to leaner svelte remote functions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function formatCurrency(amount: number, currency = "EUR"): string {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
||||
amount,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user