feat: add demo auth flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const userRoutes = [
|
||||
"devices",
|
||||
"support",
|
||||
"settings",
|
||||
"login",
|
||||
"login/password",
|
||||
];
|
||||
|
||||
const adminRoutes = [
|
||||
|
||||
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
|
||||
<option value="no-subscription">Нет подписки</option>
|
||||
<option value="trial">Доступна пробная подписка</option>
|
||||
<option value="devices">Лимит и докупка устройств</option>
|
||||
<option value="auth">Вход и регистрация</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="demo-topbar__actions">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- [Админка: бэкапы](/demo/admin/backups)
|
||||
- [Пробный период](/demo/home?mock=trial)
|
||||
- [Докупка устройств](/demo/devices?mock=devices)
|
||||
- [Вход и регистрация](/demo/login?mock=auth)
|
||||
|
||||
## Как собирается
|
||||
|
||||
|
||||
+50
-2
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user