Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@revenuecat/purchases-js": "^1.32.0",
"astro": "^5.7.0",
"dompurify": "^3.1.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0"
Expand All @@ -32,6 +33,7 @@
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.9",
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/AccountView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import VanityInboxForm from "./VanityInboxForm.svelte";
import DomainManager from "./DomainManager.svelte";
import LocaleToggle from "./LocaleToggle.svelte";
import QRCode from "./QRCode.svelte";

const loc = useLocale();
const user = useUser();
Expand Down Expand Up @@ -143,6 +144,57 @@
} catch { /* silent */ }
}

let twoFactorSetup = $state<{ secret: string; uri: string } | null>(null);
let twoFactorCode = $state("");
let twoFactorBusy = $state(false);
let twoFactorError = $state("");

async function startTwoFactorSetup() {
if (!user.apiKey) return;
twoFactorBusy = true;
twoFactorError = "";
try {
twoFactorSetup = await api.setupTwoFactor(user.apiKey);
twoFactorCode = "";
} catch (e: any) {
twoFactorError = e?.message || t("genericError");
} finally {
twoFactorBusy = false;
}
}

async function confirmTwoFactor() {
if (!user.apiKey || !twoFactorCode.trim()) return;
twoFactorBusy = true;
twoFactorError = "";
try {
await api.enableTwoFactor(twoFactorCode.trim(), user.apiKey);
twoFactorSetup = null;
twoFactorCode = "";
await refreshUser();
} catch (e: any) {
twoFactorError = t("twoFactorBadCode");
} finally {
twoFactorBusy = false;
}
}

async function disableTwoFactor() {
if (!user.apiKey) return;
twoFactorBusy = true;
twoFactorError = "";
try {
await api.disableTwoFactor(twoFactorCode.trim(), user.apiKey);
twoFactorCode = "";
twoFactorSetup = null;
await refreshUser();
} catch (e: any) {
twoFactorError = t("twoFactorBadCode");
} finally {
twoFactorBusy = false;
}
}

