From da28b6946197ac469217061c4d3274aa697d31ac Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 28 May 2026 16:40:16 +0300 Subject: [PATCH] feat: add demo auth flow --- docs-site/public/_redirects | 2 + docs-site/public/demo/demo-shell.js | 45 ++++++--- docs-site/scripts/materialize-demo-routes.mjs | 2 + docs-site/src/pages/demo.astro | 1 + docs-site/src/pages/demo/[...path].astro | 12 ++- docs/getting-started/demo.md | 1 + frontend/src/App.svelte | 52 +++++++++- frontend/src/lib/webapp/mockApi.js | 94 ++++++++++++++++++- frontend/src/lib/webapp/previewMock.js | 22 +++++ frontend/src/lib/webapp/stores/authStore.js | 5 +- 10 files changed, 220 insertions(+), 16 deletions(-) diff --git a/docs-site/public/_redirects b/docs-site/public/_redirects index ea15b6c..d2ea8ac 100644 --- a/docs-site/public/_redirects +++ b/docs-site/public/_redirects @@ -16,5 +16,7 @@ /demo/runtime/support/* /demo/runtime/app.html 200 /demo/runtime/settings /demo/runtime/app.html 200 /demo/runtime/settings/* /demo/runtime/app.html 200 +/demo/runtime/login /demo/runtime/app.html 200 +/demo/runtime/login/* /demo/runtime/app.html 200 /demo/runtime/admin /demo/runtime/app.html 200 /demo/runtime/admin/* /demo/runtime/app.html 200 diff --git a/docs-site/public/demo/demo-shell.js b/docs-site/public/demo/demo-shell.js index a3bab4e..1dfa0e2 100644 --- a/docs-site/public/demo/demo-shell.js +++ b/docs-site/public/demo/demo-shell.js @@ -8,6 +8,7 @@ const stateMocks = new Set([ "no-subscription", "trial", "devices", + "auth", ]); const routeMocks = new Set([...stateMocks, "guides", "install"]); const params = new URLSearchParams(window.location.search); @@ -20,7 +21,9 @@ const normalizePath = (value) => { }; const normalizeRouteMock = (value) => { - const mock = String(value || "").trim().toLowerCase(); + const mock = String(value || "") + .trim() + .toLowerCase(); return routeMocks.has(mock) ? mock : defaultMock; }; @@ -36,7 +39,8 @@ const routeFromPublicPath = () => { if (!lowerPathname.startsWith(`${demoBase}/`)) return ""; const publicRoute = pathname.slice(demoBase.length); - if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime")) return ""; + if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime")) + return ""; return normalizePath(publicRoute); }; @@ -65,6 +69,7 @@ const routeFromParams = () => { "devices", "support", "settings", + "login", ].includes(screen) ) { return `/${screen}`; @@ -73,7 +78,13 @@ const routeFromParams = () => { }; let initialRoute = routeFromParams(); -const mockForRoute = (route) => (normalizePath(route) === "/devices" ? "devices" : ""); +const mockForRoute = (route) => { + const normalized = normalizePath(route); + if (normalized === "/devices") return "devices"; + if (normalized === "/login" || normalized.startsWith("/login/")) + return "auth"; + return ""; +}; const initialMock = params.has("mock") ? normalizeRouteMock(params.get("mock")) : normalizeRouteMock(mockForRoute(initialRoute) || defaultMock); @@ -84,7 +95,7 @@ if (initialMock === "trial" && initialRoute === "/trial") { window.history.replaceState( null, "", - `${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}` + `${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`, ); } params.set("mock", initialMock); @@ -96,8 +107,11 @@ frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash const routeFromRuntimeUrl = (url) => { if (url.origin !== window.location.origin) return ""; - if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase())) return ""; - const runtimePath = normalizePath(url.pathname.slice(runtimeBase.length) || "/home"); + if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase())) + return ""; + const runtimePath = normalizePath( + url.pathname.slice(runtimeBase.length) || "/home", + ); if (runtimePath === "/app.html") { return normalizePath(url.searchParams.get("path") || "/home"); } @@ -108,14 +122,20 @@ const materializedRouteFromRuntime = (route) => { const normalized = normalizePath(route); if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users"; if (/^\/admin\/payments\/\d+$/i.test(normalized)) return "/admin/payments"; - if (/^\/admin\/payments\/users\/-?\d+$/i.test(normalized)) return "/admin/payments"; + if (/^\/admin\/payments\/users\/-?\d+$/i.test(normalized)) + return "/admin/payments"; if (/^\/admin\/support\/\d+$/i.test(normalized)) return "/admin/support"; if (/^\/support\/\d+$/i.test(normalized)) return "/support"; return normalized; }; -const publicPathFromRoute = (route) => `${demoBase}${materializedRouteFromRuntime(route)}`; -const routeForStateMock = (mock) => (mock === "devices" ? "/devices" : "/home"); +const publicPathFromRoute = (route) => + `${demoBase}${materializedRouteFromRuntime(route)}`; +const routeForStateMock = (mock) => { + if (mock === "devices") return "/devices"; + if (mock === "auth") return "/login"; + return "/home"; +}; const runtimeSrc = (route, searchParams = new URLSearchParams()) => { const nextParams = new URLSearchParams(searchParams); nextParams.delete("screen"); @@ -138,7 +158,9 @@ const syncParentUrlFromFrame = () => { nextUrl.pathname = publicPathFromRoute(route); nextUrl.searchParams.delete("path"); - const mock = normalizeRouteMock(frameUrl.searchParams.get("mock") || params.get("mock")); + const mock = normalizeRouteMock( + frameUrl.searchParams.get("mock") || params.get("mock"), + ); if (mock === defaultMock) nextUrl.searchParams.delete("mock"); else nextUrl.searchParams.set("mock", mock); if (stateSelect) stateSelect.value = normalizeStateMock(mock); @@ -147,7 +169,8 @@ const syncParentUrlFromFrame = () => { nextUrl.searchParams.delete("admin_section"); const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (nextPath !== currentPath) window.history.replaceState(null, "", nextPath); + if (nextPath !== currentPath) + window.history.replaceState(null, "", nextPath); } catch (_error) { // The iframe is same-origin in docs builds; this keeps local oddities harmless. } diff --git a/docs-site/scripts/materialize-demo-routes.mjs b/docs-site/scripts/materialize-demo-routes.mjs index dd79fa5..7ade645 100644 --- a/docs-site/scripts/materialize-demo-routes.mjs +++ b/docs-site/scripts/materialize-demo-routes.mjs @@ -13,6 +13,8 @@ const userRoutes = [ "devices", "support", "settings", + "login", + "login/password", ]; const adminRoutes = [ diff --git a/docs-site/src/pages/demo.astro b/docs-site/src/pages/demo.astro index c89705b..6cb8af1 100644 --- a/docs-site/src/pages/demo.astro +++ b/docs-site/src/pages/demo.astro @@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/'; +
diff --git a/docs-site/src/pages/demo/[...path].astro b/docs-site/src/pages/demo/[...path].astro index a606f5a..9605cc8 100644 --- a/docs-site/src/pages/demo/[...path].astro +++ b/docs-site/src/pages/demo/[...path].astro @@ -2,7 +2,17 @@ import DemoShell from '../demo.astro'; export function getStaticPaths() { - const userRoutes = ['home', 'install', 'trial', 'invite', 'devices', 'support', 'settings']; + const userRoutes = [ + 'home', + 'install', + 'trial', + 'invite', + 'devices', + 'support', + 'settings', + 'login', + 'login/password', + ]; const adminRoutes = [ 'stats', 'users', diff --git a/docs/getting-started/demo.md b/docs/getting-started/demo.md index 906b033..c58615d 100644 --- a/docs/getting-started/demo.md +++ b/docs/getting-started/demo.md @@ -12,6 +12,7 @@ - [Админка: бэкапы](/demo/admin/backups) - [Пробный период](/demo/home?mock=trial) - [Докупка устройств](/demo/devices?mock=devices) +- [Вход и регистрация](/demo/login?mock=auth) ## Как собирается diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e0ce5bf..f2a5209 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1043,6 +1043,41 @@ adminMountedTarget = null; } + function currentMockMode() { + if (!MOCK) return ""; + const currentMock = currentSearchParams().get("mock"); + if (currentMock) return String(currentMock).trim().toLowerCase(); + const parentMock = docsDemoParentSearchParams()?.get("mock"); + return String(parentMock || "") + .trim() + .toLowerCase(); + } + + function isDemoAuthMock() { + return ["auth", "login", "register"].includes(currentMockMode()); + } + + function prepareDemoAuthState() { + const authDemo = MOCK_SOURCE.data?.auth_demo || {}; + const email = String(authDemo.email || "demo.user@example.com").trim(); + authStore.update((s) => ({ + ...s, + authStatus: "", + authIsError: false, + authBusy: false, + authResendCooldown: 0, + email, + emailPassword: String(authDemo.password || ""), + pendingEmail: "", + emailCode: "", + passwordLoginMode: false, + passwordLoginFallback: false, + loginEmailFieldError: "", + loginEmailTooltipOpen: false, + telegramLoginBusy: false, + })); + } + function currentSearchParams() { return new URLSearchParams(window.location.search); } @@ -1196,6 +1231,11 @@ await loadPublicInstall(shareToken); return; } + if (MOCK && isDemoAuthMock()) { + prepareDemoAuthState(); + showLogin(); + return; + } await runWebappBoot({ MOCK, setMode: (next) => { @@ -1243,7 +1283,7 @@ window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`); } - function isPasswordLoginPath(pathname = window.location.pathname) { + function isPasswordLoginPath(pathname = routePathnameFromLocation()) { return ( String(pathname || "") .replace(/\/+$/, "") @@ -1253,7 +1293,15 @@ function syncPasswordLoginPath(enabled, replace = false) { if (typeof window === "undefined" || window.location.protocol === "file:") return; - const targetPath = enabled ? "/login/password" : "/"; + const targetPath = enabled ? "/login/password" : isDocsDemo ? "/login" : "/"; + if (isDocsDemo) { + const targetRuntimePath = withRoutePrefix(targetPath, routePrefix); + if (window.location.pathname === targetRuntimePath) return; + const nextUrl = `${targetRuntimePath}${window.location.search}${window.location.hash}`; + window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); + cleanDocsDemoRouteQuery(); + return; + } if (window.location.pathname === targetPath) return; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 1954eaa..25a62c2 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -21,6 +21,9 @@ let demoPaymentSequence = 20000; const demoSettingsChanges = new Map(); const demoPaymentStatuses = new Map(); const deviceTopupSaleModes = new Set(["hwid_device", "hwid_devices", "hwid_devices_renewal"]); +const DEFAULT_DEMO_AUTH_EMAIL = "demo.user@example.com"; +const DEFAULT_DEMO_AUTH_CODE = "123456"; +const DEFAULT_DEMO_AUTH_PASSWORD = "demo-password"; function demoPromos() { if (!demoPromosState) demoPromosState = defaultClone(DEMO_DATASET.promos || []); @@ -75,6 +78,75 @@ function isDeviceTopupSaleMode(value) { return deviceTopupSaleModes.has(String(value || "").toLowerCase()); } +function demoAuthConfig() { + return { + email: DEFAULT_DEMO_AUTH_EMAIL, + code: DEFAULT_DEMO_AUTH_CODE, + password: DEFAULT_DEMO_AUTH_PASSWORD, + ...(DEV_MOCK.data.auth_demo || {}), + }; +} + +function applyDemoEmailAuthUser(email) { + const normalizedEmail = String(email || demoAuthConfig().email || DEFAULT_DEMO_AUTH_EMAIL) + .trim() + .toLowerCase(); + const language = DEV_MOCK.data.user?.language_code || DEV_MOCK.config.language || "ru"; + DEV_MOCK.data.user = withDemoAvatar( + { + ...(DEV_MOCK.data.user || {}), + id: 910777, + user_id: 910777, + telegram_id: null, + telegram_linked: false, + telegram_photo_url: "", + avatar_url: "", + username: "", + first_name: "Email Demo", + last_name: "", + email: normalizedEmail, + email_verified: true, + password_auth_enabled: true, + is_admin: false, + language_code: language, + registration_date: "2026-05-28T12:00:00Z", + panel_status: "inactive", + }, + 160 + ); + DEV_MOCK.data.subscription = { + ...(DEV_MOCK.data.subscription || {}), + active: false, + status: "INACTIVE", + remaining_text: "Подписка не активна", + end_date_text: "", + days_left: 0, + config_link: null, + connect_url: null, + panel_short_uuid: "", + install_share_token: "", + install_share_url: "", + traffic_used: "0 B", + traffic_used_bytes: 0, + traffic_limit: "0 B", + traffic_limit_bytes: 0, + premium_used: "0 B", + premium_used_bytes: 0, + premium_limit: "0 B", + premium_limit_bytes: 0, + can_topup_regular_traffic: false, + can_topup_premium_traffic: false, + can_topup_devices: false, + extra_hwid_devices: 0, + max_devices: 0, + }; + DEV_MOCK.data.settings = { + ...(DEV_MOCK.data.settings || {}), + trial_enabled: true, + trial_available: true, + }; +} + function demoDeviceTopupPlan(body) { const deviceCount = Number(body.device_count || body.months || 0); const plans = DEV_MOCK.data.device_topup_options?.plans || []; @@ -1650,11 +1722,31 @@ export async function mockApi(path, options = {}, context = {}) { subscription, }; } - if (path === "/auth/email/request") return { ok: true }; + if (path === "/auth/email/request") { + const authDemo = demoAuthConfig(); + return { ok: true, email_code: authDemo.code }; + } if (path === "/auth/email/verify" || path === "/auth/email/magic") { + if (path === "/auth/email/verify") { + const body = jsonBody(options); + applyDemoEmailAuthUser(body.email); + } return { ok: true, csrf_token: "local-preview-csrf" }; } if (path === "/auth/email/password") { + const body = jsonBody(options); + const authDemo = demoAuthConfig(); + const normalizedEmail = String(body.email || "") + .trim() + .toLowerCase(); + const password = String(body.password || ""); + if ( + normalizedEmail === String(authDemo.email || DEFAULT_DEMO_AUTH_EMAIL).toLowerCase() && + password === String(authDemo.password || DEFAULT_DEMO_AUTH_PASSWORD) + ) { + applyDemoEmailAuthUser(normalizedEmail); + return { ok: true, csrf_token: "local-preview-csrf" }; + } return { ok: false, error: "password_login_failed", fallback: "email_code" }; } if (path === "/auth/token") { diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index b11d3b9..7e5e0f5 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -293,6 +293,12 @@ export const DEV_MOCK = { language_code: "ru", is_admin: true, }, + auth_demo: { + enabled: false, + email: "demo.user@example.com", + code: "123456", + password: "demo-password", + }, subscription: { active: true, status: "ACTIVE", @@ -551,6 +557,22 @@ export function applyPreviewMock(kind) { return; } + if (mode === "auth" || mode === "login" || mode === "register") { + DEV_MOCK.data.auth_demo = { + ...(DEV_MOCK.data.auth_demo || {}), + enabled: true, + email: "demo.user@example.com", + code: "123456", + password: "demo-password", + }; + DEV_MOCK.data.settings.email_auth_enabled = true; + DEV_MOCK.config.telegramOAuthClientId = 0; + DEV_MOCK.config.telegramLoginBotId = 0; + DEV_MOCK.data.settings.trial_enabled = true; + DEV_MOCK.data.settings.trial_available = true; + return; + } + if (mode === "tariffs") { DEV_MOCK.data.settings.traffic_mode = false; if (DEMO_DATASET.plans?.length) { diff --git a/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js index 8545e15..9d34fdb 100644 --- a/frontend/src/lib/webapp/stores/authStore.js +++ b/frontend/src/lib/webapp/stores/authStore.js @@ -181,7 +181,10 @@ export function createAuthStore({ if (referralParam) payload.referral_code = referralParam; const response = await publicApi("/auth/email/request", payload); if (!response.ok) throw response; - state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: "" })); + const presetCode = String(response.email_code || response.code || "") + .replace(/\D/g, "") + .slice(0, 6); + state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode })); changeScreen("code"); setAuthStatus(""); startCooldownTimer(60);