Update login page with a new two-pane design and improved accessibility
Redesign the login page with a two-pane layout, add new i18n keys, and fix a11y issues with the password visibility toggle. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: dec93827-1135-4c56-a2a9-80eab0f95359 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/dffA2SQ Replit-Helium-Checkpoint-Created: true
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 34 KiB |
@@ -19,7 +19,20 @@
|
||||
"createAccount": "حساب جديد",
|
||||
"registrationClosed": "إنشاء الحسابات العامة مغلق حالياً. تواصل مع مدير النظام.",
|
||||
"displayNameAr": "الاسم (عربي)",
|
||||
"displayNameEn": "الاسم (إنجليزي)"
|
||||
"displayNameEn": "الاسم (إنجليزي)",
|
||||
"subtitle": "سجّل دخولك للمتابعة",
|
||||
"signingIn": "جارٍ الدخول...",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"showPassword": "إظهار كلمة المرور",
|
||||
"hidePassword": "إخفاء كلمة المرور",
|
||||
"errRequired": "الرجاء إدخال اسم المستخدم وكلمة المرور.",
|
||||
"errInvalid": "اسم مستخدم أو كلمة مرور غير صحيحة.",
|
||||
"heroTitle": "مرحباً بك في منصتك الموحدة.",
|
||||
"heroSubtitle": "سجّل دخولك لإدارة كل شيء من مكان واحد.",
|
||||
"feat1": "كل أدواتك في لوحة تحكم واحدة",
|
||||
"feat2": "تحديثات لحظية",
|
||||
"feat3": "وصول آمن بالجلسات",
|
||||
"rights": "جميع الحقوق محفوظة."
|
||||
},
|
||||
"home": {
|
||||
"myApps": "تطبيقاتي"
|
||||
|
||||
@@ -19,7 +19,20 @@
|
||||
"createAccount": "Create Account",
|
||||
"registrationClosed": "Public registration is currently closed. Please contact the administrator.",
|
||||
"displayNameAr": "Name (Arabic)",
|
||||
"displayNameEn": "Name (English)"
|
||||
"displayNameEn": "Name (English)",
|
||||
"subtitle": "Sign in to your account to continue",
|
||||
"signingIn": "Signing in...",
|
||||
"noAccount": "Don't have an account?",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"errRequired": "Please enter your username and password.",
|
||||
"errInvalid": "Invalid username or password.",
|
||||
"heroTitle": "Welcome to your unified workspace.",
|
||||
"heroSubtitle": "Sign in to manage everything from one place.",
|
||||
"feat1": "All your tools in one dashboard",
|
||||
"feat2": "Real-time updates",
|
||||
"feat3": "Secure session-based access",
|
||||
"rights": "All rights reserved."
|
||||
},
|
||||
"home": {
|
||||
"myApps": "My Apps"
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLogin } from "@workspace/api-client-react";
|
||||
import { useLogin, getGetMeQueryKey } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetMeQueryKey } from "@workspace/api-client-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppName, useAppSettings } from "@/hooks/use-app-settings";
|
||||
import i18n from "@/i18n";
|
||||
import {
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
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 { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({ username: "", password: "" });
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const login = useLogin();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
setError(t("auth.errRequired"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
login.mutate(
|
||||
{ data: { username: form.username, password: form.password } },
|
||||
{ data: { username: username.trim(), password } },
|
||||
{
|
||||
onSuccess: (user) => {
|
||||
if (user.preferredLanguage) {
|
||||
@@ -35,76 +48,216 @@ export default function LoginPage() {
|
||||
setLocation("/");
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
description: t("auth.login"),
|
||||
variant: "destructive",
|
||||
});
|
||||
setError(t("auth.errInvalid"));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar");
|
||||
|
||||
const features = [
|
||||
t("auth.feat1"),
|
||||
t("auth.feat2"),
|
||||
t("auth.feat3"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-4xl font-bold text-primary mb-2">{appName}</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("auth.welcomeBack")}</h1>
|
||||
<div className="min-h-screen flex w-full text-foreground" dir={dir}>
|
||||
{/* Left brand panel — hidden on small screens */}
|
||||
<div className="hidden lg:flex w-1/2 os-bg flex-col justify-between p-12 relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at top right, hsl(var(--primary) / 0.18), transparent 55%), radial-gradient(circle at bottom left, hsl(var(--accent) / 0.16), transparent 55%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-flex items-center gap-3 mb-20">
|
||||
<div className="icon-tile w-12 h-12 icon-tile-blue">
|
||||
<Sparkles size={24} color="#FFFFFF" />
|
||||
</div>
|
||||
<span className="font-bold text-2xl tracking-tight text-foreground">
|
||||
{appName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("auth.username")}</Label>
|
||||
<Input
|
||||
<div className="max-w-md space-y-8">
|
||||
<h1 className="text-4xl font-black text-foreground leading-tight">
|
||||
{t("auth.heroTitle")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
{t("auth.heroSubtitle")}
|
||||
</p>
|
||||
<div className="space-y-4 pt-4">
|
||||
{features.map((feat) => (
|
||||
<div
|
||||
key={feat}
|
||||
className="flex items-center gap-3 text-foreground/90 font-medium"
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5 text-primary shrink-0" />
|
||||
<span>{feat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-sm text-muted-foreground font-medium">
|
||||
© {new Date().getFullYear()} {appName}. {t("auth.rights")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right form panel */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 sm:p-8 relative os-bg">
|
||||
{/* Language toggle */}
|
||||
<div
|
||||
className={`absolute top-4 ${
|
||||
dir === "rtl" ? "left-4 md:left-8" : "right-4 md:right-8"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLanguage}
|
||||
className="text-sm font-medium hover:bg-foreground/5 px-3 py-1.5 rounded-md transition-colors text-foreground"
|
||||
>
|
||||
{lang === "ar" ? "EN" : "عربي"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile brand */}
|
||||
<div className="lg:hidden mb-8 flex items-center gap-2">
|
||||
<div className="icon-tile w-10 h-10 icon-tile-blue">
|
||||
<Sparkles size={20} color="#FFFFFF" />
|
||||
</div>
|
||||
<span className="font-bold text-2xl tracking-tight">{appName}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div
|
||||
className={`mb-8 ${
|
||||
dir === "rtl"
|
||||
? "text-center lg:text-right"
|
||||
: "text-center lg:text-left"
|
||||
}`}
|
||||
>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t("auth.welcomeBack")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("auth.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-6 sm:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
{/* Username */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block font-semibold text-sm text-foreground"
|
||||
>
|
||||
{t("auth.username")}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
required
|
||||
className="bg-white/70 border-slate-200 text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
className="bg-white/70 border-slate-200 text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={login.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>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block font-semibold text-sm text-foreground"
|
||||
>
|
||||
{login.isPending ? t("common.loading") : t("auth.loginButton")}
|
||||
</Button>
|
||||
</form>
|
||||
{t("auth.password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={login.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>
|
||||
|
||||
{/* Error alert */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={login.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"
|
||||
>
|
||||
{login.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{login.isPending
|
||||
? t("auth.signingIn")
|
||||
: t("auth.loginButton")}
|
||||
</button>
|
||||
|
||||
{settings?.registrationOpen && (
|
||||
<div className="text-center">
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocation("/register")}
|
||||
className="text-sm text-primary hover:underline"
|
||||
className="text-primary font-semibold hover:underline"
|
||||
>
|
||||
{t("auth.register")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => i18n.changeLanguage(i18n.language === "ar" ? "en" : "ar")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("common.language")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user