authentication and calendar-sync passwords keyed (214 -> 163)
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These are the first words a new account reads, which is why the brief ranked them above the larger screens. DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now uses useFormat().date like the other two. DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it never appears. Keyed rather than deleted because removing it is not my call.
This commit is contained in:
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function ForgotPassword() {
|
export function ForgotPassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const client = useClient('/api/auth');
|
const client = useClient('/api/auth');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@@ -34,29 +36,29 @@ export function ForgotPassword() {
|
|||||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||||
{done ? (
|
{done ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
|
||||||
<div className="text-sm text-duck-dark/60">
|
<div className="text-sm text-duck-dark/60">
|
||||||
<p>
|
<p>
|
||||||
A password reset link has been sent to <strong>{state.email}</strong>.
|
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p>Click it to reset your password.</p>
|
<p>{t('auth.checkEmailHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -74,7 +76,7 @@ export function ForgotPassword() {
|
|||||||
<div className="text-center text-sm text-duck-dark/60">
|
<div className="text-center text-sm text-duck-dark/60">
|
||||||
<p>
|
<p>
|
||||||
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
Back to login
|
{t('auth.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useResetPassword } from './useResetPassword';
|
import { useResetPassword } from './useResetPassword';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function ResetPassword() {
|
export function ResetPassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||||
const loading = tokenStatus === 'loading';
|
const loading = tokenStatus === 'loading';
|
||||||
const invalid = tokenStatus === 'invalid';
|
const invalid = tokenStatus === 'invalid';
|
||||||
@@ -15,45 +17,45 @@ export function ResetPassword() {
|
|||||||
<>
|
<>
|
||||||
{loading && (
|
{loading && (
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invalid && (
|
{invalid && (
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
|
||||||
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Enter your new password</div>
|
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<div className="grid md:flex gap-4">
|
<div className="grid md:flex gap-4">
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder={t('auth.minChars')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder={t('auth.confirmPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -65,7 +67,7 @@ export function ResetPassword() {
|
|||||||
disabled={!isValid || isSubmitting}
|
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"
|
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 ? 'Resetting...' : 'Reset Password'}
|
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
|
|||||||
import { useMounted } from 'hooks/useMounted';
|
import { useMounted } from 'hooks/useMounted';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export const useResetPassword = () => {
|
export const useResetPassword = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isMounted = useMounted();
|
const isMounted = useMounted();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useClient('/api/auth');
|
const client = useClient('/api/auth');
|
||||||
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
|
|||||||
password: state.password,
|
password: state.password,
|
||||||
verificationCode,
|
verificationCode,
|
||||||
});
|
});
|
||||||
toast.success('Password reset successfully. Please sign in.');
|
toast.success(t('auth.resetOk'));
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
const error = ex as { message?: string };
|
const error = ex as { message?: string };
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
export function Bootstrap() {
|
export function Bootstrap() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const authClient = useClient('/api/auth');
|
const authClient = useClient('/api/auth');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -30,7 +32,7 @@ export function Bootstrap() {
|
|||||||
password: state.password,
|
password: state.password,
|
||||||
confirmPassword: state.confirmPassword,
|
confirmPassword: state.confirmPassword,
|
||||||
});
|
});
|
||||||
toast.success('Administrator account created. Please sign in.');
|
toast.success(t('auth.bootstrapOk'));
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
const error = ex as { message?: string };
|
const error = ex as { message?: string };
|
||||||
@@ -43,35 +45,35 @@ export function Bootstrap() {
|
|||||||
return (
|
return (
|
||||||
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Create your administrator account</div>
|
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Name</span>
|
<span className="text-duck-dark/70">{t('auth.name')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Your name"
|
placeholder={t('auth.namePlaceholder')}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email</span>
|
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="admin@example.com"
|
placeholder={t('auth.adminEmailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Username</span>
|
<span className="text-duck-dark/70">{t('auth.username')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -83,23 +85,23 @@ export function Bootstrap() {
|
|||||||
|
|
||||||
<div className="grid md:flex gap-4">
|
<div className="grid md:flex gap-4">
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Min 12 characters"
|
placeholder={t('auth.minChars')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
<Label className="grid gap-2 flex-1">
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm your password"
|
placeholder={t('auth.confirmPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
|
|||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
const initialState: LoginFormState = {
|
const initialState: LoginFormState = {
|
||||||
// email: 'pastilhas@pastilhas.dev',
|
// email: 'pastilhas@pastilhas.dev',
|
||||||
// password: '',
|
// password: '',
|
||||||
};
|
};
|
||||||
export function Login() {
|
export function Login() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
||||||
const { signin } = useAuth();
|
const { signin } = useAuth();
|
||||||
@@ -40,20 +42,20 @@ export function Login() {
|
|||||||
return (
|
return (
|
||||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
|
||||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Email or username</span>
|
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
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
|
// `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.
|
// field would have validated the user out of an account they have.
|
||||||
type="text"
|
type="text"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
// `username` rather than `email`, which is what password managers expect for a field that
|
// `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.
|
// takes either — and it is the value they already store beside the password.
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -61,12 +63,12 @@ export function Login() {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">Password</span>
|
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Your password"
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -90,7 +92,7 @@ export function Login() {
|
|||||||
{/* </p> */}
|
{/* </p> */}
|
||||||
<p>
|
<p>
|
||||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||||
Forgot password?
|
{t('auth.forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
type DebugPanelProps = {
|
type DebugPanelProps = {
|
||||||
fullControlMode: boolean;
|
fullControlMode: boolean;
|
||||||
cameraInfo: {
|
cameraInfo: {
|
||||||
@@ -13,9 +15,10 @@ type DebugPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||||
<div className="font-bold text-blue-400">Controls:</div>
|
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
|
||||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||||
</div>
|
</div>
|
||||||
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
|
|||||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||||
|
|
||||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
|
||||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||||
|
|
||||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
|
||||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||||
|
|||||||
+34
-23
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { copyToClipboard } from 'helpers/clipboard';
|
import { copyToClipboard } from 'helpers/clipboard';
|
||||||
|
import { useFormat } from 'i18n';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||||
//
|
//
|
||||||
@@ -26,19 +28,21 @@ type DavPassword = {
|
|||||||
|
|
||||||
type Minted = { password: string; username: string; label: string };
|
type Minted = { password: string; username: string; label: string };
|
||||||
|
|
||||||
const formatDate = (value: string | null) =>
|
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
|
||||||
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
|
||||||
|
|
||||||
const copy = async (text: string, what: string) => {
|
|
||||||
try {
|
|
||||||
await copyToClipboard(text);
|
|
||||||
toast.success(`${what} copied`);
|
|
||||||
} catch {
|
|
||||||
toast.error('Could not copy — select and copy manually');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DavAppPasswords = () => {
|
export const DavAppPasswords = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { date: formatDate } = useFormat();
|
||||||
|
|
||||||
|
const copy = async (text: string, what: string) => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(text);
|
||||||
|
toast.success(t('settings.dav.copied', { what }));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.dav.copyFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -71,7 +75,7 @@ export const DavAppPasswords = () => {
|
|||||||
setLabel('');
|
setLabel('');
|
||||||
load();
|
load();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not create the app password');
|
toast.error(t('settings.dav.createFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
@@ -80,10 +84,10 @@ export const DavAppPasswords = () => {
|
|||||||
const revoke = async (id: number) => {
|
const revoke = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await client.post(`/dav/passwords/${id}/revoke`, {});
|
await client.post(`/dav/passwords/${id}/revoke`, {});
|
||||||
toast.success('Revoked — that device will stop syncing');
|
toast.success(t('settings.dav.revoked'));
|
||||||
load();
|
load();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not revoke');
|
toast.error(t('settings.dav.revokeFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +96,7 @@ export const DavAppPasswords = () => {
|
|||||||
await client.delete(`/dav/passwords/${id}`);
|
await client.delete(`/dav/passwords/${id}`);
|
||||||
load();
|
load();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not delete');
|
toast.error(t('settings.dav.deleteFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +130,12 @@ export const DavAppPasswords = () => {
|
|||||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||||
{value}
|
{value}
|
||||||
</code>
|
</code>
|
||||||
<Button variant="ghost" size="sm" onClick={() => copy(value, field)} title={`Copy ${field}`}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copy(value, field)}
|
||||||
|
title={t('settings.dav.copyField', { field })}
|
||||||
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,29 +155,31 @@ export const DavAppPasswords = () => {
|
|||||||
|
|
||||||
<form onSubmit={create} className="flex items-end gap-2">
|
<form onSubmit={create} className="flex items-end gap-2">
|
||||||
<Label className="grid flex-1 gap-2">
|
<Label className="grid flex-1 gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20"
|
className="h-11 bg-background/60 border-duck-dark/20"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(ev) => setLabel(ev.target.value)}
|
onChange={(ev) => setLabel(ev.target.value)}
|
||||||
placeholder="Pixel 9, iPad, Thunderbird…"
|
placeholder={t('settings.dav.placeholder')}
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
Create
|
{t('settings.dav.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{passwords.length === 0 ? (
|
{passwords.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||||
No devices yet.
|
{t('settings.dav.none')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{passwords.map((entry) => {
|
{passwords.map((entry) => {
|
||||||
const revoked = !!entry.revokedAt;
|
const revoked = !!entry.revokedAt;
|
||||||
const lastUsed = formatDate(entry.lastUsedAt);
|
const lastUsed = entry.lastUsedAt
|
||||||
|
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
@@ -192,7 +203,7 @@ export const DavAppPasswords = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!revoked && (
|
{!revoked && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title="Revoke">
|
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
|
||||||
<Ban className="h-3.5 w-3.5" />
|
<Ban className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +211,7 @@ export const DavAppPasswords = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => remove(entry.id)}
|
onClick={() => remove(entry.id)}
|
||||||
title="Delete"
|
title={t('settings.dav.delete')}
|
||||||
className="hover:text-red-500"
|
className="hover:text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -360,6 +360,21 @@
|
|||||||
"resetTitle": "Reset the password for {{email}}?",
|
"resetTitle": "Reset the password for {{email}}?",
|
||||||
"resetBody": "A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up afterwards. Their existing password stops working immediately, and they are signed out everywhere.",
|
"resetBody": "A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up afterwards. Their existing password stops working immediately, and they are signed out everywhere.",
|
||||||
"resetLinuxNote": "Their Linux account ({{osUser}}) is not affected: it has no password, and SSH keys are unchanged."
|
"resetLinuxNote": "Their Linux account ({{osUser}}) is not affected: it has no password, and SSH keys are unchanged."
|
||||||
|
},
|
||||||
|
"dav": {
|
||||||
|
"newDevice": "New device",
|
||||||
|
"create": "Create",
|
||||||
|
"none": "No devices yet.",
|
||||||
|
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"delete": "Delete",
|
||||||
|
"copyFailed": "Could not copy — select and copy manually",
|
||||||
|
"createFailed": "Could not create the app password",
|
||||||
|
"revoked": "Revoked — that device will stop syncing",
|
||||||
|
"revokeFailed": "Could not revoke",
|
||||||
|
"deleteFailed": "Could not delete",
|
||||||
|
"copyField": "Copy {{field}}",
|
||||||
|
"copied": "{{what}} copied"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -484,5 +499,41 @@
|
|||||||
"submitted": "Bug report submitted",
|
"submitted": "Bug report submitted",
|
||||||
"failed": "Failed to submit bug report",
|
"failed": "Failed to submit bug report",
|
||||||
"submit": "Submit"
|
"submit": "Submit"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signInTitle": "Welcome Back",
|
||||||
|
"signInSubtitle": "Sign in to your account",
|
||||||
|
"emailOrUsername": "Email or username",
|
||||||
|
"password": "Password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordPlaceholder": "Your password",
|
||||||
|
"forgotTitle": "Forgot Password",
|
||||||
|
"forgotSubtitle": "Enter your email to receive a reset link",
|
||||||
|
"email": "Email",
|
||||||
|
"backToLogin": "Back to login",
|
||||||
|
"checkEmail": "Check your email",
|
||||||
|
"checkEmailHint": "Click it to reset your password.",
|
||||||
|
"resetOk": "Password reset successfully. Please sign in.",
|
||||||
|
"verifying": "Verifying…",
|
||||||
|
"invalidLink": "Invalid Link",
|
||||||
|
"invalidLinkHint": "This reset link is invalid or has expired.",
|
||||||
|
"resetTitle": "Reset Password",
|
||||||
|
"resetSubtitle": "Enter your new password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"minChars": "Min 12 characters",
|
||||||
|
"confirmPlaceholder": "Confirm your password",
|
||||||
|
"bootstrapTitle": "Welcome, Admin",
|
||||||
|
"bootstrapSubtitle": "Create your administrator account",
|
||||||
|
"name": "Name",
|
||||||
|
"username": "Username",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"adminEmailPlaceholder": "admin@example.com",
|
||||||
|
"bootstrapOk": "Administrator account created. Please sign in.",
|
||||||
|
"debug": {
|
||||||
|
"controls": "Controls:",
|
||||||
|
"camera": "Camera:",
|
||||||
|
"model": "Model:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,21 @@
|
|||||||
"resetTitle": "Redefinir a palavra-passe de {{email}}?",
|
"resetTitle": "Redefinir a palavra-passe de {{email}}?",
|
||||||
"resetBody": "É gerada uma nova palavra-passe e mostrada uma única vez — não é guardada em nenhum lugar e não pode ser consultada depois. A palavra-passe atual deixa de funcionar imediatamente, e a sessão é terminada em todo o lado.",
|
"resetBody": "É gerada uma nova palavra-passe e mostrada uma única vez — não é guardada em nenhum lugar e não pode ser consultada depois. A palavra-passe atual deixa de funcionar imediatamente, e a sessão é terminada em todo o lado.",
|
||||||
"resetLinuxNote": "A conta Linux ({{osUser}}) não é afetada: não tem palavra-passe, e as chaves SSH ficam inalteradas."
|
"resetLinuxNote": "A conta Linux ({{osUser}}) não é afetada: não tem palavra-passe, e as chaves SSH ficam inalteradas."
|
||||||
|
},
|
||||||
|
"dav": {
|
||||||
|
"newDevice": "Novo dispositivo",
|
||||||
|
"create": "Criar",
|
||||||
|
"none": "Ainda não há dispositivos.",
|
||||||
|
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||||
|
"revoke": "Revogar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
|
||||||
|
"createFailed": "Não foi possível criar a palavra-passe de aplicação",
|
||||||
|
"revoked": "Revogada — esse dispositivo deixa de sincronizar",
|
||||||
|
"revokeFailed": "Não foi possível revogar",
|
||||||
|
"deleteFailed": "Não foi possível eliminar",
|
||||||
|
"copyField": "Copiar {{field}}",
|
||||||
|
"copied": "{{what}} copiado"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -484,5 +499,41 @@
|
|||||||
"submitted": "Comunicação de erro enviada",
|
"submitted": "Comunicação de erro enviada",
|
||||||
"failed": "Falha ao enviar a comunicação de erro",
|
"failed": "Falha ao enviar a comunicação de erro",
|
||||||
"submit": "Enviar"
|
"submit": "Enviar"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signInTitle": "Bem-vindo de volta",
|
||||||
|
"signInSubtitle": "Inicie sessão na sua conta",
|
||||||
|
"emailOrUsername": "Email ou nome de utilizador",
|
||||||
|
"password": "Palavra-passe",
|
||||||
|
"forgotPassword": "Esqueceu-se da palavra-passe?",
|
||||||
|
"emailPlaceholder": "utilizador@exemplo.com",
|
||||||
|
"passwordPlaceholder": "A sua palavra-passe",
|
||||||
|
"forgotTitle": "Recuperar palavra-passe",
|
||||||
|
"forgotSubtitle": "Introduza o seu email para receber um link de recuperação",
|
||||||
|
"email": "Email",
|
||||||
|
"backToLogin": "Voltar ao início de sessão",
|
||||||
|
"checkEmail": "Verifique o seu email",
|
||||||
|
"checkEmailHint": "Clique nele para redefinir a palavra-passe.",
|
||||||
|
"resetOk": "Palavra-passe redefinida com sucesso. Inicie sessão.",
|
||||||
|
"verifying": "A verificar…",
|
||||||
|
"invalidLink": "Link inválido",
|
||||||
|
"invalidLinkHint": "Este link de recuperação é inválido ou expirou.",
|
||||||
|
"resetTitle": "Redefinir palavra-passe",
|
||||||
|
"resetSubtitle": "Introduza a sua nova palavra-passe",
|
||||||
|
"confirmPassword": "Confirmar palavra-passe",
|
||||||
|
"minChars": "Mínimo 12 caracteres",
|
||||||
|
"confirmPlaceholder": "Confirme a sua palavra-passe",
|
||||||
|
"bootstrapTitle": "Bem-vindo, Administrador",
|
||||||
|
"bootstrapSubtitle": "Crie a sua conta de administrador",
|
||||||
|
"name": "Nome",
|
||||||
|
"username": "Nome de utilizador",
|
||||||
|
"namePlaceholder": "O seu nome",
|
||||||
|
"adminEmailPlaceholder": "admin@exemplo.com",
|
||||||
|
"bootstrapOk": "Conta de administrador criada. Inicie sessão.",
|
||||||
|
"debug": {
|
||||||
|
"controls": "Controlos:",
|
||||||
|
"camera": "Câmara:",
|
||||||
|
"model": "Modelo:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user