短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进, 没设过的照旧只用验证码,不强制。 - app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现, n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib - POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在; 15 分钟内连错 10 次 → 2016,期间正确密码也挡住 - POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015 - PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去 - 登录页顶部「验证码 / 密码」两段切换 tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
|
import { useAuth } from "../App";
|
|
import { Book, Check, Logo, TicketIcon } from "../components/icons";
|
|
import { Button, Field, InlineAlert } from "../components/ui";
|
|
import { ApiError, api, session, type Tokens } from "../lib/client";
|
|
import type { User } from "../lib/types";
|
|
|
|
/**
|
|
* 登录后回哪儿:`?next=` 给邀请链接用。
|
|
* 只接受站内路径(以单个 `/` 开头),避免被拼成 `//evil.com` 的外跳。
|
|
*/
|
|
function safeNext(raw: string | null): string | null {
|
|
if (!raw) return null;
|
|
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
|
return raw;
|
|
}
|
|
|
|
const HIGHLIGHTS = [
|
|
{ icon: <Check size={15} />, title: "一件事,从头到尾", desc: "收下、开工、完成,状态一路可见" },
|
|
{
|
|
icon: <TicketIcon size={15} />,
|
|
title: "卡住就变成工单",
|
|
desc: "原任务自动挂着等,办完再回来,不丢线",
|
|
},
|
|
{
|
|
icon: <Book size={15} />,
|
|
title: "办完自动留档",
|
|
desc: "验收通过生成知识草稿,核对后进团队库",
|
|
},
|
|
];
|
|
|
|
export function LoginPage() {
|
|
const { reload } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [params] = useSearchParams();
|
|
const next = safeNext(params.get("next"));
|
|
const [phone, setPhone] = useState("");
|
|
const [code, setCode] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [mode, setMode] = useState<"code" | "password">("code");
|
|
const [devCode, setDevCode] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [countdown, setCountdown] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (countdown <= 0) return;
|
|
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [countdown]);
|
|
|
|
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
|
|
|
|
async function sendCode() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone });
|
|
setDevCode(data.dev_code ?? null);
|
|
setCountdown(60);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "发送失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const data =
|
|
mode === "code"
|
|
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
|
|
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
|
|
phone,
|
|
password,
|
|
});
|
|
session.save(data.tokens);
|
|
await reload();
|
|
navigate(next ?? "/", { replace: true });
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "登录失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-full flex-col lg:flex-row">
|
|
{/* 左:品牌页 */}
|
|
<div className="relative hidden flex-1 overflow-hidden bg-primary-900 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
|
<div className="grid-lines absolute inset-0 opacity-40" aria-hidden />
|
|
<div className="absolute -left-24 -top-24 h-80 w-80 rounded-full bg-primary-700/60 blur-3xl" aria-hidden />
|
|
<div className="absolute -bottom-32 right-0 h-96 w-96 rounded-full bg-clay-500/20 blur-3xl" aria-hidden />
|
|
|
|
<div className="relative flex items-center gap-2.5 text-paper-50">
|
|
<Logo size={32} />
|
|
<span className="text-lg font-semibold tracking-tight">达道Task</span>
|
|
</div>
|
|
|
|
<div className="relative max-w-md">
|
|
<h1 className="font-display text-4xl font-semibold leading-[1.15] text-paper-50">
|
|
从任务到知识,
|
|
<br />
|
|
一条道走到底。
|
|
</h1>
|
|
<p className="mt-4 text-base leading-relaxed text-paper-200/80">
|
|
你自己的待办、需要别人搭把手的时刻、以及干完活留下的经验——本来就是一回事。
|
|
</p>
|
|
|
|
<ul className="mt-10 space-y-5">
|
|
{HIGHLIGHTS.map((h) => (
|
|
<li key={h.title} className="flex gap-3">
|
|
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-paper-50/20 bg-paper-50/10 text-paper-100">
|
|
{h.icon}
|
|
</span>
|
|
<span>
|
|
<span className="block text-sm font-medium text-paper-50">{h.title}</span>
|
|
<span className="block text-sm text-paper-200/65">{h.desc}</span>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<p className="relative text-2xs text-paper-200/45">
|
|
个人先用着,团队按席位。数据在你自己的工作区里。
|
|
</p>
|
|
</div>
|
|
|
|
{/* 右:表单 */}
|
|
<main className="flex flex-1 items-center justify-center bg-paper-100 px-5 py-12">
|
|
<div className="w-full max-w-sm">
|
|
<div className="mb-8 flex items-center gap-2.5 lg:hidden">
|
|
<Logo size={30} />
|
|
<div className="leading-tight">
|
|
<div className="text-sm font-semibold text-ink-900">达道Task</div>
|
|
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 className="text-2xl font-semibold">登录</h2>
|
|
<p className="mt-1.5 text-sm text-ink-500">
|
|
{mode === "code"
|
|
? "手机号验证码登录,第一次登录会自动创建账号。"
|
|
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
|
|
</p>
|
|
|
|
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
|
|
{(
|
|
[
|
|
["code", "验证码"],
|
|
["password", "密码"],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => {
|
|
setMode(key);
|
|
setError(null);
|
|
}}
|
|
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
|
|
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<form className="mt-5 space-y-4" onSubmit={submit}>
|
|
<Field label="手机号">
|
|
<input
|
|
id="phone"
|
|
className="input h-11"
|
|
inputMode="numeric"
|
|
autoComplete="tel"
|
|
placeholder="13800138000"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value.trim())}
|
|
maxLength={11}
|
|
/>
|
|
</Field>
|
|
|
|
{mode === "code" ? (
|
|
<Field label="验证码">
|
|
<div className="flex gap-2">
|
|
<input
|
|
id="code"
|
|
className="input h-11 tracking-[0.3em]"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
placeholder="6 位数字"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.trim())}
|
|
maxLength={6}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="h-11 shrink-0 px-3.5"
|
|
disabled={!phoneValid || busy || countdown > 0}
|
|
onClick={sendCode}
|
|
>
|
|
{countdown > 0 ? `${countdown}s` : "获取验证码"}
|
|
</Button>
|
|
</div>
|
|
</Field>
|
|
) : (
|
|
<Field label="密码">
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
className="input h-11"
|
|
autoComplete="current-password"
|
|
placeholder="你的登录密码"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
maxLength={64}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{devCode && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
|
|
<span className="mt-px text-clay-500">开发环境验证码:{devCode}</span>
|
|
<span className="text-clay-600/70">(生产环境走短信通道,不回显)</span>
|
|
</div>
|
|
)}
|
|
{error && <InlineAlert>{error}</InlineAlert>}
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="lg"
|
|
className="mt-2 w-full"
|
|
disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
|
|
>
|
|
{busy ? "处理中…" : "登录"}
|
|
</Button>
|
|
</form>
|
|
|
|
<p className="mt-8 text-2xs leading-relaxed text-ink-400">
|
|
登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。
|
|
</p>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|