fix and update
This commit is contained in:
@@ -23,6 +23,8 @@ import { useToast } from '@/components/id/toast-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
|
|
||||||
import { OtpInput } from '@/components/ui/otp-input';
|
import { OtpInput } from '@/components/ui/otp-input';
|
||||||
|
|
||||||
@@ -1369,7 +1371,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<form onSubmit={handlePinSubmit} className="space-y-3">
|
<form onSubmit={handlePinSubmit} className="space-y-3">
|
||||||
|
|
||||||
<Input
|
<PinInput
|
||||||
|
|
||||||
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||||
|
|
||||||
@@ -1377,7 +1379,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
value={pin}
|
value={pin}
|
||||||
|
|
||||||
onChange={(event) => setPin(event.target.value)}
|
onChange={setPin}
|
||||||
|
|
||||||
required
|
required
|
||||||
|
|
||||||
@@ -1387,7 +1389,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting || !isPinInputComplete(pin)}>
|
||||||
|
|
||||||
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAuth } from '@/components/id/auth-provider';
|
|||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
import { OtpInput } from '@/components/ui/otp-input';
|
import { OtpInput } from '@/components/ui/otp-input';
|
||||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -166,16 +168,16 @@ export default function RegisterPage() {
|
|||||||
{step === 'pin' && pendingSessionId ? (
|
{step === 'pin' && pendingSessionId ? (
|
||||||
<form onSubmit={handlePinSubmit} className="mt-8 space-y-3">
|
<form onSubmit={handlePinSubmit} className="mt-8 space-y-3">
|
||||||
<p className="text-center text-sm text-[#b9bdc9]">Установите PIN для завершения регистрации</p>
|
<p className="text-center text-sm text-[#b9bdc9]">Установите PIN для завершения регистрации</p>
|
||||||
<Input
|
<PinInput
|
||||||
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||||
placeholder="PIN"
|
placeholder="PIN"
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(event) => setPin(event.target.value)}
|
onChange={setPin}
|
||||||
required
|
required
|
||||||
minLength={4}
|
minLength={4}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
|
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting || !isPinInputComplete(pin)}>
|
||||||
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ import { useToast } from '@/components/id/toast-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
import { OtpInput } from '@/components/ui/otp-input';
|
import { OtpInput } from '@/components/ui/otp-input';
|
||||||
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
|
||||||
|
import { markPinIdleWatch } from '@/lib/pin-idle-storage';
|
||||||
import { formatUserAgentLabel } from '@/lib/device-client';
|
import { formatUserAgentLabel } from '@/lib/device-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
@@ -51,8 +54,8 @@ const deviceIcons: Record<string, LucideIcon> = {
|
|||||||
|
|
||||||
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
|
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
|
||||||
|
|
||||||
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { title: string; placeholder: string; type: string }> = {
|
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { title: string; placeholder: string; type?: string }> = {
|
||||||
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
|
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр' },
|
||||||
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
|
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
|
||||||
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
|
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
|
||||||
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
|
||||||
@@ -336,6 +339,7 @@ export default function SecurityPage() {
|
|||||||
try {
|
try {
|
||||||
if (dialogMode === 'pin') {
|
if (dialogMode === 'pin') {
|
||||||
await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token);
|
await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token);
|
||||||
|
markPinIdleWatch(user.id);
|
||||||
showToast('PIN-код установлен');
|
showToast('PIN-код установлен');
|
||||||
} else if (dialogMode === 'password') {
|
} else if (dialogMode === 'password') {
|
||||||
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
|
||||||
@@ -694,15 +698,29 @@ export default function SecurityPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||||||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||||||
|
{dialogMode === 'pin' ? (
|
||||||
|
<PinInput
|
||||||
|
className="border-0 bg-transparent focus:ring-0"
|
||||||
|
placeholder={dialogConfig.pin.placeholder}
|
||||||
|
value={dialogValue}
|
||||||
|
onChange={setDialogValue}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Input
|
<Input
|
||||||
className="border-0 bg-transparent focus:ring-0"
|
className="border-0 bg-transparent focus:ring-0"
|
||||||
type={dialogConfig[dialogMode].type}
|
type={dialogMode === 'password' ? 'password' : 'text'}
|
||||||
placeholder={dialogConfig[dialogMode].placeholder}
|
placeholder={dialogConfig[dialogMode].placeholder}
|
||||||
value={dialogValue}
|
value={dialogValue}
|
||||||
onChange={(event) => setDialogValue(event.target.value)}
|
onChange={(event) => setDialogValue(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
|
<Button
|
||||||
|
className="mt-5 w-full"
|
||||||
|
onClick={submitDialog}
|
||||||
|
disabled={isDialogSaving || (dialogMode === 'pin' ? !isPinInputComplete(dialogValue) : !dialogValue.trim())}
|
||||||
|
>
|
||||||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { useToast } from './toast-provider';
|
|||||||
import { PinLockModal } from './pin-lock-modal';
|
import { PinLockModal } from './pin-lock-modal';
|
||||||
import { AppBootstrapScreen } from './app-bootstrap-screen';
|
import { AppBootstrapScreen } from './app-bootstrap-screen';
|
||||||
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge';
|
import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge';
|
||||||
|
import { usePinIdleLock } from '@/hooks/use-pin-idle-lock';
|
||||||
|
import { clearPinIdleWatch, isPinIdleWatchActive, markPinIdleWatch } from '@/lib/pin-idle-storage';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: PublicUser | null;
|
user: PublicUser | null;
|
||||||
@@ -126,7 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { publicApiUrl } = usePublicSettings();
|
const { publicApiUrl, pinLockTimeoutMinutes } = usePublicSettings();
|
||||||
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
||||||
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
||||||
const [user, setUser] = React.useState<PublicUser | null>(null);
|
const [user, setUser] = React.useState<PublicUser | null>(null);
|
||||||
@@ -188,6 +190,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const activatePinLock = React.useCallback((sessionId: string) => {
|
const activatePinLock = React.useCallback((sessionId: string) => {
|
||||||
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
|
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
|
||||||
if (!resolvedSessionId) return;
|
if (!resolvedSessionId) return;
|
||||||
|
markPinIdleWatch(user?.id);
|
||||||
setLockedSessionId(resolvedSessionId);
|
setLockedSessionId(resolvedSessionId);
|
||||||
setIsPinLocked(true);
|
setIsPinLocked(true);
|
||||||
setPinError(null);
|
setPinError(null);
|
||||||
@@ -196,7 +199,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
setUser(cachedUser);
|
setUser(cachedUser);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [user?.id]);
|
||||||
|
|
||||||
const warmUpAndApplySession = React.useCallback(
|
const warmUpAndApplySession = React.useCallback(
|
||||||
async (accessToken: string) => {
|
async (accessToken: string) => {
|
||||||
@@ -275,6 +278,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.localStorage.removeItem(AUTH_REFRESH_KEY);
|
window.localStorage.removeItem(AUTH_REFRESH_KEY);
|
||||||
window.localStorage.removeItem(AUTH_SESSION_KEY);
|
window.localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
|
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
|
||||||
|
clearPinIdleWatch();
|
||||||
initialBootstrapDoneRef.current = false;
|
initialBootstrapDoneRef.current = false;
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -456,22 +460,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
}, [refreshProfile]);
|
}, [refreshProfile]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const pinIdleLockEnabled =
|
||||||
if (!isSessionReady || isPinLocked) return;
|
isSessionReady &&
|
||||||
if (pathname.startsWith('/auth/')) return;
|
!isPinLocked &&
|
||||||
const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
!pathname.startsWith('/auth/') &&
|
||||||
if (!accessToken) return;
|
Boolean(token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null)) &&
|
||||||
|
isPinIdleWatchActive(user?.id);
|
||||||
|
|
||||||
void fetchAuthSession(accessToken)
|
usePinIdleLock({
|
||||||
.then((session) => {
|
enabled: pinIdleLockEnabled,
|
||||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
timeoutMinutes: pinLockTimeoutMinutes,
|
||||||
})
|
accessToken: token,
|
||||||
.catch((error) => {
|
onLockRequired: activatePinLock,
|
||||||
if (isPinRequiredError(error)) {
|
|
||||||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
function handleVisibilityChange() {
|
function handleVisibilityChange() {
|
||||||
@@ -515,6 +516,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await establishSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
|
markPinIdleWatch(auth.user.id);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
@@ -550,6 +552,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await establishSession(response.auth);
|
await establishSession(response.auth);
|
||||||
} else if (response.auth) {
|
} else if (response.auth) {
|
||||||
persistPartialAuth(response.auth);
|
persistPartialAuth(response.auth);
|
||||||
|
markPinIdleWatch(response.auth.user.id);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
@@ -571,6 +574,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await establishSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
|
markPinIdleWatch(auth.user.id);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
@@ -592,6 +596,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await establishSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
|
markPinIdleWatch(auth.user.id);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
@@ -620,6 +625,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await establishSession(auth);
|
await establishSession(auth);
|
||||||
} else {
|
} else {
|
||||||
persistPartialAuth(auth);
|
persistPartialAuth(auth);
|
||||||
|
markPinIdleWatch(auth.user.id);
|
||||||
setHasStoredSession(true);
|
setHasStoredSession(true);
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
@@ -650,6 +656,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
|
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
|
||||||
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
|
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
|
||||||
setToken(response.accessToken);
|
setToken(response.accessToken);
|
||||||
|
markPinIdleWatch(user?.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await warmUpAndApplySession(response.accessToken);
|
await warmUpAndApplySession(response.accessToken);
|
||||||
@@ -673,7 +680,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
router.replace('/');
|
router.replace('/');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearPinLock, pathname, router, warmUpAndApplySession]
|
[clearPinLock, pathname, router, user?.id, warmUpAndApplySession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePinUnlock = React.useCallback(
|
const handlePinUnlock = React.useCallback(
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import * as React from 'react';
|
|||||||
import { KeyRound } from 'lucide-react';
|
import { KeyRound } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
|
|
||||||
interface PinLockModalProps {
|
interface PinLockModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -39,20 +40,18 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockMod
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<Input
|
<PinInput
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-[58px] text-center text-lg tracking-[0.4em]"
|
className="h-[58px] text-center text-lg tracking-[0.4em]"
|
||||||
placeholder="PIN"
|
placeholder="PIN"
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(event) => setPin(event.target.value)}
|
onChange={setPin}
|
||||||
required
|
required
|
||||||
minLength={4}
|
minLength={4}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
/>
|
||||||
{error ? <p className="text-center text-sm text-red-600">{error}</p> : null}
|
{error ? <p className="text-center text-sm text-red-600">{error}</p> : null}
|
||||||
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting || pin.length < 4}>
|
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting || !isPinInputComplete(pin)}>
|
||||||
{isSubmitting ? 'Проверяем...' : 'Разблокировать'}
|
{isSubmitting ? 'Проверяем...' : 'Разблокировать'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface PublicSettingsContextValue {
|
|||||||
publicFrontendUrl: string;
|
publicFrontendUrl: string;
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
ldapUseLdaps: boolean;
|
ldapUseLdaps: boolean;
|
||||||
|
pinLockTimeoutMinutes: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refreshPublicSettings: () => Promise<void>;
|
refreshPublicSettings: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,7 @@ const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
|||||||
publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL,
|
publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL,
|
||||||
ldapEnabled: false,
|
ldapEnabled: false,
|
||||||
ldapUseLdaps: false,
|
ldapUseLdaps: false,
|
||||||
|
pinLockTimeoutMinutes: 15,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
refreshPublicSettings: async () => undefined
|
refreshPublicSettings: async () => undefined
|
||||||
});
|
});
|
||||||
@@ -65,6 +67,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
|
|||||||
publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''),
|
publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''),
|
||||||
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
||||||
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
||||||
|
pinLockTimeoutMinutes: Math.max(1, Number.parseInt(settings.PIN_LOCK_TIMEOUT_MINUTES ?? '15', 10) || 15),
|
||||||
isLoading,
|
isLoading,
|
||||||
refreshPublicSettings
|
refreshPublicSettings
|
||||||
};
|
};
|
||||||
|
|||||||
65
apps/frontend/components/ui/pin-input.tsx
Normal file
65
apps/frontend/components/ui/pin-input.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PIN_MAX_LENGTH, sanitizePinInput } from '@/lib/pin-input';
|
||||||
|
|
||||||
|
const PIN_ALLOWED_KEYS = new Set([
|
||||||
|
'Backspace',
|
||||||
|
'Delete',
|
||||||
|
'Tab',
|
||||||
|
'Escape',
|
||||||
|
'Enter',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'Home',
|
||||||
|
'End',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface PinInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'inputMode'> {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxLength = PIN_MAX_LENGTH,
|
||||||
|
onKeyDown,
|
||||||
|
onPaste,
|
||||||
|
type = 'password',
|
||||||
|
...props
|
||||||
|
}: PinInputProps) {
|
||||||
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
onChange(sanitizePinInput(event.target.value, maxLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (!PIN_ALLOWED_KEYS.has(event.key) && !event.ctrlKey && !event.metaKey && !/^\d$/.test(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
onKeyDown?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(sanitizePinInput(event.clipboardData.getData('text'), maxLength));
|
||||||
|
onPaste?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
type={type}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="\d*"
|
||||||
|
maxLength={maxLength}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
apps/frontend/hooks/use-pin-idle-lock.ts
Normal file
192
apps/frontend/hooks/use-pin-idle-lock.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
AUTH_SESSION_KEY,
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
fetchAuthSession,
|
||||||
|
isPinRequiredError,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll', 'wheel', 'pointerdown', 'click'] as const;
|
||||||
|
const ACTIVITY_DEBOUNCE_MS = 250;
|
||||||
|
const SERVER_TOUCH_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
interface UsePinIdleLockOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
timeoutMinutes: number;
|
||||||
|
accessToken: string | null;
|
||||||
|
onLockRequired: (sessionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePinIdleLock({
|
||||||
|
enabled,
|
||||||
|
timeoutMinutes,
|
||||||
|
accessToken,
|
||||||
|
onLockRequired,
|
||||||
|
}: UsePinIdleLockOptions) {
|
||||||
|
const onLockRequiredRef = useRef(onLockRequired);
|
||||||
|
const accessTokenRef = useRef(accessToken);
|
||||||
|
const timeoutMsRef = useRef(Math.max(1, timeoutMinutes) * 60_000);
|
||||||
|
const idleDeadlineRef = useRef<number>(Date.now() + timeoutMsRef.current);
|
||||||
|
const idleTimerRef = useRef<number | null>(null);
|
||||||
|
const activityDebounceRef = useRef<number | null>(null);
|
||||||
|
const lastServerTouchRef = useRef(0);
|
||||||
|
const checkInFlightRef = useRef(false);
|
||||||
|
const enabledRef = useRef(enabled);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLockRequiredRef.current = onLockRequired;
|
||||||
|
}, [onLockRequired]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
accessTokenRef.current = accessToken;
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enabledRef.current = enabled;
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timeoutMsRef.current = Math.max(1, timeoutMinutes) * 60_000;
|
||||||
|
if (enabledRef.current) {
|
||||||
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
||||||
|
}
|
||||||
|
}, [timeoutMinutes]);
|
||||||
|
|
||||||
|
const clearIdleTimer = useCallback(() => {
|
||||||
|
if (idleTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(idleTimerRef.current);
|
||||||
|
idleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncServerActivity = useCallback(async () => {
|
||||||
|
const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastServerTouchRef.current < SERVER_TOUCH_INTERVAL_MS) return;
|
||||||
|
lastServerTouchRef.current = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchAuthSession(token);
|
||||||
|
} catch (error) {
|
||||||
|
if (isPinRequiredError(error)) {
|
||||||
|
onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkIdleLock = useCallback(async () => {
|
||||||
|
if (!enabledRef.current || checkInFlightRef.current) return;
|
||||||
|
|
||||||
|
const remainingMs = idleDeadlineRef.current - Date.now();
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
void checkIdleLock();
|
||||||
|
}, remainingMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkInFlightRef.current = true;
|
||||||
|
clearIdleTimer();
|
||||||
|
|
||||||
|
const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
if (!token) {
|
||||||
|
checkInFlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await fetchAuthSession(token);
|
||||||
|
if (session.requiresPin) {
|
||||||
|
onLockRequiredRef.current(session.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
void checkIdleLock();
|
||||||
|
}, timeoutMsRef.current);
|
||||||
|
} catch (error) {
|
||||||
|
if (isPinRequiredError(error)) {
|
||||||
|
onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
void checkIdleLock();
|
||||||
|
}, timeoutMsRef.current);
|
||||||
|
} finally {
|
||||||
|
checkInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [clearIdleTimer]);
|
||||||
|
|
||||||
|
const registerActivity = useCallback(() => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
|
||||||
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
void checkIdleLock();
|
||||||
|
}, timeoutMsRef.current);
|
||||||
|
|
||||||
|
void syncServerActivity();
|
||||||
|
}, [checkIdleLock, clearIdleTimer, syncServerActivity]);
|
||||||
|
|
||||||
|
const scheduleActivityReset = useCallback(() => {
|
||||||
|
if (activityDebounceRef.current !== null) {
|
||||||
|
window.clearTimeout(activityDebounceRef.current);
|
||||||
|
}
|
||||||
|
activityDebounceRef.current = window.setTimeout(() => {
|
||||||
|
activityDebounceRef.current = null;
|
||||||
|
registerActivity();
|
||||||
|
}, ACTIVITY_DEBOUNCE_MS);
|
||||||
|
}, [registerActivity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
clearIdleTimer();
|
||||||
|
if (activityDebounceRef.current !== null) {
|
||||||
|
window.clearTimeout(activityDebounceRef.current);
|
||||||
|
activityDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
idleDeadlineRef.current = Date.now() + timeoutMsRef.current;
|
||||||
|
lastServerTouchRef.current = Date.now();
|
||||||
|
void checkIdleLock();
|
||||||
|
|
||||||
|
for (const eventName of ACTIVITY_EVENTS) {
|
||||||
|
window.addEventListener(eventName, scheduleActivityReset, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
clearIdleTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void checkIdleLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearIdleTimer();
|
||||||
|
if (activityDebounceRef.current !== null) {
|
||||||
|
window.clearTimeout(activityDebounceRef.current);
|
||||||
|
activityDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
for (const eventName of ACTIVITY_EVENTS) {
|
||||||
|
window.removeEventListener(eventName, scheduleActivityReset);
|
||||||
|
}
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [checkIdleLock, clearIdleTimer, enabled, scheduleActivityReset]);
|
||||||
|
}
|
||||||
35
apps/frontend/lib/pin-idle-storage.ts
Normal file
35
apps/frontend/lib/pin-idle-storage.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { AUTH_USER_CACHE_KEY } from '@/lib/api';
|
||||||
|
|
||||||
|
const PIN_IDLE_WATCH_KEY = 'auth:pin-idle-watch-user';
|
||||||
|
|
||||||
|
function readCachedUserId(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { id?: string };
|
||||||
|
return parsed.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPinIdleWatch(userId?: string | null) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const resolvedUserId = userId ?? readCachedUserId();
|
||||||
|
if (!resolvedUserId) return;
|
||||||
|
window.localStorage.setItem(PIN_IDLE_WATCH_KEY, resolvedUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPinIdleWatch() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.removeItem(PIN_IDLE_WATCH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPinIdleWatchActive(userId?: string | null) {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const stored = window.localStorage.getItem(PIN_IDLE_WATCH_KEY);
|
||||||
|
if (!stored) return false;
|
||||||
|
const resolvedUserId = userId ?? readCachedUserId();
|
||||||
|
return Boolean(resolvedUserId && stored === resolvedUserId);
|
||||||
|
}
|
||||||
10
apps/frontend/lib/pin-input.ts
Normal file
10
apps/frontend/lib/pin-input.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const PIN_MIN_LENGTH = 4;
|
||||||
|
export const PIN_MAX_LENGTH = 6;
|
||||||
|
|
||||||
|
export function sanitizePinInput(value: string, maxLength: number = PIN_MAX_LENGTH): string {
|
||||||
|
return value.replace(/\D/g, '').slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPinInputComplete(value: string, minLength: number = PIN_MIN_LENGTH): boolean {
|
||||||
|
return /^\d+$/.test(value) && value.length >= minLength && value.length <= PIN_MAX_LENGTH;
|
||||||
|
}
|
||||||
@@ -132,7 +132,8 @@ export const PUBLIC_SETTING_KEYS = [
|
|||||||
'REGISTRATION_ENABLED',
|
'REGISTRATION_ENABLED',
|
||||||
'MAINTENANCE_MODE',
|
'MAINTENANCE_MODE',
|
||||||
'LDAP_ENABLED',
|
'LDAP_ENABLED',
|
||||||
'LDAP_USE_LDAPS'
|
'LDAP_USE_LDAPS',
|
||||||
|
'PIN_LOCK_TIMEOUT_MINUTES'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Reference in New Issue
Block a user