fix and update

This commit is contained in:
lendry
2026-07-02 00:10:22 +03:00
parent bcdfbc3861
commit 4306d0ce37
11 changed files with 375 additions and 41 deletions

View File

@@ -41,6 +41,8 @@ import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
import { AppBootstrapScreen } from './app-bootstrap-screen';
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 {
user: PublicUser | null;
@@ -126,7 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const { publicApiUrl } = usePublicSettings();
const { publicApiUrl, pinLockTimeoutMinutes } = usePublicSettings();
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
const [token, setToken] = React.useState<string | null>(initialAuth.token);
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 resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
if (!resolvedSessionId) return;
markPinIdleWatch(user?.id);
setLockedSessionId(resolvedSessionId);
setIsPinLocked(true);
setPinError(null);
@@ -196,7 +199,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (cachedUser) {
setUser(cachedUser);
}
}, []);
}, [user?.id]);
const warmUpAndApplySession = React.useCallback(
async (accessToken: string) => {
@@ -275,6 +278,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.localStorage.removeItem(AUTH_REFRESH_KEY);
window.localStorage.removeItem(AUTH_SESSION_KEY);
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
clearPinIdleWatch();
initialBootstrapDoneRef.current = false;
setToken(null);
setUser(null);
@@ -456,22 +460,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
void refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
if (!isSessionReady || isPinLocked) return;
if (pathname.startsWith('/auth/')) return;
const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
if (!accessToken) return;
const pinIdleLockEnabled =
isSessionReady &&
!isPinLocked &&
!pathname.startsWith('/auth/') &&
Boolean(token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null)) &&
isPinIdleWatchActive(user?.id);
void fetchAuthSession(accessToken)
.then((session) => {
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
})
.catch((error) => {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
}
});
}, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]);
usePinIdleLock({
enabled: pinIdleLockEnabled,
timeoutMinutes: pinLockTimeoutMinutes,
accessToken: token,
onLockRequired: activatePinLock,
});
React.useEffect(() => {
function handleVisibilityChange() {
@@ -515,6 +516,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
@@ -550,6 +552,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await establishSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
markPinIdleWatch(response.auth.user.id);
setHasStoredSession(true);
}
return response;
@@ -571,6 +574,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
@@ -592,6 +596,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
return auth;
@@ -620,6 +625,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await establishSession(auth);
} else {
persistPartialAuth(auth);
markPinIdleWatch(auth.user.id);
setHasStoredSession(true);
}
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_SESSION_KEY, sessionId);
setToken(response.accessToken);
markPinIdleWatch(user?.id);
try {
await warmUpAndApplySession(response.accessToken);
@@ -673,7 +680,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.replace('/');
}
},
[clearPinLock, pathname, router, warmUpAndApplySession]
[clearPinLock, pathname, router, user?.id, warmUpAndApplySession]
);
const handlePinUnlock = React.useCallback(

View File

@@ -4,7 +4,8 @@ import * as React from 'react';
import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
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 {
open: boolean;
@@ -39,20 +40,18 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockMod
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
<PinInput
autoFocus
className="h-[58px] text-center text-lg tracking-[0.4em]"
placeholder="PIN"
value={pin}
onChange={(event) => setPin(event.target.value)}
onChange={setPin}
required
minLength={4}
maxLength={6}
inputMode="numeric"
autoComplete="one-time-code"
/>
{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 ? 'Проверяем...' : 'Разблокировать'}
</Button>
</form>

View File

@@ -11,6 +11,7 @@ interface PublicSettingsContextValue {
publicFrontendUrl: string;
ldapEnabled: boolean;
ldapUseLdaps: boolean;
pinLockTimeoutMinutes: number;
isLoading: boolean;
refreshPublicSettings: () => Promise<void>;
}
@@ -22,6 +23,7 @@ const PublicSettingsContext = createContext<PublicSettingsContextValue>({
publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL,
ldapEnabled: false,
ldapUseLdaps: false,
pinLockTimeoutMinutes: 15,
isLoading: true,
refreshPublicSettings: async () => undefined
});
@@ -65,6 +67,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''),
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').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,
refreshPublicSettings
};

View 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}
/>
);
}