async function loadPlanData() {
if (!user.info?.user_id) return;
try {
Expand Down Expand Up @@ -485,6 +537,79 @@
</a>
</div>

<!-- Two-factor -->
<div class="bg-abyss/80 glow-border rounded-sm p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-white text-sm font-display tracking-wider">{t("twoFactor")}</h3>
<span class="text-[9px] tracking-widest px-1.5 py-0.5 font-mono
{user.info?.two_factor_enabled ? 'bg-pulse/15 text-pulse' : 'bg-ghost/15 text-muted'}">
{user.info?.two_factor_enabled ? t("twoFactorOn") : t("twoFactorOff")}
</span>
</div>
<p class="text-muted text-[10px] font-mono mb-3">{t("twoFactorDescription")}</p>

{#if twoFactorSetup}
<p class="text-muted text-[11px] font-mono mb-3">{t("twoFactorScan")}</p>
<div class="flex flex-col sm:flex-row gap-4 mb-3">
<div class="flex-shrink-0 self-center sm:self-start">
<QRCode value={twoFactorSetup.uri} size={180} />
</div>
<div class="flex-1 bg-slab/50 border border-ghost/20 rounded-sm p-3 font-mono text-[11px] min-w-0">
<div class="text-muted text-[10px] tracking-widest mb-1">SECRET</div>
<code class="text-neon break-all">{twoFactorSetup.secret}</code>
</div>
</div>
<div class="flex gap-2 mb-2">
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
bind:value={twoFactorCode}
placeholder={t("twoFactorCodePlaceholder")}
class="flex-1 bg-slab/50 border border-ghost/20 text-white text-center text-base font-mono tracking-[0.4em] px-3 py-2 rounded-sm focus:border-neon/40 focus:outline-none"
/>
<button
onclick={confirmTwoFactor}
disabled={twoFactorBusy || twoFactorCode.trim().length < 6}
class="px-4 py-2 text-xs font-mono tracking-wider border border-neon/40 text-neon hover:bg-neon/10 transition-all disabled:opacity-40"
>
{twoFactorBusy ? "..." : t("twoFactorVerify")}
</button>
</div>
{#if twoFactorError}<div class="text-ember text-[11px] font-mono">{twoFactorError}</div>{/if}
{:else if user.info?.two_factor_enabled}
<div class="flex gap-2">
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
bind:value={twoFactorCode}
placeholder={t("twoFactorCodePlaceholder")}
class="flex-1 bg-slab/50 border border-ghost/20 text-white text-center text-base font-mono tracking-[0.4em] px-3 py-2 rounded-sm focus:border-neon/40 focus:outline-none"
/>
<button
onclick={disableTwoFactor}
disabled={twoFactorBusy || twoFactorCode.trim().length < 6}
class="px-4 py-2 text-xs font-mono tracking-wider border border-ember/40 text-ember hover:bg-ember/10 transition-all disabled:opacity-40"
>
{twoFactorBusy ? "..." : t("twoFactorDisable")}
</button>
</div>
{#if twoFactorError}<div class="text-ember text-[11px] font-mono mt-2">{twoFactorError}</div>{/if}
{:else}
<button
onclick={startTwoFactorSetup}
disabled={twoFactorBusy}
class="px-4 py-2 text-xs font-mono tracking-wider border border-neon/40 text-neon hover:bg-neon/10 transition-all disabled:opacity-40"
>
{twoFactorBusy ? "..." : t("twoFactorEnable")}
</button>
{#if twoFactorError}<div class="text-ember text-[11px] font-mono mt-2">{twoFactorError}</div>{/if}
{/if}
</div>

<!-- Webhook -->
<div class="bg-abyss/80 glow-border rounded-sm p-5">
<div class="flex items-center justify-between mb-2">
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/QRCode.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script lang="ts">
import QRCode from "qrcode";
import { onMount } from "svelte";

interface Props {
value: string;
size?: number;
}
let { value, size = 200 }: Props = $props();

let canvasEl: HTMLCanvasElement | undefined = $state();

function render() {
if (!canvasEl || !value) return;
QRCode.toCanvas(canvasEl, value, {
width: size,
margin: 1,
color: { dark: "#0a0e14", light: "#ffffff" },
}).catch(() => {});
}

onMount(render);
$effect(() => {
void value;
void size;
render();
});
</script>

<canvas bind:this={canvasEl} class="bg-white rounded-sm"></canvas>
127 changes: 102 additions & 25 deletions apps/web/src/pages/auth/verify.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,52 +24,129 @@ import Layout from "../../layouts/Layout.astro";
<script>
import { t, setLocale, type Locale } from "@ephemask/shared";

// Locale comes from URL prefix (set by middleware via window globals)
const localeCode = (window as any).__EPHEMASK_LOCALE__ as Locale | undefined;
const localePrefix = ((window as any).__EPHEMASK_LOCALE_PREFIX__ as string | undefined) || "";
if (localeCode) setLocale(localeCode);

const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const el = document.getElementById("verify-status");
const apiUrl = import.meta.env.PUBLIC_API_URL || "";

function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]!));
}

function showSuccess() {
if (!el) return;
el.innerHTML = `
<svg class="w-12 h-12 text-neon mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-neon text-sm font-mono mb-2">${t("loginSuccess")}</p>
<p class="text-ghost text-xs font-mono">${t("redirecting")}</p>
`;
setTimeout(() => {
window.location.href = `${localePrefix}/account`;
}, 1200);
}

function showFailure(message: string) {
if (!el) return;
el.innerHTML = `
<svg class="w-12 h-12 text-ember mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<p class="text-ember text-sm font-mono mb-2">${escapeHtml(message)}</p>
<a href="${localePrefix}/login" class="text-neon text-xs font-mono hover:underline">${t("tryAgain")}</a>
`;
}

function showTwoFactorPrompt(pendingToken: string) {
if (!el) return;
el.innerHTML = `
<div class="bg-abyss/80 glow-border rounded-sm p-6 max-w-sm mx-auto">
<p class="text-white text-sm font-mono mb-4">${t("twoFactorRequired")}</p>
<input
id="totp-code"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
placeholder="${escapeHtml(t("twoFactorCodePlaceholder"))}"
class="w-full bg-slab/50 border border-ghost/20 text-white text-center text-lg font-mono tracking-[0.4em] px-3 py-3 rounded-sm focus:border-neon/40 focus:outline-none mb-3"
/>
<p id="totp-error" class="text-ember text-xs font-mono mb-3 hidden"></p>
<button id="totp-submit" class="w-full py-3 text-sm font-mono tracking-wider border border-neon/40 text-neon hover:bg-neon/10 transition-all">
${t("twoFactorVerify")}
</button>
</div>
`;

const codeInput = document.getElementById("totp-code") as HTMLInputElement | null;
const errorEl = document.getElementById("totp-error");
const submitBtn = document.getElementById("totp-submit") as HTMLButtonElement | null;
if (!codeInput || !submitBtn) return;
codeInput.focus();

async function submit() {
const code = codeInput!.value.trim();
if (code.length < 6) return;
submitBtn!.disabled = true;
submitBtn!.textContent = "...";
try {
const res = await fetch(`${apiUrl}/auth/2fa/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pending_token: pendingToken, code }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (errorEl) {
errorEl.classList.remove("hidden");
errorEl.textContent = data.error === "token expired"
? t("twoFactorPendingExpired")
: t("twoFactorBadCode");
}
submitBtn!.disabled = false;
submitBtn!.textContent = t("twoFactorVerify");
return;
}
const data = await res.json();
localStorage.setItem("ephemask-user-key", data.api_key);
localStorage.setItem("ephemask-user-id", data.user_id);
showSuccess();
} catch {
submitBtn!.disabled = false;
submitBtn!.textContent = t("twoFactorVerify");
}
}

submitBtn.addEventListener("click", submit);
codeInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
}

if (!token) {
if (el) el.innerHTML = `<p class="text-ember text-sm font-mono">${t("invalidLink")}</p>`;
} else {
const apiUrl = import.meta.env.PUBLIC_API_URL || "";
fetch(`${apiUrl}/auth/verify?token=${token}`)
.then((res) => {
if (!res.ok) throw new Error("expired");
return res.json();
})
.then((data: any) => {
if (data.two_factor_required && data.pending_token) {
showTwoFactorPrompt(data.pending_token);
return;
}
localStorage.setItem("ephemask-user-key", data.api_key);
localStorage.setItem("ephemask-user-id", data.user_id);

if (el) {
el.innerHTML = `
<svg class="w-12 h-12 text-neon mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-neon text-sm font-mono mb-2">${t("loginSuccess")}</p>
<p class="text-ghost text-xs font-mono">${t("redirecting")}</p>
`;
}

setTimeout(() => {
window.location.href = `${localePrefix}/account`;
}, 1500);
showSuccess();
})
.catch(() => {
if (el) {
el.innerHTML = `
<svg class="w-12 h-12 text-ember mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<p class="text-ember text-sm font-mono mb-2">${t("linkExpired")}</p>
<a href="${localePrefix}/login" class="text-neon text-xs font-mono hover:underline">${t("tryAgain")}</a>
`;
}
showFailure(t("linkExpired"));
});
}
</script>
Loading
Loading