d0e6912017
Update project name from teaboy-os to tx-os and remove obsolete services. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3154f23a-748a-4118-aa41-fc01b7b1f04d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/PVuelRZ Replit-Helium-Checkpoint-Created: true
243 lines
9.5 KiB
TypeScript
243 lines
9.5 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "wouter";
|
|
import {
|
|
useResetPassword,
|
|
useVerifyResetToken,
|
|
} from "@workspace/api-client-react";
|
|
import { useAppName, useAppSettings } from "@/hooks/use-app-settings";
|
|
import i18n from "@/i18n";
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
Loader2,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
} from "lucide-react";
|
|
import { AnimatedArt } from "@/components/animated-art";
|
|
|
|
export default function ResetPasswordPage() {
|
|
const { t, i18n: i18nInstance } = useTranslation();
|
|
const lang = i18nInstance.language;
|
|
const dir = lang === "ar" ? "rtl" : "ltr";
|
|
const appName = useAppName();
|
|
const { data: settings } = useAppSettings();
|
|
const [, setLocation] = useLocation();
|
|
|
|
const token = useMemo(() => {
|
|
if (typeof window === "undefined") return "";
|
|
return new URLSearchParams(window.location.search).get("token") ?? "";
|
|
}, []);
|
|
|
|
const [password, setPassword] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [done, setDone] = useState(false);
|
|
const [tokenValid, setTokenValid] = useState<boolean | null>(null);
|
|
|
|
const verify = useVerifyResetToken();
|
|
const reset = useResetPassword();
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setTokenValid(false);
|
|
return;
|
|
}
|
|
verify.mutate(
|
|
{ data: { token } },
|
|
{
|
|
onSuccess: (resp) => setTokenValid(resp.valid),
|
|
onError: () => setTokenValid(false),
|
|
},
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [token]);
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (password.length < 6) {
|
|
setError(t("auth.reset.errMinLength"));
|
|
return;
|
|
}
|
|
if (password !== confirm) {
|
|
setError(t("auth.reset.errMismatch"));
|
|
return;
|
|
}
|
|
setError(null);
|
|
reset.mutate(
|
|
{ data: { token, newPassword: password } },
|
|
{
|
|
onSuccess: () => setDone(true),
|
|
onError: () => setError(t("auth.reset.errGeneric")),
|
|
},
|
|
);
|
|
};
|
|
|
|
const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar");
|
|
|
|
const footerText =
|
|
lang === "ar" ? settings?.footerTextAr : settings?.footerTextEn;
|
|
|
|
return (
|
|
<div className="min-h-screen flex w-full text-foreground relative" dir={dir}>
|
|
<button
|
|
type="button"
|
|
onClick={toggleLanguage}
|
|
className={`absolute top-4 z-50 text-sm font-medium hover:bg-foreground/5 px-3 py-1.5 rounded-md transition-colors text-foreground bg-background/70 backdrop-blur-sm border border-border/50 ${
|
|
dir === "rtl" ? "left-4 md:left-8" : "right-4 md:right-8"
|
|
}`}
|
|
>
|
|
{lang === "ar" ? "EN" : "عربي"}
|
|
</button>
|
|
|
|
<div className="hidden lg:block lg:w-1/2 relative overflow-hidden shrink-0">
|
|
<AnimatedArt />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col items-center justify-center p-6 sm:p-8 relative os-bg">
|
|
<div className="w-full max-w-[400px]">
|
|
<div className="glass-panel rounded-2xl p-6 sm:p-8">
|
|
<h1 className="text-xl font-bold mb-1">{t("auth.reset.title")}</h1>
|
|
<p className="text-sm text-muted-foreground mb-5">
|
|
{t("auth.reset.subtitle")}
|
|
</p>
|
|
|
|
{tokenValid === null ? (
|
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
</div>
|
|
) : tokenValid === false ? (
|
|
<div className="space-y-4">
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
>
|
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
|
<span>{t("auth.reset.invalidToken")}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocation("/forgot-password")}
|
|
className="w-full inline-flex items-center justify-center gap-2 h-10 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:opacity-90 transition-opacity"
|
|
>
|
|
{t("auth.reset.requestNew")}
|
|
</button>
|
|
</div>
|
|
) : done ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-700 dark:text-emerald-300">
|
|
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" />
|
|
<span>{t("auth.reset.successMessage")}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocation("/login")}
|
|
className="w-full inline-flex items-center justify-center gap-2 h-10 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:opacity-90 transition-opacity"
|
|
>
|
|
{t("auth.reset.goToLogin")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="newPassword"
|
|
className="block font-semibold text-sm text-foreground"
|
|
>
|
|
{t("auth.reset.newPassword")}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="newPassword"
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="new-password"
|
|
value={password}
|
|
onChange={(e) => {
|
|
setPassword(e.target.value);
|
|
setError(null);
|
|
}}
|
|
disabled={reset.isPending}
|
|
className={`block w-full h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-60 ${
|
|
dir === "rtl" ? "pl-10" : "pr-10"
|
|
}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
className={`absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors ${
|
|
dir === "rtl" ? "left-3" : "right-3"
|
|
}`}
|
|
aria-label={
|
|
showPassword
|
|
? t("auth.hidePassword")
|
|
: t("auth.showPassword")
|
|
}
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="confirmPassword"
|
|
className="block font-semibold text-sm text-foreground"
|
|
>
|
|
{t("auth.reset.confirmPassword")}
|
|
</label>
|
|
<input
|
|
id="confirmPassword"
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="new-password"
|
|
value={confirm}
|
|
onChange={(e) => {
|
|
setConfirm(e.target.value);
|
|
setError(null);
|
|
}}
|
|
disabled={reset.isPending}
|
|
className="block w-full h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-60"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
>
|
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={reset.isPending}
|
|
className="w-full inline-flex items-center justify-center gap-2 h-10 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-60"
|
|
>
|
|
{reset.isPending && (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
)}
|
|
{reset.isPending
|
|
? t("auth.reset.saving")
|
|
: t("auth.reset.submit")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{footerText && (
|
|
<div className="absolute bottom-4 inset-x-0 text-center text-xs text-muted-foreground px-4">
|
|
© {new Date().getFullYear()} {appName}. {footerText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|