diff --git a/app/public/site.webmanifest b/app/public/site.webmanifest index 1e0c42e..3b251ef 100644 --- a/app/public/site.webmanifest +++ b/app/public/site.webmanifest @@ -1,6 +1,6 @@ { "name": "Chat Nio", - "short_name": "ChatNio", + "short_name": "Chat Nio", "icons": [ { "src": "/service/android-chrome-192x192.png", diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts new file mode 100644 index 0000000..9a69adc --- /dev/null +++ b/app/src/api/auth.ts @@ -0,0 +1,111 @@ +import axios from "axios"; +import { getErrorMessage } from "@/utils/base.ts"; + +export type LoginForm = { + username: string; + password: string; +}; + +export type DeepLoginForm = { + token: string; +}; + +export type LoginResponse = { + status: boolean; + error: string; + token: string; +}; + +export type StateResponse = { + status: boolean; + user: string; + admin: boolean; +}; + +export type RegisterForm = { + username: string; + password: string; + repassword: string; + email: string; + code: string; +}; + +export type RegisterResponse = { + status: boolean; + error: string; + token: string; +}; + +export type VerifyForm = { + email: string; +}; + +export type VerifyResponse = { + status: boolean; + error: string; +}; + +export type ResetForm = { + email: string; + code: string; + password: string; + repassword: string; +}; + +export type ResetResponse = { + status: boolean; + error: string; +}; + +export async function doLogin( + data: DeepLoginForm | LoginForm, +): Promise { + const response = await axios.post("/login", data); + return response.data as LoginResponse; +} + +export async function doState(): Promise { + const response = await axios.post("/state"); + return response.data as StateResponse; +} + +export async function doRegister( + data: RegisterForm, +): Promise { + try { + const response = await axios.post("/register", data); + return response.data as RegisterResponse; + } catch (e) { + return { + status: false, + error: getErrorMessage(e), + token: "", + }; + } +} + +export async function doVerify(email: string): Promise { + try { + const response = await axios.post("/verify", { + email, + } as VerifyForm); + return response.data as VerifyResponse; + } catch (e) { + return { + status: false, + error: getErrorMessage(e), + }; + } +} + +export async function doReset(data: ResetForm): Promise { + try { + const response = await axios.post("/reset", data); + return response.data as ResetResponse; + } catch (e) { + return { + status: false, + error: getErrorMessage(e), + }; + } +} diff --git a/app/src/assets/globals.less b/app/src/assets/globals.less index 075afe1..c1f8932 100644 --- a/app/src/assets/globals.less +++ b/app/src/assets/globals.less @@ -59,6 +59,7 @@ --assistant-shadow: hsla(218, 100%, 64%, .03); --gold: 45 100% 50%; + --link: 210 100% 63%; } .dark { diff --git a/app/src/assets/main.less b/app/src/assets/main.less index 086c483..89601d3 100644 --- a/app/src/assets/main.less +++ b/app/src/assets/main.less @@ -43,6 +43,7 @@ html, body { outline: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; + -webkit-overflow-scrolling: touch; } body { diff --git a/app/src/assets/pages/auth.less b/app/src/assets/pages/auth.less index 4a9f9e3..997cf18 100644 --- a/app/src/assets/pages/auth.less +++ b/app/src/assets/pages/auth.less @@ -4,3 +4,79 @@ overflow: hidden; background: hsla(var(--background-container)); } + +.auth-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 2.5rem 2rem; + user-select: none; + + .logo { + width: 4rem; + height: 4rem; + } + + & > * { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } +} + +.auth-card { + width: 80vw; + max-width: 360px; + min-width: 280px; + margin: 1rem 0; +} + +.auth-wrapper { + display: flex; + flex-direction: column; + padding: 1.5rem 0; + + & > * { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } +} + +.addition-wrapper { + display: flex; + flex-direction: column; + border-radius: var(--radius); + border: 1px solid hsla(var(--border)); + padding: 1.25rem; + align-items: center; + transform: translateY(-1rem); + font-size: 0.875rem; + text-align: center; + + a { + text-decoration: underline; + text-underline-offset: 0.25rem; + text-underline: 2px solid hsl(var(--text-secondary)); + color: hsl(var(--text-secondary)); + transition: 0.2s ease-in-out; + cursor: pointer; + + &:hover { + color: hsl(var(--text)); + text-underline-color: hsl(var(--text)); + } + } + + .row { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/app/src/components/I18nProvider.tsx b/app/src/components/I18nProvider.tsx index 05df2cf..2421bf8 100644 --- a/app/src/components/I18nProvider.tsx +++ b/app/src/components/I18nProvider.tsx @@ -16,7 +16,7 @@ function I18nProvider() { diff --git a/app/src/components/ProjectLink.tsx b/app/src/components/ProjectLink.tsx index b3233bb..9542312 100644 --- a/app/src/components/ProjectLink.tsx +++ b/app/src/components/ProjectLink.tsx @@ -26,7 +26,7 @@ function ProjectLink() { > *; } +export type LengthRangeRequiredProps = { + content: string; + min: number; + max: number; + hideOnEmpty?: boolean; +}; + +export function LengthRangeRequired({ + content, + min, + max, + hideOnEmpty, +}: LengthRangeRequiredProps) { + const { t } = useTranslation(); + const onDisplay = useMemo(() => { + if (hideOnEmpty && content.length === 0) return false; + return content.length < min || content.length > max; + }, [content, min, max, hideOnEmpty]); + + return ( + + ({t("auth.length-range", { min, max })}) + + ); +} + +export type SameRequiredProps = { + content: string; + compare: string; + hideOnEmpty?: boolean; +}; + +export function SameRequired({ + content, + compare, + hideOnEmpty, +}: SameRequiredProps) { + const { t } = useTranslation(); + const onDisplay = useMemo(() => { + if (hideOnEmpty && compare.length === 0) return false; + return content !== compare; + }, [content, compare, hideOnEmpty]); + + return ( + + ({t("auth.same-rule")}) + + ); +} + +export type EmailRequireProps = { + content: string; + hideOnEmpty?: boolean; +}; + +export function EmailRequire({ content, hideOnEmpty }: EmailRequireProps) { + const { t } = useTranslation(); + const onDisplay = useMemo(() => { + if (hideOnEmpty && content.length === 0) return false; + return !isEmailValid(content); + }, [content, hideOnEmpty]); + + return ( + + ({t("auth.invalid-email")}) + + ); +} + export default Required; diff --git a/app/src/components/ThemeProvider.tsx b/app/src/components/ThemeProvider.tsx index db8095c..4e77afc 100644 --- a/app/src/components/ThemeProvider.tsx +++ b/app/src/components/ThemeProvider.tsx @@ -95,8 +95,12 @@ export function ModeToggle() { return ( ); } diff --git a/app/src/components/TickButton.tsx b/app/src/components/TickButton.tsx new file mode 100644 index 0000000..8085c0d --- /dev/null +++ b/app/src/components/TickButton.tsx @@ -0,0 +1,53 @@ +import { Button, ButtonProps } from "@/components/ui/button.tsx"; +import React, { useEffect, useRef, useState } from "react"; +import { isAsyncFunc } from "@/utils/base.ts"; + +export interface TickButtonProps extends ButtonProps { + tick: number; + onTickChange?: (tick: number) => void; + onClick?: ( + e: React.MouseEvent, + ) => boolean | Promise; +} + +function TickButton({ + tick, + onTickChange, + onClick, + children, + ...props +}: TickButtonProps) { + const stamp = useRef(0); + const [timer, setTimer] = useState(0); + + useEffect(() => { + setInterval(() => { + const offset = Math.floor((Number(Date.now()) - stamp.current) / 1000); + let value = tick - offset; + if (value <= 0) value = 0; + setTimer(value); + onTickChange && onTickChange(value); + }, 250); + }, []); + + const onReset = () => (stamp.current = Number(Date.now())); + + // if is async function, use this: + const onTrigger = isAsyncFunc(onClick) + ? async (e: React.MouseEvent) => { + if (timer !== 0 || !onClick) return; + if (await onClick(e)) onReset(); + } + : (e: React.MouseEvent) => { + if (timer !== 0 || !onClick) return; + if (onClick(e)) onReset(); + }; + + return ( + + ); +} + +export default TickButton; diff --git a/app/src/components/app/NavBar.tsx b/app/src/components/app/NavBar.tsx index b83137e..c081825 100644 --- a/app/src/components/app/NavBar.tsx +++ b/app/src/components/app/NavBar.tsx @@ -9,7 +9,7 @@ import { import { Button } from "@/components/ui/button.tsx"; import { Menu } from "lucide-react"; import { useEffect } from "react"; -import { login, tokenField } from "@/conf.ts"; +import { tokenField } from "@/conf.ts"; import { toggleMenu } from "@/store/menu.ts"; import ProjectLink from "@/components/ProjectLink.tsx"; import ModeToggle from "@/components/ThemeProvider.tsx"; @@ -17,6 +17,7 @@ import router from "@/router.tsx"; import MenuBar from "./MenuBar.tsx"; import { getMemory } from "@/utils/memory.ts"; import { deeptrainApiEndpoint } from "@/utils/env.ts"; +import { goAuth } from "@/utils/app.ts"; function NavMenu() { const username = useSelector(selectUsername); @@ -62,7 +63,7 @@ function NavBar() { {auth ? ( ) : ( - )} diff --git a/app/src/components/home/ModelFinder.tsx b/app/src/components/home/ModelFinder.tsx index 6e9c8c8..ddc7926 100644 --- a/app/src/components/home/ModelFinder.tsx +++ b/app/src/components/home/ModelFinder.tsx @@ -1,5 +1,5 @@ import SelectGroup, { SelectItemProps } from "@/components/SelectGroup.tsx"; -import { expensiveModels, login, supportModels } from "@/conf.ts"; +import { expensiveModels, supportModels } from "@/conf.ts"; import { getPlanModels, openMarket, @@ -18,6 +18,7 @@ import { teenagerSelector } from "@/store/package.ts"; import { ToastAction } from "@/components/ui/toast.tsx"; import { useMemo } from "react"; import { Sparkles } from "lucide-react"; +import { goAuth } from "@/utils/app.ts"; function GetModel(name: string): Model { return supportModels.find((model) => model.id === name) as Model; @@ -99,7 +100,7 @@ function ModelFinder(props: ModelSelectorProps) { toast({ title: t("login-require"), action: ( - + {t("login")} ), diff --git a/app/src/components/home/ModelMarket.tsx b/app/src/components/home/ModelMarket.tsx index 9a7a7e9..c786a21 100644 --- a/app/src/components/home/ModelMarket.tsx +++ b/app/src/components/home/ModelMarket.tsx @@ -10,7 +10,7 @@ import { X, } from "lucide-react"; import React, { useMemo, useState } from "react"; -import { login, modelAvatars, supportModels } from "@/conf.ts"; +import { modelAvatars, supportModels } from "@/conf.ts"; import { splitList } from "@/utils/base.ts"; import { Model } from "@/api/types.ts"; import { useDispatch, useSelector } from "react-redux"; @@ -30,6 +30,7 @@ import { ToastAction } from "@/components/ui/toast.tsx"; import { selectAuthenticated } from "@/store/auth.ts"; import { useToast } from "@/components/ui/use-toast.ts"; import { docsEndpoint } from "@/utils/env.ts"; +import { goAuth } from "@/utils/app.ts"; type SearchBarProps = { value: string; @@ -100,7 +101,7 @@ function ModelItem({ model, className, style }: ModelProps) { toast({ title: t("login-require"), action: ( - + {t("login")} ), diff --git a/app/src/components/home/SideBar.tsx b/app/src/components/home/SideBar.tsx index 200db5c..5786e49 100644 --- a/app/src/components/home/SideBar.tsx +++ b/app/src/components/home/SideBar.tsx @@ -39,10 +39,10 @@ import { } from "@/components/ui/alert-dialog.tsx"; import { getSharedLink, shareConversation } from "@/api/sharing.ts"; import { Input } from "@/components/ui/input.tsx"; -import { login } from "@/conf.ts"; import MenuBar from "@/components/app/MenuBar.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { deeptrainApiEndpoint } from "@/utils/env.ts"; +import { goAuth } from "@/utils/app.ts"; type Operation = { target: ConversationInstance | null; @@ -358,7 +358,7 @@ function SideBar() { ) : ( - )} diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index a1605cd..a28ae58 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -4,6 +4,7 @@ "not-found": "页面未找到", "home": "首页", "login": "登录", + "register": "注册", "login-require": "您需要登录才能使用此功能", "logout": "登出", "quota": "点数", @@ -34,6 +35,34 @@ "fatal": "应用崩溃", "download-fatal-log": "下载错误日志", "fatal-tips": "请您先检查您的网络,浏览器兼容性,尝试清除浏览器缓存并刷新页面。如果问题仍然存在,请将日志提供给开发者以便我们排查问题。", + "auth": { + "username": "用户名", + "username-placeholder": "请输入用户名", + "password": "密码", + "password-placeholder": "请输入密码", + "check-password": "确认密码", + "check-password-placeholder": "请再次输入密码", + "email": "邮箱", + "email-placeholder": "请输入邮箱", + "username-or-email": "用户名或邮箱", + "username-or-email-placeholder": "请输入用户名或邮箱", + "code": "验证码", + "code-placeholder": "请输入验证码", + "send-code": "发送", + "incorrect-info": "填错信息?", + "fall-back": "回退一步", + "forgot-password": "忘记密码?", + "reset-password": "重置密码", + "no-account": "没有账号?", + "register": "注册一个", + "have-account": "已有账号?", + "login": "现在登录", + "next-step": "下一步", + "verify": "验证", + "length-range": "应为 {{min}} ~ {{max}} 位", + "same-rule": "两次输入不一致", + "invalid-email": "邮箱格式错误" + }, "tag": { "free": "免费", "official": "官方", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index dff2e3c..74ad0f6 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -423,5 +423,34 @@ "title": "Mask Settings", "search": "Search Mask Name", "context": "Contains {{length}} context" + }, + "register": "Register", + "auth": { + "username": "Username", + "password": "Password", + "username-or-email": "Username or E-mail", + "username-or-email-placeholder": "Please enter your username or mailbox", + "password-placeholder": "Please enter the password", + "forgot-password": "Lost Password?", + "reset-password": "Password Reset", + "no-account": "No account?", + "register": "Sign up for a", + "username-placeholder": "Please enter username", + "check-password": "Enter Password again", + "check-password-placeholder": "Please enter the password again", + "email": "Email", + "email-placeholder": "Enter email", + "have-account": "Already have an account? ", + "login": "Login Now", + "next-step": "Next", + "verify": "Verification", + "code": "CAPTCHA", + "code-placeholder": "Please enter OTP code", + "send-code": "Post", + "incorrect-info": "Wrong information?", + "fall-back": "Go back one step", + "length-range": "Expected {{min}} ~ {{max}} digits", + "same-rule": "* Fields do not match", + "invalid-email": "The email doesn't look right !" } -} +} \ No newline at end of file diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index ebfd8db..3c6fbe2 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -423,5 +423,34 @@ "title": "プリセット設定", "search": "プリセット名を検索", "context": "{{length}}のコンテキストが含まれています" + }, + "register": "登録", + "auth": { + "username": "ユーザー名", + "password": "パスワード", + "username-or-email": "ユーザー名またはメールアドレス", + "username-or-email-placeholder": "ユーザー名またはメールアドレスを入力してください", + "password-placeholder": "パスワードを入力してください", + "forgot-password": "パスワードを忘れた!", + "reset-password": "パスワードをリセット", + "no-account": "アカウントをお持ちではありませんか?", + "register": "にサインアップする", + "username-placeholder": "ここにあなたのユーザ名を入力", + "check-password": "パスワード確認", + "check-password-placeholder": "もう一度、パスワードを入力し直して下さい。", + "email": "メールアドレス", + "email-placeholder": "メールアドレスを入力してください", + "have-account": "すでにアカウントをお持ちですか?", + "login": "ログインしました。", + "next-step": "次へ", + "verify": "検証", + "code": "認証コード", + "code-placeholder": "認証コードを入力してください", + "send-code": "送信する", + "incorrect-info": "情報が間違っていますか?", + "fall-back": "1つ前のステップに戻る", + "length-range": "{{min }}〜{{ max}}桁が必要です", + "same-rule": "一貫性のない入力", + "invalid-email": "メールの形式が正しくありません" } -} +} \ No newline at end of file diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index fec1cde..24a295c 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -423,5 +423,34 @@ "title": "Настройки маски", "search": "Поиск по имени маски", "context": "Содержит {{length}} контекст" + }, + "register": "Постановка на учет", + "auth": { + "username": "имя пользователя", + "password": "пароль", + "username-or-email": "Имя пользователя или адрес электронной почты", + "username-or-email-placeholder": "Введите имя пользователя или адрес электронной почты", + "password-placeholder": "Пожалуйста, введите пароль", + "forgot-password": "Забыли пароль?", + "reset-password": "Восстановить пароль", + "no-account": "У вас нет аккаунта?", + "register": "Зарегистрируйтесь на", + "username-placeholder": "Введите здесь имя пользователя", + "check-password": "Подтверждение пароля", + "check-password-placeholder": "Введите, пожалуйста, пароль снова", + "email": "Эл. почта", + "email-placeholder": "Введите адрес электронной почты", + "have-account": "Уже есть аккаунт?", + "login": "Войти", + "next-step": "Cледующий шаг", + "verify": "Сертификация", + "code": "Код подтверждения", + "code-placeholder": "Введите проверочный код", + "send-code": "Посл", + "incorrect-info": "Неверная информация?", + "fall-back": "Вернитесь на шаг назад", + "length-range": "Ожидаемые цифры: {{min}} ~ {{max}}", + "same-rule": "Несогласованные входные данные", + "invalid-email": "Неверный формат электронной почты" } -} +} \ No newline at end of file diff --git a/app/src/router.tsx b/app/src/router.tsx index f95d8ce..e695b43 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -3,6 +3,9 @@ import Home from "./routes/Home.tsx"; import NotFound from "./routes/NotFound.tsx"; import Auth from "./routes/Auth.tsx"; import { lazy, Suspense } from "react"; +import { useDeeptrain } from "@/utils/env.ts"; +import Register from "@/routes/Register.tsx"; +import Forgot from "@/routes/Forgot.tsx"; const Generation = lazy(() => import("@/routes/Generation.tsx")); const Sharing = lazy(() => import("@/routes/Sharing.tsx")); @@ -16,116 +19,132 @@ const Charge = lazy(() => import("@/routes/admin/Charge.tsx")); const Users = lazy(() => import("@/routes/admin/Users.tsx")); const Broadcast = lazy(() => import("@/routes/admin/Broadcast.tsx")); -const router = createBrowserRouter([ - { - id: "home", - path: "/", - Component: Home, - ErrorBoundary: NotFound, - }, - { - id: "login", - path: "/login", - Component: Auth, - ErrorBoundary: NotFound, - }, - { - id: "generation", - path: "/generate", - element: ( - - - - ), - ErrorBoundary: NotFound, - }, - { - id: "share", - path: "/share/:hash", - element: ( - - - - ), - ErrorBoundary: NotFound, - }, - { - id: "article", - path: "/article", - element: ( - -
- - ), - ErrorBoundary: NotFound, - }, - { - id: "admin", - path: "/admin", - element: ( - - - - ), - children: [ - { - id: "admin-dashboard", - path: "", - element: ( - - - - ), - }, - { - id: "admin-users", - path: "users", - element: ( - - - - ), - }, - { - id: "admin-channel", - path: "channel", - element: ( - - - - ), - }, - { - id: "admin-system", - path: "system", - element: ( - - - - ), - }, - { - id: "admin-charge", - path: "charge", - element: ( - - - - ), - }, - { - id: "admin-broadcast", - path: "broadcast", - element: ( - - - - ), - }, - ], - ErrorBoundary: NotFound, - }, -]); +const router = createBrowserRouter( + [ + { + id: "home", + path: "/", + Component: Home, + ErrorBoundary: NotFound, + }, + { + id: "login", + path: "/login", + Component: Auth, + ErrorBoundary: NotFound, + }, + !useDeeptrain && + ({ + id: "register", + path: "/register", + Component: Register, + ErrorBoundary: NotFound, + } as any), + !useDeeptrain && + ({ + id: "forgot", + path: "/forgot", + Component: Forgot, + ErrorBoundary: NotFound, + } as any), + { + id: "generation", + path: "/generate", + element: ( + + + + ), + ErrorBoundary: NotFound, + }, + { + id: "share", + path: "/share/:hash", + element: ( + + + + ), + ErrorBoundary: NotFound, + }, + { + id: "article", + path: "/article", + element: ( + +
+ + ), + ErrorBoundary: NotFound, + }, + { + id: "admin", + path: "/admin", + element: ( + + + + ), + children: [ + { + id: "admin-dashboard", + path: "", + element: ( + + + + ), + }, + { + id: "admin-users", + path: "users", + element: ( + + + + ), + }, + { + id: "admin-channel", + path: "channel", + element: ( + + + + ), + }, + { + id: "admin-system", + path: "system", + element: ( + + + + ), + }, + { + id: "admin-charge", + path: "charge", + element: ( + + + + ), + }, + { + id: "admin-broadcast", + path: "broadcast", + element: ( + + + + ), + }, + ], + ErrorBoundary: NotFound, + }, + ].filter(Boolean), +); export function AppRouter() { return ; diff --git a/app/src/routes/Auth.tsx b/app/src/routes/Auth.tsx index 9e5db5a..d4e76f9 100644 --- a/app/src/routes/Auth.tsx +++ b/app/src/routes/Auth.tsx @@ -1,18 +1,27 @@ import { useToast } from "@/components/ui/use-toast.ts"; import { ToastAction } from "@/components/ui/toast.tsx"; -import { login, tokenField } from "@/conf.ts"; -import { useEffect } from "react"; +import { tokenField } from "@/conf.ts"; +import { useEffect, useReducer } from "react"; import Loader from "@/components/Loader.tsx"; import "@/assets/pages/auth.less"; -import axios from "axios"; import { validateToken } from "@/store/auth.ts"; import { useDispatch } from "react-redux"; import router from "@/router.tsx"; import { useTranslation } from "react-i18next"; import { getQueryParam } from "@/utils/path.ts"; import { setMemory } from "@/utils/memory.ts"; +import { useDeeptrain } from "@/utils/env.ts"; +import { Card, CardContent } from "@/components/ui/card.tsx"; +import { goAuth } from "@/utils/app.ts"; +import { Label } from "@/components/ui/label.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import Require from "@/components/Require.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { formReducer } from "@/utils/form.ts"; +import { doLogin, LoginForm } from "@/api/auth.ts"; +import { getErrorMessage } from "@/utils/base.ts"; -function Auth() { +function DeepAuth() { const { toast } = useToast(); const { t } = useTranslation(); const dispatch = useDispatch(); @@ -24,39 +33,38 @@ function Auth() { title: t("invalid-token"), description: t("invalid-token-prompt"), action: ( - + {t("try-again")} ), }); - setTimeout(login, 2500); + setTimeout(goAuth, 2500); return; } setMemory(tokenField, token); - axios - .post("/login", { token }) - .then((res) => { - const data = res.data; + + doLogin({ token }) + .then((data) => { if (!data.status) { toast({ title: t("login-failed"), description: t("login-failed-prompt", { reason: data.error }), action: ( - + {t("try-again")} ), }); } else - validateToken(dispatch, data.token, () => { + validateToken(dispatch, data.token, async () => { toast({ title: t("login-success"), description: t("login-success-prompt"), }); - router.navigate("/"); + await router.navigate("/"); }); }) .catch((err) => { @@ -65,7 +73,7 @@ function Auth() { title: t("server-error"), description: `${t("server-error-prompt")}\n${err.message}`, action: ( - + {t("try-again")} ), @@ -80,4 +88,99 @@ function Auth() { ); } +function Login() { + const { t } = useTranslation(); + const { toast } = useToast(); + const globalDispatch = useDispatch(); + const [form, dispatch] = useReducer(formReducer(), { + username: sessionStorage.getItem("username") || "", + password: sessionStorage.getItem("password") || "", + }); + + const onSubmit = async () => { + if (!form.username.trim().length || !form.password.trim().length) return; + + try { + const resp = await doLogin(form); + if (!resp.status) { + toast({ + title: t("login-failed"), + description: t("login-failed-prompt", { reason: resp.error }), + }); + return; + } + + toast({ + title: t("login-success"), + description: t("login-success-prompt"), + }); + + validateToken(globalDispatch, resp.token); + await router.navigate("/"); + } catch (err) { + console.debug(err); + toast({ + title: t("server-error"), + description: `${t("server-error-prompt")}\n${getErrorMessage(err)}`, + }); + } + }; + + return ( +
+ +
{t("login")} Chat Nio
+ + +
+ + + dispatch({ type: "update:username", payload: e.target.value }) + } + /> + + + + dispatch({ type: "update:password", payload: e.target.value }) + } + /> + + +
+
+
+ +
+ ); +} + +function Auth() { + return useDeeptrain ? : ; +} + export default Auth; diff --git a/app/src/routes/Forgot.tsx b/app/src/routes/Forgot.tsx new file mode 100644 index 0000000..5e4b248 --- /dev/null +++ b/app/src/routes/Forgot.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/ui/use-toast.ts"; +import { useDispatch } from "react-redux"; +import { useReducer } from "react"; +import { formReducer } from "@/utils/form.ts"; +import { doLogin, LoginForm, ResetForm } from "@/api/auth.ts"; +import { validateToken } from "@/store/auth.ts"; +import router from "@/router.tsx"; +import { getErrorMessage } from "@/utils/base.ts"; +import { Card, CardContent } from "@/components/ui/card.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import Require from "@/components/Require.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Button } from "@/components/ui/button.tsx"; + +function Forgot() { + const { t } = useTranslation(); + const { toast } = useToast(); + const globalDispatch = useDispatch(); + const [form, dispatch] = useReducer(formReducer(), { + email: "", + code: "", + password: "", + repassword: "", + }); + + const onSubmit = async () => { + if (!form.username.trim().length || !form.password.trim().length) return; + + try { + const resp = await doLogin(form); + if (!resp.status) { + toast({ + title: t("login-failed"), + description: t("login-failed-prompt", { reason: resp.error }), + }); + return; + } + + toast({ + title: t("login-success"), + description: t("login-success-prompt"), + }); + + validateToken(globalDispatch, resp.token); + await router.navigate("/"); + } catch (err) { + console.debug(err); + toast({ + title: t("server-error"), + description: `${t("server-error-prompt")}\n${getErrorMessage(err)}`, + }); + } + }; + + return ( +
+ +
{t("auth.reset-password")}
+ + +
+ + + dispatch({ type: "update:username", payload: e.target.value }) + } + /> + + + + dispatch({ type: "update:password", payload: e.target.value }) + } + /> + + +
+
+
+ +
+ ); +} + +export default Forgot; diff --git a/app/src/routes/Register.tsx b/app/src/routes/Register.tsx new file mode 100644 index 0000000..e3907a7 --- /dev/null +++ b/app/src/routes/Register.tsx @@ -0,0 +1,268 @@ +import { Card, CardContent } from "@/components/ui/card.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import Require, { + EmailRequire, + LengthRangeRequired, + SameRequired, +} from "@/components/Require.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import router from "@/router.tsx"; +import { useTranslation } from "react-i18next"; +import { formReducer, isEmailValid, isTextInRange } from "@/utils/form.ts"; +import React, { useReducer, useState } from "react"; +import { doRegister, doVerify, RegisterForm } from "@/api/auth.ts"; +import { useToast } from "@/components/ui/use-toast.ts"; +import TickButton from "@/components/TickButton.tsx"; + +type CompProps = { + form: RegisterForm; + dispatch: React.Dispatch; + next: boolean; + setNext: (next: boolean) => void; +}; + +function Preflight({ form, dispatch, setNext }: CompProps) { + const { t } = useTranslation(); + + const onSubmit = () => { + if ( + !isTextInRange(form.username, 2, 24) || + !isTextInRange(form.password, 6, 36) || + form.password.trim() !== form.repassword.trim() + ) + return; + + setNext(true); + }; + + return ( +
+ + + dispatch({ + type: "update:username", + payload: e.target.value, + }) + } + /> + + + + dispatch({ + type: "update:password", + payload: e.target.value, + }) + } + /> + + + + dispatch({ + type: "update:repassword", + payload: e.target.value, + }) + } + /> + + +
+ ); +} + +function doFormat(form: RegisterForm): RegisterForm { + return { + ...form, + username: form.username.trim(), + password: form.password.trim(), + repassword: form.repassword.trim(), + email: form.email.trim(), + code: form.code.trim(), + }; +} + +function Verify({ form, dispatch, setNext }: CompProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const onSubmit = async () => { + const data = doFormat(form); + + if (!isEmailValid(data.email) || !data.code.trim().length) return; + + const res = await doRegister(data); + if (!res.status) { + toast({ + title: t("error"), + description: res.error, + }); + return; + } + }; + + const onVerify = async () => { + if (form.email.trim().length === 0 || !isEmailValid(form.email)) + return false; + + const res = await doVerify(form.email); + if (!res.status) { + toast({ + title: t("error"), + description: res.error, + }); + return false; + } + + return true; + }; + + return ( +
+ + + dispatch({ + type: "update:email", + payload: e.target.value, + }) + } + /> + + + +
+ + dispatch({ + type: "update:code", + payload: e.target.value, + }) + } + /> + + {t("auth.send-code")} + +
+ + + +
+ {t("auth.incorrect-info")} + setNext(false)} + > + {t("auth.fall-back")} + +
+
+ ); +} + +function Register() { + const { t } = useTranslation(); + const [next, setNext] = useState(false); + const [form, dispatch] = useReducer(formReducer(), { + username: "", + password: "", + repassword: "", + email: "", + code: "", + }); + + return ( +
+ +
{t("register")} Chat Nio
+ + + {!next ? ( + + ) : ( + + )} + + + +
+ ); +} + +export default Register; diff --git a/app/src/store/auth.ts b/app/src/store/auth.ts index 4639316..5bb85fd 100644 --- a/app/src/store/auth.ts +++ b/app/src/store/auth.ts @@ -3,6 +3,7 @@ import axios from "axios"; import { tokenField } from "@/conf.ts"; import { AppDispatch, RootState } from "./index.ts"; import { forgetMemory, setMemory } from "@/utils/memory.ts"; +import { doState } from "@/api/auth.ts"; export const authSlice = createSlice({ name: "auth", @@ -32,6 +33,12 @@ export const authSlice = createSlice({ setAdmin: (state, action) => { state.admin = action.payload as boolean; }, + updateData: (state, action) => { + state.init = true; + state.authenticated = action.payload.authenticated as boolean; + state.username = action.payload.username as string; + state.admin = action.payload.admin as boolean; + }, logout: (state) => { state.token = ""; state.authenticated = false; @@ -53,18 +60,26 @@ export function validateToken( dispatch(setToken(token)); if (token.length === 0) { - dispatch(setAuthenticated(false)); - dispatch(setUsername("")); - dispatch(setInit(true)); + dispatch( + updateData({ + authenticated: false, + username: "", + admin: false, + }), + ); + return; } else - axios - .post("/state") - .then((res) => { - dispatch(setAuthenticated(res.data.status)); - dispatch(setUsername(res.data.user)); - dispatch(setInit(true)); - dispatch(setAdmin(res.data.admin)); + doState() + .then((data) => { + dispatch( + updateData({ + authenticated: data.status, + username: data.user, + admin: data.admin, + }), + ); + hook && hook(); }) .catch((err) => { @@ -86,5 +101,6 @@ export const { logout, setInit, setAdmin, + updateData, } = authSlice.actions; export default authSlice.reducer; diff --git a/app/src/utils/app.ts b/app/src/utils/app.ts index 33ec607..3a984d8 100644 --- a/app/src/utils/app.ts +++ b/app/src/utils/app.ts @@ -1,3 +1,7 @@ +import router from "@/router.tsx"; +import { useDeeptrain } from "@/utils/env.ts"; +import { login } from "@/conf.ts"; + export let event: BeforeInstallPromptEvent | undefined; window.addEventListener("beforeinstallprompt", (e: Event) => { @@ -40,3 +44,14 @@ export function getMemoryPerformance(): number { if (!performance || !performance.memory) return NaN; return performance.memory.usedJSHeapSize / 1024 / 1024; } + +export function navigate(path: string): void { + router + .navigate(path) + .then(() => console.debug(`[service] navigate to ${path}`)) + .catch((err) => console.debug(`[service] navigate error`, err)); +} + +export function goAuth(): void { + useDeeptrain ? login() : navigate("/login"); +} diff --git a/app/src/utils/base.ts b/app/src/utils/base.ts index fc7fe190..a4bd036 100644 --- a/app/src/utils/base.ts +++ b/app/src/utils/base.ts @@ -48,3 +48,7 @@ export function getErrorMessage(error: any): string { if (typeof error === "string") return error; return JSON.stringify(error); } + +export function isAsyncFunc(fn: any): boolean { + return fn.constructor.name === "AsyncFunction"; +} diff --git a/app/src/utils/form.ts b/app/src/utils/form.ts index 1385609..4317f70 100644 --- a/app/src/utils/form.ts +++ b/app/src/utils/form.ts @@ -30,3 +30,15 @@ export const formReducer = () => { } }; }; + +export function isEmailValid(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 255; +} + +export function isInRange(value: number, min: number, max: number) { + return value >= min && value <= max; +} + +export function isTextInRange(value: string, min: number, max: number) { + return value.trim().length >= min && value.trim().length <= max; +} diff --git a/connection/database.go b/connection/database.go index 863377c..07aca57 100644 --- a/connection/database.go +++ b/connection/database.go @@ -57,8 +57,10 @@ func CreateUserTable(db *sql.DB) { bind_id INT UNIQUE, username VARCHAR(24) UNIQUE, token VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE, password VARCHAR(64) NOT NULL, - is_admin BOOLEAN DEFAULT FALSE + is_admin BOOLEAN DEFAULT FALSE, + is_banned BOOLEAN DEFAULT FALSE ); `) if err != nil {