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);