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.
This commit is contained in:
Riyadh
2026-04-20 12:21:35 +00:00
parent 1a11a389e9
commit b76840d863
4 changed files with 251 additions and 72 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+14 -1
View File
@@ -19,7 +19,20 @@
"createAccount": "حساب جديد",
"registrationClosed": "إنشاء الحسابات العامة مغلق حالياً. تواصل مع مدير النظام.",
"displayNameAr": "الاسم (عربي)",
"displayNameEn": "الاسم (إنجليزي)"
"displayNameEn": "الاسم (إنجليزي)",
"subtitle": "سجّل دخولك للمتابعة",
"signingIn": "جارٍ الدخول...",
"noAccount": "ليس لديك حساب؟",
"showPassword": "إظهار كلمة المرور",
"hidePassword": "إخفاء كلمة المرور",
"errRequired": "الرجاء إدخال اسم المستخدم وكلمة المرور.",
"errInvalid": "اسم مستخدم أو كلمة مرور غير صحيحة.",
"heroTitle": "مرحباً بك في منصتك الموحدة.",
"heroSubtitle": "سجّل دخولك لإدارة كل شيء من مكان واحد.",
"feat1": "كل أدواتك في لوحة تحكم واحدة",
"feat2": "تحديثات لحظية",
"feat3": "وصول آمن بالجلسات",
"rights": "جميع الحقوق محفوظة."
},
"home": {
"myApps": "تطبيقاتي"
+14 -1
View File
@@ -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"
+223 -70
View File
@@ -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,77 +48,217 @@ 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>
<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>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">{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="relative z-10 text-sm text-muted-foreground font-medium">
&copy; {new Date().getFullYear()} {appName}. {t("auth.rights")}
</div>
</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"
disabled={login.isPending}
>
{login.isPending ? t("common.loading") : t("auth.loginButton")}
</Button>
</form>
{settings?.registrationOpen && (
<div className="text-center">
<button
onClick={() => setLocation("/register")}
className="text-sm text-primary hover:underline"
>
{t("auth.register")}
</button>
</div>
)}
<div className="text-center">
{/* 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
onClick={() => i18n.changeLanguage(i18n.language === "ar" ? "en" : "ar")}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
type="button"
onClick={toggleLanguage}
className="text-sm font-medium hover:bg-foreground/5 px-3 py-1.5 rounded-md transition-colors text-foreground"
>
{t("common.language")}
{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"
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"
>
{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 text-sm text-muted-foreground pt-2">
{t("auth.noAccount")}{" "}
<button
type="button"
onClick={() => setLocation("/register")}
className="text-primary font-semibold hover:underline"
>
{t("auth.register")}
</button>
</div>
)}
</form>
</div>
</div>
</div>
</div>
);