Only an address worked, though usernames are already unique and already validated as /^[a-zA-Z0-9._-]+$/ — which is what makes this safe: no `@`, so the two namespaces cannot overlap and there is nothing to guess. An `@` decides which lookup runs, and it decides it exactly. The field is still called `email` on the wire, because every client sends that name and renaming it would break them for nothing. Fixed a latent bug while passing: the JWT took `email` from the REQUEST rather than the row. Identical while an address was the only way in — and a username in the `email` claim the moment it was not, leaving the DAV door, the managed home path and everything else keyed on email looking up a user who does not exist. The login field is `type="text"`, not `type="email"`, or the browser refuses to submit a username and validates someone out of an account they have. `autoComplete="username"`, which is what password managers store beside a password for a field that takes either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Link } from 'react-router';
|
|
import { Card } from '@/components/Card';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { useForm } from 'hooks/useForm';
|
|
import { useAuth } from 'hooks/useAuth';
|
|
import { useGlobal } from 'hooks/useGlobal';
|
|
|
|
const initialState: LoginFormState = {
|
|
// email: 'pastilhas@pastilhas.dev',
|
|
// password: '',
|
|
};
|
|
export function Login() {
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
|
const { signin } = useAuth();
|
|
const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
|
|
|
|
const handleSubmit = async (ev: React.FormEvent) => {
|
|
ev.preventDefault();
|
|
if (!isValid || isSubmitting) return;
|
|
|
|
setIsSubmitting(true);
|
|
setDuckHidden(true);
|
|
try {
|
|
await signin({ email: state.email!, password: state.password! });
|
|
window.location.href = '/';
|
|
} catch (ex) {
|
|
const error = ex as { message?: string };
|
|
toast.error(error.message || 'Login failed. Please try again.');
|
|
update({ ...state, password: '' });
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
|
<div className="text-center">
|
|
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
|
<div className="text-duck-dark/60">Sign in to your account</div>
|
|
</div>
|
|
|
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70">Email or username</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
|
|
// field would have validated the user out of an account they have.
|
|
type="text"
|
|
name="email"
|
|
placeholder="you@example.com"
|
|
// `username` rather than `email`, which is what password managers expect for a field that
|
|
// takes either — and it is the value they already store beside the password.
|
|
autoComplete="username"
|
|
/>
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70">Password</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="password"
|
|
name="password"
|
|
placeholder="Your password"
|
|
autoComplete="current-password"
|
|
/>
|
|
</Label>
|
|
|
|
<div className="pt-2">
|
|
<Button
|
|
type="submit"
|
|
disabled={!isValid || isSubmitting}
|
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
>
|
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="text-center text-sm text-duck-dark/60 grid gap-1">
|
|
{/* <p> */}
|
|
{/* Don't have an account?{' '} */}
|
|
{/* <Link to="/auth/register" className="text-duck-forest underline hover:text-duck-forest/80"> */}
|
|
{/* Register */}
|
|
{/* </Link> */}
|
|
{/* </p> */}
|
|
<p>
|
|
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
|
Forgot password?
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type LoginFormState = {
|
|
email?: string;
|
|
password?: string;
|
|
};
|
|
|
|
const validateForm = (state: Partial<LoginFormState>) => {
|
|
const { email, password } = state;
|
|
if (!email || !password) return false;
|
|
return true;
|
|
};
|