feat: add cloudflare auto-tunnel onboarding flow and bump version to v0.9.81
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vocechat-web",
|
"name": "vocechat-web",
|
||||||
"version": "0.9.80",
|
"version": "0.9.81",
|
||||||
"homepage": "https://voce.chat",
|
"homepage": "https://voce.chat",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metamask/onboarding": "^1.0.1",
|
"@metamask/onboarding": "^1.0.1",
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Admin-Konto",
|
"admin_account": "Admin-Konto",
|
||||||
"who_sign_up": "Registrierungsberechtigung",
|
"who_sign_up": "Registrierungsberechtigung",
|
||||||
"invites": "Einladungen",
|
"invites": "Einladungen",
|
||||||
"update_domain_tip": "Falsche Domain? Hier aktualisieren:"
|
"update_domain_tip": "Falsche Domain? Hier aktualisieren:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Admin Account",
|
"admin_account": "Admin Account",
|
||||||
"who_sign_up": "Who Can Sign Up",
|
"who_sign_up": "Who Can Sign Up",
|
||||||
"invites": "Invites",
|
"invites": "Invites",
|
||||||
"update_domain_tip": "Incorrect link domain?Please update it here:"
|
"update_domain_tip": "Incorrect link domain?Please update it here:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Cuenta de Administrador",
|
"admin_account": "Cuenta de Administrador",
|
||||||
"who_sign_up": "Quién Puede Registrarse",
|
"who_sign_up": "Quién Puede Registrarse",
|
||||||
"invites": "Invitaciones",
|
"invites": "Invitaciones",
|
||||||
"update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:"
|
"update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Compte Administrateur",
|
"admin_account": "Compte Administrateur",
|
||||||
"who_sign_up": "Qui Peut S'inscrire",
|
"who_sign_up": "Qui Peut S'inscrire",
|
||||||
"invites": "Invitations",
|
"invites": "Invitations",
|
||||||
"update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :"
|
"update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Conta de administrador",
|
"admin_account": "Conta de administrador",
|
||||||
"who_sign_up": "Quem pode se inscrever",
|
"who_sign_up": "Quem pode se inscrever",
|
||||||
"invites": "Convites",
|
"invites": "Convites",
|
||||||
"update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:"
|
"update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Учетная запись администратора",
|
"admin_account": "Учетная запись администратора",
|
||||||
"who_sign_up": "Кто может зарегистрироваться",
|
"who_sign_up": "Кто может зарегистрироваться",
|
||||||
"invites": "Приглашения",
|
"invites": "Приглашения",
|
||||||
"update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:"
|
"update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "Yönetici Hesabı",
|
"admin_account": "Yönetici Hesabı",
|
||||||
"who_sign_up": "Kimler kaydolabilir",
|
"who_sign_up": "Kimler kaydolabilir",
|
||||||
"invites": "Davetler",
|
"invites": "Davetler",
|
||||||
"update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:"
|
"update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:",
|
||||||
|
"tunnel_initializing": "Initializing Tunnel service...",
|
||||||
|
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
|
||||||
|
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
|
||||||
|
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
|
||||||
|
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
|
||||||
|
"tunnel_opt_in_yes": "Yes",
|
||||||
|
"tunnel_opt_in_skip": "Skip",
|
||||||
|
"tunnel_progress": "Starting tunnel...",
|
||||||
|
"tunnel_error": "Tunnel error",
|
||||||
|
"tunnel_get_domain": "Get Public Domain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"admin_account": "管理员账号",
|
"admin_account": "管理员账号",
|
||||||
"who_sign_up": "注册权限",
|
"who_sign_up": "注册权限",
|
||||||
"invites": "邀请",
|
"invites": "邀请",
|
||||||
"update_domain_tip": "链接域名有问题?可在此更新设置:"
|
"update_domain_tip": "链接域名有问题?可在此更新设置:",
|
||||||
|
"tunnel_initializing": "正在初始化 Tunnel 服务...",
|
||||||
|
"tunnel_dns_warning": "正在等待 DNS 传播,如果访问失败请尝试清除 DNS 缓存后再试",
|
||||||
|
"tunnel_redirect_countdown": "{{seconds}} 秒后重定向到公网地址...",
|
||||||
|
"tunnel_domain_assigned": "已为您分配了一个临时的公网域名",
|
||||||
|
"tunnel_opt_in_title": "获取自动分配的公网域名?",
|
||||||
|
"tunnel_opt_in_yes": "是",
|
||||||
|
"tunnel_opt_in_skip": "跳过",
|
||||||
|
"tunnel_progress": "正在启动 Tunnel...",
|
||||||
|
"tunnel_error": "Tunnel 出错",
|
||||||
|
"tunnel_get_domain": "获取公网域名"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ const whiteList = [
|
|||||||
"preCheckFileFromUrl",
|
"preCheckFileFromUrl",
|
||||||
"passkeyLoginStart",
|
"passkeyLoginStart",
|
||||||
"passkeyLoginFinish",
|
"passkeyLoginFinish",
|
||||||
|
"getAutoTunnelInfo",
|
||||||
];
|
];
|
||||||
const whiteList401 = [
|
const whiteList401 = [
|
||||||
"getAgoraVoicingList",
|
"getAgoraVoicingList",
|
||||||
"getAgoraChannels",
|
"getAgoraChannels",
|
||||||
"getGoogleAuthConfig",
|
"getGoogleAuthConfig",
|
||||||
"getGithubAuthConfig",
|
"getGithubAuthConfig",
|
||||||
|
"getAutoTunnelInfo",
|
||||||
];
|
];
|
||||||
const errorWhiteList = [
|
const errorWhiteList = [
|
||||||
"preCheckFileFromUrl",
|
"preCheckFileFromUrl",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AgoraConfig,
|
AgoraConfig,
|
||||||
AgoraTokenResponse,
|
AgoraTokenResponse,
|
||||||
AgoraVoicingListResponse,
|
AgoraVoicingListResponse,
|
||||||
|
AutoTunnelInfo,
|
||||||
CloudflaredStatus,
|
CloudflaredStatus,
|
||||||
CreateAdminDTO,
|
CreateAdminDTO,
|
||||||
FirebaseConfig,
|
FirebaseConfig,
|
||||||
@@ -493,6 +494,9 @@ export const serverApi = createApi({
|
|||||||
responseHandler: "text",
|
responseHandler: "text",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
getAutoTunnelInfo: builder.query<AutoTunnelInfo, void>({
|
||||||
|
query: () => ({ url: `/admin/cloudflared/auto_info` }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,4 +560,6 @@ export const {
|
|||||||
useDeleteGroupAnnouncementMutation,
|
useDeleteGroupAnnouncementMutation,
|
||||||
useLazyGetCloudflaredStatusQuery,
|
useLazyGetCloudflaredStatusQuery,
|
||||||
useStopCloudflaredMutation,
|
useStopCloudflaredMutation,
|
||||||
|
useGetAutoTunnelInfoQuery,
|
||||||
|
useLazyGetAutoTunnelInfoQuery,
|
||||||
} = serverApi;
|
} = serverApi;
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useWizard, Wizard } from "react-use-wizard";
|
import { useWizard, Wizard } from "react-use-wizard";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import steps from "./steps";
|
import { buildSteps } from "./steps";
|
||||||
import AdminAccount from "./steps/admin-account";
|
import AdminAccount from "./steps/admin-account";
|
||||||
import DonePage from "./steps/done-page";
|
import DonePage from "./steps/done-page";
|
||||||
|
import GetPublicDomain from "./steps/get-public-domain";
|
||||||
import InviteLink from "./steps/invite-link";
|
import InviteLink from "./steps/invite-link";
|
||||||
import ServerName from "./steps/server-name";
|
import ServerName from "./steps/server-name";
|
||||||
import WelcomePage from "./steps/welcome-page";
|
import WelcomePage from "./steps/welcome-page";
|
||||||
import WhoCanSignUp from "./steps/who-can-sign-up";
|
import WhoCanSignUp from "./steps/who-can-sign-up";
|
||||||
import SelectLanguage from "../../components/Language";
|
import SelectLanguage from "../../components/Language";
|
||||||
|
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
|
||||||
|
|
||||||
const Navigator = () => {
|
const Navigator = ({ showTunnelStep }: { showTunnelStep: boolean }) => {
|
||||||
const { activeStep, goToStep } = useWizard();
|
const { activeStep, goToStep } = useWizard();
|
||||||
|
const steps = buildSteps(showTunnelStep);
|
||||||
const canJumpTo = steps[activeStep]?.canJumpTo || [];
|
const canJumpTo = steps[activeStep]?.canJumpTo || [];
|
||||||
console.log("active step", activeStep);
|
console.log("active step", activeStep);
|
||||||
|
|
||||||
@@ -51,17 +54,21 @@ const Navigator = () => {
|
|||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const { t } = useTranslation("welcome");
|
const { t } = useTranslation("welcome");
|
||||||
const [serverName, setServerName] = useState("");
|
const [serverName, setServerName] = useState("");
|
||||||
|
const { data: autoInfo } = useGetAutoTunnelInfoQuery();
|
||||||
|
const showTunnelStep = autoInfo ? !autoInfo.auto_cftunnel : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>{t("onboarding.title") || ""}</title>
|
<title>{t("onboarding.title") || ""}</title>
|
||||||
<div className="h-screen bg-neutral-100 dark:bg-neutral-900 overflow-y-auto">
|
<div className="h-screen bg-neutral-100 dark:bg-neutral-900 overflow-y-auto">
|
||||||
<Wizard header={<Navigator />}>
|
<Wizard header={<Navigator showTunnelStep={showTunnelStep} />}>
|
||||||
<WelcomePage />
|
<WelcomePage />
|
||||||
<ServerName serverName={serverName} setServerName={setServerName} />
|
<ServerName serverName={serverName} setServerName={setServerName} />
|
||||||
<AdminAccount serverName={serverName} />
|
<AdminAccount serverName={serverName} />
|
||||||
<WhoCanSignUp />
|
<WhoCanSignUp />
|
||||||
{/* lazy call invite link API */}
|
{/* lazy call invite link API */}
|
||||||
<InviteLink />
|
<InviteLink />
|
||||||
|
{showTunnelStep && <GetPublicDomain />}
|
||||||
<DonePage serverName={serverName} />
|
<DonePage serverName={serverName} />
|
||||||
</Wizard>
|
</Wizard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// import { useState } from "react";
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
|
||||||
// `name` for in-code usage, `label` for display
|
// `name` for in-code usage, `label` for display
|
||||||
@@ -8,33 +7,48 @@ export interface Step {
|
|||||||
canJumpTo?: string[];
|
canJumpTo?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const steps: Step[] = [
|
export function buildSteps(showTunnelStep: boolean): Step[] {
|
||||||
{
|
const base: Step[] = [
|
||||||
name: "welcomePage",
|
{
|
||||||
label: t("welcome:onboarding.welcome_page")
|
name: "welcomePage",
|
||||||
},
|
label: t("welcome:onboarding.welcome_page")
|
||||||
{
|
},
|
||||||
name: "serverName",
|
{
|
||||||
label: t("welcome:onboarding.set_name")
|
name: "serverName",
|
||||||
},
|
label: t("welcome:onboarding.set_name")
|
||||||
{
|
},
|
||||||
name: "adminAccount",
|
{
|
||||||
label: t("welcome:onboarding.admin_account")
|
name: "adminAccount",
|
||||||
},
|
label: t("welcome:onboarding.admin_account")
|
||||||
{
|
},
|
||||||
name: "whoCanSignUp",
|
{
|
||||||
label: t("welcome:onboarding.who_sign_up")
|
name: "whoCanSignUp",
|
||||||
},
|
label: t("welcome:onboarding.who_sign_up")
|
||||||
{
|
},
|
||||||
name: "inviteLink",
|
{
|
||||||
label: t("welcome:onboarding.invites"),
|
name: "inviteLink",
|
||||||
canJumpTo: ["whoCanSignUp"]
|
label: t("welcome:onboarding.invites"),
|
||||||
},
|
canJumpTo: ["whoCanSignUp"]
|
||||||
{
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showTunnelStep) {
|
||||||
|
base.push({
|
||||||
|
name: "getPublicDomain",
|
||||||
|
label: t("welcome:onboarding.tunnel_get_domain"),
|
||||||
|
canJumpTo: ["whoCanSignUp", "inviteLink"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
base.push({
|
||||||
name: "donePage",
|
name: "donePage",
|
||||||
label: t("welcome:onboarding.done"),
|
label: t("welcome:onboarding.done"),
|
||||||
canJumpTo: ["whoCanSignUp", "inviteLink"]
|
canJumpTo: showTunnelStep
|
||||||
}
|
? ["whoCanSignUp", "inviteLink", "getPublicDomain"]
|
||||||
];
|
: ["whoCanSignUp", "inviteLink"]
|
||||||
|
});
|
||||||
|
|
||||||
export default steps;
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default buildSteps(false);
|
||||||
|
|||||||
@@ -3,20 +3,40 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import StyledButton from "@/components/styled/Button";
|
import StyledButton from "@/components/styled/Button";
|
||||||
import PlayIcon from "@/assets/icons/play.svg?url";
|
import PlayIcon from "@/assets/icons/play.svg?url";
|
||||||
|
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
|
||||||
|
|
||||||
export default function DonePage({ serverName }: { serverName: string }) {
|
export default function DonePage({ serverName }: { serverName: string }) {
|
||||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { data: autoInfo } = useGetAutoTunnelInfoQuery();
|
||||||
|
|
||||||
|
const tunnelUrl =
|
||||||
|
autoInfo?.tunnel_status.status === "running" ? autoInfo.tunnel_status.url : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-2 flex-center flex-col text-center md:w-[588px] m-auto dark:text-gray-100">
|
<div className="h-full px-2 flex-center flex-col text-center md:w-[588px] m-auto dark:text-gray-100">
|
||||||
<span className="text-2xl font-bold mb-2">{t("done_welcome", { serverName })}</span>
|
<span className="text-2xl font-bold mb-2">{t("done_welcome", { serverName })}</span>
|
||||||
<span className="text-sm mb-12">{t("done_title")}</span>
|
<span className="text-sm mb-6">{t("done_title")}</span>
|
||||||
<span className="text-xl mb-12">
|
<span className="text-xl mb-6">
|
||||||
<Trans i18nKey={"onboarding.done_desc"} ns={"welcome"}>
|
<Trans i18nKey={"onboarding.done_desc"} ns={"welcome"}>
|
||||||
<span className="font-bold" />
|
<span className="font-bold" />
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
{tunnelUrl && (
|
||||||
|
<div className="w-full mb-8 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg text-left">
|
||||||
|
<p className="text-sm font-semibold text-green-700 dark:text-green-300 mb-1">
|
||||||
|
{t("tunnel_domain_assigned")}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={tunnelUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-mono text-sm text-green-600 dark:text-green-400 break-all hover:underline"
|
||||||
|
>
|
||||||
|
{tunnelUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<StyledButton
|
<StyledButton
|
||||||
className="!w-32 h-auto flex flex-col items-center py-3"
|
className="!w-32 h-auto flex flex-col items-center py-3"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useWizard } from "react-use-wizard";
|
||||||
|
|
||||||
|
import StyledButton from "@/components/styled/Button";
|
||||||
|
import { BASE_ORIGIN, tokenHeader } from "@/app/config";
|
||||||
|
import { getLocalAuthData } from "@/utils";
|
||||||
|
|
||||||
|
type Phase = "prompt" | "starting" | "done" | "error";
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 10;
|
||||||
|
|
||||||
|
interface SseEvent {
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
url: string | null;
|
||||||
|
progress: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GetPublicDomain() {
|
||||||
|
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||||
|
const { nextStep } = useWizard();
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>("prompt");
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const countRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
if (countRef.current) clearInterval(countRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addLog = (msg: string) =>
|
||||||
|
setLogs((prev) => [...prev.slice(-29), msg]);
|
||||||
|
|
||||||
|
function startCountdown(url: string) {
|
||||||
|
setTunnelUrl(url);
|
||||||
|
setPhase("done");
|
||||||
|
let remaining = COUNTDOWN_SECONDS;
|
||||||
|
setCountdown(remaining);
|
||||||
|
countRef.current = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
setCountdown(remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(countRef.current!);
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYes = async () => {
|
||||||
|
setPhase("starting");
|
||||||
|
setLogs([]);
|
||||||
|
setErrorMsg(null);
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token } = getLocalAuthData();
|
||||||
|
const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/start`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { [tokenHeader]: token },
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok || !resp.body) {
|
||||||
|
setPhase("error");
|
||||||
|
setErrorMsg(`HTTP ${resp.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const parts = buf.split("\n\n");
|
||||||
|
buf = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const line = part.trim();
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
try {
|
||||||
|
const evt: SseEvent = JSON.parse(line.slice("data:".length).trim());
|
||||||
|
addLog(evt.message);
|
||||||
|
if (evt.event === "url" || evt.event === "already_running") {
|
||||||
|
reader.cancel();
|
||||||
|
startCountdown(evt.url!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.event === "error" || evt.event === "stopped") {
|
||||||
|
reader.cancel();
|
||||||
|
setPhase("error");
|
||||||
|
setErrorMsg(evt.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name !== "AbortError") {
|
||||||
|
setPhase("error");
|
||||||
|
setErrorMsg(String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (phase === "prompt") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-8 dark:text-gray-100 px-4">
|
||||||
|
<span className="text-2xl font-bold">{t("tunnel_opt_in_title")}</span>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<StyledButton className="w-24 h-11" onClick={handleYes}>
|
||||||
|
{t("tunnel_opt_in_yes")}
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton className="w-24 h-11 ghost" onClick={nextStep}>
|
||||||
|
{t("tunnel_opt_in_skip")}
|
||||||
|
</StyledButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "starting") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
|
||||||
|
<div className="w-10 h-10 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-lg font-semibold">{t("tunnel_progress")}</span>
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="w-full md:w-[480px] p-3 bg-gray-50 dark:bg-gray-800 rounded-lg font-mono text-xs text-gray-500 dark:text-gray-400 max-h-40 overflow-y-auto text-left flex flex-col gap-0.5">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "done" && tunnelUrl) {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4 md:max-w-md m-auto">
|
||||||
|
<div className="w-10 h-10 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="font-mono text-sm break-all text-green-600 dark:text-green-400">
|
||||||
|
{tunnelUrl}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{t("tunnel_redirect_countdown", { seconds: countdown })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
|
||||||
|
<span className="text-lg font-semibold text-red-500">{t("tunnel_error")}</span>
|
||||||
|
{errorMsg && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 break-all max-w-sm">
|
||||||
|
{errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<StyledButton className="w-24 h-11" onClick={nextStep}>
|
||||||
|
{t("tunnel_opt_in_skip")}
|
||||||
|
</StyledButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,23 +1,182 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useWizard } from "react-use-wizard";
|
import { useWizard } from "react-use-wizard";
|
||||||
|
|
||||||
import StyledButton from "@/components/styled/Button";
|
import StyledButton from "@/components/styled/Button";
|
||||||
import PlayIcon from "@/assets/icons/play.svg?url";
|
import PlayIcon from "@/assets/icons/play.svg?url";
|
||||||
|
import { BASE_ORIGIN } from "@/app/config";
|
||||||
|
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
|
||||||
|
|
||||||
|
type Phase = "loading" | "normal" | "initializing" | "dns_wait" | "redirecting";
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 10;
|
||||||
|
|
||||||
|
interface SseEvent {
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
url: string | null;
|
||||||
|
progress: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function WelcomePage() {
|
export default function WelcomePage() {
|
||||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||||
const { nextStep } = useWizard();
|
const { nextStep } = useWizard();
|
||||||
return (
|
const { data: autoInfo, isLoading, isError } = useGetAutoTunnelInfoQuery();
|
||||||
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
|
|
||||||
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
|
const [phase, setPhase] = useState<Phase>("loading");
|
||||||
<span className="text-sm mb-6">{t("welcome_desc")}</span>
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
<StyledButton
|
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
|
||||||
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
|
const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
|
||||||
onClick={nextStep}
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
>
|
const countRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
<img src={PlayIcon} alt="play icon" />
|
|
||||||
<span>{t("start")}</span>
|
// Determine phase once auto_info loads
|
||||||
</StyledButton>
|
useEffect(() => {
|
||||||
</div>
|
if (isLoading) return;
|
||||||
);
|
if (isError || !autoInfo?.auto_cftunnel) {
|
||||||
|
setPhase("normal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// AUTO_CFTUNNEL=true: check if already running
|
||||||
|
if (autoInfo.tunnel_status.status === "running" && autoInfo.tunnel_status.url) {
|
||||||
|
setTunnelUrl(autoInfo.tunnel_status.url);
|
||||||
|
startDnsCountdown(autoInfo.tunnel_status.url);
|
||||||
|
} else {
|
||||||
|
setPhase("initializing");
|
||||||
|
}
|
||||||
|
}, [isLoading, autoInfo]);
|
||||||
|
|
||||||
|
// Subscribe to SSE progress stream while initializing
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "initializing") return;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/auto_stream`, { signal: ctrl.signal });
|
||||||
|
if (!resp.ok || !resp.body) return;
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const parts = buf.split("\n\n");
|
||||||
|
buf = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const line = part.trim();
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
try {
|
||||||
|
const evt: SseEvent = JSON.parse(line.slice("data:".length).trim());
|
||||||
|
if (evt.message) {
|
||||||
|
setLogs((prev) => [...prev.slice(-29), evt.message]);
|
||||||
|
}
|
||||||
|
if (evt.event === "url" || evt.event === "already_running") {
|
||||||
|
reader.cancel();
|
||||||
|
startDnsCountdown(evt.url!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// AbortError on cleanup, or transient network error — stay in initializing
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
function startDnsCountdown(url: string) {
|
||||||
|
setPhase("dns_wait");
|
||||||
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
|
let remaining = COUNTDOWN_SECONDS;
|
||||||
|
countRef.current = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
setCountdown(remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(countRef.current!);
|
||||||
|
setPhase("redirecting");
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (countRef.current) clearInterval(countRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (phase === "loading" || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "normal") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
|
||||||
|
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
|
||||||
|
<span className="text-sm mb-6">{t("welcome_desc")}</span>
|
||||||
|
<StyledButton
|
||||||
|
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
|
||||||
|
onClick={nextStep}
|
||||||
|
>
|
||||||
|
<img src={PlayIcon} alt="play icon" />
|
||||||
|
<span>{t("start")}</span>
|
||||||
|
</StyledButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "initializing") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
|
||||||
|
<div className="w-10 h-10 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-lg font-semibold">{t("tunnel_initializing")}</span>
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="w-full md:w-[480px] p-3 bg-gray-50 dark:bg-gray-800 rounded-lg font-mono text-xs text-gray-500 dark:text-gray-400 max-h-40 overflow-y-auto text-left flex flex-col gap-0.5">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "dns_wait" || phase === "redirecting") {
|
||||||
|
return (
|
||||||
|
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4 md:max-w-md m-auto">
|
||||||
|
<div className="w-10 h-10 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
{tunnelUrl && (
|
||||||
|
<span className="font-mono text-sm break-all text-green-600 dark:text-green-400">
|
||||||
|
{tunnelUrl}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("tunnel_dns_warning")}
|
||||||
|
</p>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{t("tunnel_redirect_countdown", { seconds: countdown })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,3 +150,8 @@ export interface CloudflaredStatus {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoTunnelInfo {
|
||||||
|
auto_cftunnel: boolean;
|
||||||
|
tunnel_status: CloudflaredStatus;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user