Files
20260512-skg-tk/web/app/login/page.tsx
kang 6201ee9a7d fix(web): tolerant polling, objectURL cleanup, throttled pointermove
- home/detail video pollers no longer clearInterval on a single transient error
  (give up only after 10 consecutive failures), matching the agent page
- agent page creates preview objectURLs inside useEffect so each has a matching
  revoke under strict-mode double-invocation
- login pointermove throttled via rAF and skipped on coarse pointers
- source-analysis.html: changelog entry for this hardening pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00

262 lines
11 KiB
TypeScript

"use client"
import type { FormEvent } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
ArrowRight,
Building2,
CheckCircle2,
Eye,
EyeOff,
LockKeyhole,
UserRound,
} from "lucide-react"
import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/login/animated-login-characters"
import { OasisCanvas } from "@/components/login/oasis-canvas"
type LoginStatus = "idle" | "loading" | "success"
type AuthConfig = {
auth_configured?: boolean
password_enabled?: boolean
feishu_enabled?: boolean
}
function normalizeNextPath(value: string | null | undefined) {
const next = (value || "/").trim() || "/"
if (!next.startsWith("/") || next.startsWith("//")) return "/"
return next
}
function loginNextPath() {
if (typeof window === "undefined") return "/"
return normalizeNextPath(new URLSearchParams(window.location.search).get("next"))
}
export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [nextPath] = useState(loginNextPath)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true)
const [showPassword, setShowPassword] = useState(false)
const [activeField, setActiveField] = useState<"username" | "password" | null>(null)
const [hasError, setHasError] = useState(false)
const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
const autoFeishuAttemptedRef = useRef(false)
useEffect(() => {
let cancelled = false
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data) setAuthConfig(data)
})
.catch(() => {
if (!cancelled) setAuthConfig(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
// skip touch / coarse pointers — the eye-follow effect is pointless there and
// would thrash state (and battery) on scroll-driven pointer events
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
let frame = 0
const onPointerMove = (event: PointerEvent) => {
if (frame) return // coalesce to at most one state update per animation frame
frame = window.requestAnimationFrame(() => {
frame = 0
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
})
}
window.addEventListener("pointermove", onPointerMove)
return () => {
window.removeEventListener("pointermove", onPointerMove)
if (frame) window.cancelAnimationFrame(frame)
}
}, [])
const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
useEffect(() => {
if (!feishuEnabled || status !== "idle" || autoFeishuAttemptedRef.current) return
const attemptKey = `skg-feishu-auto-login:${nextPath}`
if (window.sessionStorage.getItem(attemptKey) === "1") return
window.sessionStorage.setItem(attemptKey, "1")
autoFeishuAttemptedRef.current = true
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}, [feishuEnabled, nextPath, status])
const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success"
if (hasError) return "error"
if (showPassword && activeField === "password") return "peek"
if (activeField || username || password) return "typing"
return "idle"
}, [activeField, hasError, password, showPassword, status, username])
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) {
setHasError(true)
return
}
setStatus("loading")
try {
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, remember }),
})
if (!res.ok) {
throw new Error()
}
setStatus("success")
window.setTimeout(() => {
window.location.href = nextPath
}, 420)
} catch {
setStatus("idle")
setHasError(true)
}
}
function onFeishuLogin() {
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}
return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas />
<div className="login-oasis-shade" />
<div className="login-source-overlay">
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
<div className="login-top-brand" aria-hidden="true">
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
</div>
<div className="login-source-character-strip" aria-hidden="true">
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
{feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<UserRound className="h-4 w-4 text-white/45" />
<input
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
value={username}
disabled={disabled}
autoComplete="username"
onFocus={() => setActiveField("username")}
onBlur={() => setActiveField(null)}
onChange={(event) => {
setUsername(event.target.value)
if (hasError) setHasError(false)
}}
/>
</span>
</label>
<label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<LockKeyhole className="h-4 w-4 text-white/45" />
<input
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
value={password}
disabled={disabled}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
onFocus={() => setActiveField("password")}
onBlur={() => setActiveField(null)}
onChange={(event) => {
setPassword(event.target.value)
if (hasError) setHasError(false)
}}
/>
<button
className="grid h-9 w-9 place-items-center rounded-[8px] text-white/55 transition hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/45 disabled:opacity-50"
type="button"
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</span>
</label>
</div>
) : null}
{passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox"
checked={remember}
disabled={disabled}
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
) : null}
{status === "success" ? (
<div className="mt-3">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-[8px] border border-emerald-300/30 bg-emerald-400/10 text-emerald-100">
<CheckCircle2 className="h-4 w-4" />
</div>
</div>
) : null}
{passwordEnabled ? (
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit"
disabled={disabled}
>
<ArrowRight className="h-4 w-4" />
</button>
) : null}
</form>
</section>
</div>
</main>
)
}