package updates and build time error fix in the main app
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173",
|
||||
"build": "NODE_ENV=build vite build",
|
||||
"prod": "HOST=0.0.0.0 PORT=3000 bun ./build/index.js",
|
||||
"prod": "HOST=0.0.0.0 PORT=3000 bun run ./build/index.js",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -14,15 +14,14 @@
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
"test": "bun run test:unit -- --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pkg/db": "workspace:*",
|
||||
"@pkg/logger": "workspace:*",
|
||||
"@pkg/logic": "workspace:*",
|
||||
"@pkg/result": "workspace:*",
|
||||
"@tanstack/svelte-query": "^6.0.10",
|
||||
"better-auth": "^1.4.7",
|
||||
"@tanstack/svelte-query": "^6.0.18",
|
||||
"date-fns": "^4.1.0",
|
||||
"hono": "^4.11.1",
|
||||
"marked": "^17.0.1",
|
||||
@@ -35,9 +34,9 @@
|
||||
"@iconify/json": "^2.2.434",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -53,11 +52,10 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte-check": "^4.4.4",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"sveltekit-superforms": "^2.28.1",
|
||||
"sveltekit-superforms": "^2.30.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
||||
266
apps/main/src/lib/domains/security/2fa-verify.vm.svelte.ts
Normal file
266
apps/main/src/lib/domains/security/2fa-verify.vm.svelte.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { apiClient, session, user } from "$lib/global.stores";
|
||||
import { authClient } from "$lib/auth.client";
|
||||
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);
|
||||
verificationCode = $state("");
|
||||
verificationToken = $state<string | null>(null);
|
||||
errorMessage = $state<string | null>(null);
|
||||
startingVerification = $state(false);
|
||||
|
||||
async startVerification() {
|
||||
this.startingVerification = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const currentUser = get(user);
|
||||
const currentSession = get(session);
|
||||
|
||||
const uid = currentUser?.id;
|
||||
const sid = currentSession?.id;
|
||||
|
||||
if (!uid || !sid) {
|
||||
this.errorMessage = "No active session found";
|
||||
toast.error("Failed to start verification", {
|
||||
description: this.errorMessage,
|
||||
});
|
||||
this.startingVerification = false;
|
||||
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);
|
||||
});
|
||||
|
||||
result.match(
|
||||
(token) => {
|
||||
this.verificationToken = token;
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = error.message || "Failed to start verification";
|
||||
toast.error("Failed to start verification", {
|
||||
description: this.errorMessage || "Failed to start verification",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.startingVerification = false;
|
||||
}
|
||||
|
||||
async verifyCode() {
|
||||
if (!this.verificationToken) {
|
||||
this.errorMessage = "No verification session found";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.verificationCode || this.verificationCode.length !== 6) {
|
||||
this.errorMessage = "Please enter a valid 6-digit code";
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
result.match(
|
||||
() => {
|
||||
const redirectUrl = page.url.searchParams.get("redirect") || "/";
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = error.message || "Failed to verify code";
|
||||
|
||||
if (error.code === "BANNED") {
|
||||
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") &&
|
||||
!this.errorMessage.includes("already been used")
|
||||
) {
|
||||
toast.error("Verification failed", {
|
||||
description: this.errorMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.verifying = false;
|
||||
}
|
||||
|
||||
async handleBackupCode() {
|
||||
if (!this.verificationToken || !this.verificationCode) {
|
||||
this.errorMessage = "Please enter a valid backup code";
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
result.match(
|
||||
() => {
|
||||
const redirectUrl = page.url.searchParams.get("redirect") || "/";
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = error.message || "Invalid backup code";
|
||||
},
|
||||
);
|
||||
|
||||
this.verifying = false;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.verifying = false;
|
||||
this.verificationCode = "";
|
||||
this.verificationToken = null;
|
||||
this.errorMessage = null;
|
||||
this.startingVerification = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const twoFactorVerifyVM = new TwoFactorVerifyViewModel();
|
||||
404
apps/main/src/lib/domains/security/2fa.vm.svelte.ts
Normal file
404
apps/main/src/lib/domains/security/2fa.vm.svelte.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { apiClient } from "$lib/global.stores";
|
||||
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);
|
||||
twoFactorSetupInProgress = $state(false);
|
||||
showingBackupCodes = $state(false);
|
||||
qrCodeUrl = $state<string | null>(null);
|
||||
twoFactorSecret = $state<string | null>(null);
|
||||
backupCodes = $state<string[]>([]);
|
||||
twoFactorVerificationCode = $state("");
|
||||
isLoading = $state(false);
|
||||
errorMessage = $state<string | null>(null);
|
||||
|
||||
async startTwoFactorSetup() {
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async completeTwoFactorSetup() {
|
||||
if (!this.twoFactorVerificationCode) {
|
||||
this.errorMessage =
|
||||
"Please enter the verification code from your authenticator app.";
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = error.message || "Invalid verification code";
|
||||
toast.error(this.errorMessage || "Invalid verification code", {
|
||||
description: error.description || "Please try again",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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) => {
|
||||
this.errorMessage =
|
||||
error.message || "Failed to generate new backup codes";
|
||||
toast.error(this.errorMessage || "Failed to generate new backup codes", {
|
||||
description:
|
||||
error.description || "Please try again later",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
copyAllBackupCodes() {
|
||||
const codesText = this.backupCodes.join("\n");
|
||||
navigator.clipboard.writeText(codesText);
|
||||
toast.success("All backup codes copied to clipboard");
|
||||
}
|
||||
|
||||
downloadBackupCodes() {
|
||||
const codesText = this.backupCodes.join("\n");
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Two-Factor Authentication Backup Codes\n\nGenerated: ${new Date().toLocaleString()}\n\n${codesText}\n\nKeep these codes in a safe place. Each code can only be used once.`,
|
||||
],
|
||||
{
|
||||
type: "text/plain",
|
||||
},
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `2fa-backup-codes-${new Date().toISOString().split("T")[0]}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Backup codes downloaded");
|
||||
}
|
||||
|
||||
confirmBackupCodesSaved() {
|
||||
this.showingBackupCodes = false;
|
||||
toast.success("Great! Your backup codes are safely stored");
|
||||
}
|
||||
|
||||
cancelSetup() {
|
||||
this.twoFactorSetupInProgress = false;
|
||||
this.twoFactorSecret = null;
|
||||
this.qrCodeUrl = null;
|
||||
this.twoFactorVerificationCode = "";
|
||||
this.errorMessage = null;
|
||||
this.showingBackupCodes = false;
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.twoFactorEnabled = false;
|
||||
this.twoFactorSetupInProgress = false;
|
||||
this.showingBackupCodes = false;
|
||||
this.qrCodeUrl = null;
|
||||
this.backupCodes = [];
|
||||
this.twoFactorSecret = null;
|
||||
this.twoFactorVerificationCode = "";
|
||||
this.isLoading = false;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const twofactorVM = new TwoFactorViewModel();
|
||||
324
apps/main/src/lib/domains/security/two-fa-card.svelte
Normal file
324
apps/main/src/lib/domains/security/two-fa-card.svelte
Normal file
@@ -0,0 +1,324 @@
|
||||
<script lang="ts">
|
||||
import ButtonText from "$lib/components/atoms/button-text.svelte";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as InputOTP from "$lib/components/ui/input-otp";
|
||||
import { twofactorVM } from "$lib/domains/security/2fa.vm.svelte";
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
} from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
$inspect(twofactorVM.twoFactorSetupInProgress);
|
||||
$inspect(twofactorVM.twoFactorEnabled);
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={ShieldCheck} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Two-Factor Authentication</Card.Title>
|
||||
</div>
|
||||
<Card.Description>
|
||||
Add an extra layer of security to your account by enabling two-factor
|
||||
authentication.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if twofactorVM.twoFactorSetupInProgress}
|
||||
<div class="space-y-6">
|
||||
<div class="bg-muted/50 rounded-lg border p-4">
|
||||
<h4 class="mb-2 font-medium">Setup Instructions</h4>
|
||||
<ol class="list-decimal space-y-2 pl-5 text-sm">
|
||||
<li>
|
||||
Install an authenticator app like Google Authenticator
|
||||
or 2FAS on your mobile device.
|
||||
</li>
|
||||
<li>
|
||||
Scan the QR code below or manually enter the secret
|
||||
key into your app.
|
||||
</li>
|
||||
<li>
|
||||
Enter the verification code displayed in your
|
||||
authenticator app below.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
{#if twofactorVM.qrCodeUrl && twofactorVM.qrCodeUrl.length > 0}
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
{#if twofactorVM.qrCodeUrl}
|
||||
<div class="rounded-lg border bg-white p-2">
|
||||
<img
|
||||
src={twofactorVM.qrCodeUrl}
|
||||
alt="QR Code for Two-Factor Authentication"
|
||||
class="h-48 w-48"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
twofactorVM.copyToClipboard(
|
||||
twofactorVM.twoFactorSecret || "",
|
||||
);
|
||||
toast.success(
|
||||
"Secret copied to clipboard",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon icon={Copy} cls="mr-2 h-4 w-4" />
|
||||
Copy secret for manual entry
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="flex justify-center py-6">
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
cls="h-8 w-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center py-6">
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
cls="h-8 w-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<InputOTP.Root
|
||||
maxlength={6}
|
||||
bind:value={twofactorVM.twoFactorVerificationCode}
|
||||
>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(0, 3) as cell (cell)}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
<InputOTP.Separator />
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(3, 6) as cell (cell)}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-center text-sm">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-32 gap-2">
|
||||
<Button
|
||||
onclick={() => twofactorVM.completeTwoFactorSetup()}
|
||||
disabled={!twofactorVM.twoFactorVerificationCode ||
|
||||
twofactorVM.twoFactorVerificationCode.length !== 6 ||
|
||||
twofactorVM.isLoading}
|
||||
>
|
||||
{#if twofactorVM.isLoading}
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
cls="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{/if}
|
||||
Verify and Enable
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => twofactorVM.cancelSetup()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !twofactorVM.twoFactorEnabled && !twofactorVM.twoFactorSetupInProgress}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-2 text-amber-700 sm:flex-row sm:items-center dark:text-amber-400"
|
||||
>
|
||||
<Icon icon={ShieldAlert} cls="h-4 w-4" />
|
||||
<span class="text-sm">
|
||||
Two-factor authentication is currently disabled.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full sm:w-max"
|
||||
onclick={() => twofactorVM.startTwoFactorSetup()}
|
||||
disabled={twofactorVM.isLoading}
|
||||
>
|
||||
{#if twofactorVM.isLoading}
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
cls="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{/if}
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if twofactorVM.twoFactorEnabled}
|
||||
<div class="space-y-6">
|
||||
{#if !twofactorVM.showingBackupCodes}
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
<Icon icon={ShieldCheck} cls="h-4 w-4" />
|
||||
<span>Two-factor authentication is enabled.</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
twofactorVM.generateNewBackupCodes()}
|
||||
disabled={twofactorVM.isLoading}
|
||||
>
|
||||
{#if twofactorVM.isLoading}
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
cls="mr-2 h-3 w-3 animate-spin"
|
||||
/>
|
||||
{:else}
|
||||
Regenerate Recovery Codes
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={twofactorVM.isLoading}
|
||||
>
|
||||
<ButtonText
|
||||
loading={twofactorVM.isLoading}
|
||||
text="Disable 2FA"
|
||||
loadingText="Disabling..."
|
||||
/>
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title
|
||||
>Disable Two-Factor Authentication?</AlertDialog.Title
|
||||
>
|
||||
<AlertDialog.Description>
|
||||
This will remove the extra layer of
|
||||
security from your account. You will
|
||||
no longer need your authenticator app
|
||||
to sign in, and all backup codes will
|
||||
be invalidated. You can re-enable 2FA
|
||||
at any time.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
>Cancel</AlertDialog.Cancel
|
||||
>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
twofactorVM.disableTwoFactor()}
|
||||
>
|
||||
Disable 2FA
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if twofactorVM.showingBackupCodes && twofactorVM.backupCodes.length > 0}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">Recovery Codes</h4>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
twofactorVM.copyAllBackupCodes()}
|
||||
>
|
||||
<Icon icon={Copy} cls="mr-2 h-3 w-3" />
|
||||
Copy All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
twofactorVM.downloadBackupCodes()}
|
||||
>
|
||||
<Icon icon={Download} cls="mr-2 h-3 w-3" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/50 rounded-lg border p-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each twofactorVM.backupCodes as code}
|
||||
<div
|
||||
class="bg-background flex items-center justify-between rounded border px-3 py-2 font-mono text-sm"
|
||||
>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<p class="text-muted-foreground text-xs">
|
||||
<Icon
|
||||
icon={ShieldCheck}
|
||||
cls="mr-1 h-3 w-3 inline-block"
|
||||
/>
|
||||
Keep these codes in a safe place. Each code can
|
||||
only be used once to access your account if you
|
||||
lose your phone.
|
||||
</p>
|
||||
<div
|
||||
class="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/20"
|
||||
>
|
||||
<p
|
||||
class="text-xs font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
⚠️ Important: Save these codes before
|
||||
continuing. You won't be able to see them
|
||||
again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={() => twofactorVM.confirmBackupCodesSaved()}
|
||||
class="w-full"
|
||||
>
|
||||
<Icon icon={CheckCircle} cls="mr-2 h-4 w-4" />
|
||||
I've saved these codes securely
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
import adapter from "svelte-adapter-bun";
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
@@ -11,7 +11,9 @@ const config = {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
adapter: adapter({
|
||||
out: "build",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import { resolve } from "path";
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), tailwindcss(), Icons({ compiler: "svelte" })],
|
||||
|
||||
ssr: {
|
||||
external: ["argon2", "node-gyp-build"],
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@core": resolve(__dirname, "../../packages/logic/core"),
|
||||
|
||||
Reference in New Issue
Block a user