diff --git a/ordinis-admin-ui/nginx.conf b/ordinis-admin-ui/nginx.conf index cb6d11a..168eba5 100644 --- a/ordinis-admin-ui/nginx.conf +++ b/ordinis-admin-ui/nginx.conf @@ -29,6 +29,21 @@ server { proxy_pass_request_headers on; } + # /api/v1/version — VersionController в ordinis-rest-api → подключён ТОЛЬКО в + # writer (см. ordinis-app/pom.xml). Без явного правила generic /api/v1/ ниже + # роутит GET → read-api → 500 NoResourceFoundException → пустой traceId. + # Тот же класс баги что для /drafts/me ниже. + location = /api/v1/version { + set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}"; + proxy_pass http://$backend$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + } + # /api/v1/admin/* — audit + outbox admin endpoints на writer. location ~ ^/api/v1/admin/ { set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}"; diff --git a/ordinis-admin-ui/src/auth/RequireAuth.tsx b/ordinis-admin-ui/src/auth/RequireAuth.tsx index 779ebf5..a62c76f 100644 --- a/ordinis-admin-ui/src/auth/RequireAuth.tsx +++ b/ordinis-admin-ui/src/auth/RequireAuth.tsx @@ -1,19 +1,27 @@ -import { useEffect, type ReactNode } from 'react' +import { type ReactNode } from 'react' import { useAuth } from 'react-oidc-context' /** - * Гейтит всё содержимое приложения по аутентификации. Если юзер не залогинен — - * сразу редиректим на Keycloak (без промежуточной страницы "Вы не авторизованы" - * и без красного 401 от axios). Пока auth state ещё определяется — показываем - * нейтральный spinner. + * Auth gate с гостевым режимом view-only. * - * Сценарии: - * - First visit, нет токена → signinRedirect → Keycloak login → redirect_uri → - * onSigninCallback чистит URL → дети рендерятся. - * - Истёк access_token, silent renew не успел → 401 от backend ловится в - * {@link TokenSync} interceptor, который дёргает signinSilent/signinRedirect. - * - Возврат с ошибкой OIDC (?error=access_denied) → не редиректим в цикле, - * показываем сообщение. + *
До v1.1.1 этот компонент force-redirect'ил неаутентифицированных юзеров + * на Keycloak. Теперь — guest-friendly: anonymous user видит UI с PUBLIC scope + * данными (backend отдаёт PUBLIC dictionaries без auth, см. SecurityConfig + * permissive mode + ScopeContext anonymous → PUBLIC). + * + *
Логин теперь пользовательское действие: клик на кнопку «Войти» в top bar + * → {@link useAuth().signinRedirect()}. После возврата из Keycloak пользователь + * получает scope по ролям JWT и видит INTERNAL/RESTRICTED данные. + * + *
Сценарии: + *
{auth.error.message}
+ <> +Гость (anonymous) видит UI в read-only режиме: кнопки «Создать», «Удалить», + * иконки edit'а скрыты, schema editor disabled. Backend всё равно отрежет 401 + * на mutation endpoint'ах (writer требует JWT для POST/PUT/PATCH/DELETE), но + * скрытие UI избегает confusing user experience «жмёшь кнопку → toast 401». + * + *
Использовать в компонентах вместо прямого {@code useAuth().isAuthenticated}, + * чтобы при будущей более тонкой role-based gate'е (например, разрешить + * INTERNAL юзерам только PUBLIC mutations) — поменять одно место. + */ +export function useCanMutate(): boolean { + const auth = useAuth() + return auth.isAuthenticated +} diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx new file mode 100644 index 0000000..f5036cd --- /dev/null +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx @@ -0,0 +1,212 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { DatePicker } from '@nstart/ui' +import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react' + +/** + * Picker для time-travel: «активная версия на момент X». + * + *
До v1.1.1 был голый {@code } — pre-2010 + * native control с непредсказуемым стилем по браузерам и без TZ awareness. + * Здесь: + *
State модель: контролируемый компонент, parent держит {@code at: ISO string | + * undefined}. Undefined = «сейчас» (live). Любое preset / picker change → onChange + * с новым ISO. Сброс через {@code onClear} (parent очищает {@code ?at} в URL). + */ +export function TimeTravelPicker({ + value, + onChange, + onClear, + onClose, +}: { + value: string | undefined + onChange: (iso: string) => void + onClear: () => void + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const lang = (i18n.language || 'ru').split('-')[0] + + // Текущее значение date-only (для DatePicker) и time HH:MM (для number inputs). + const valueDate = useMemo(() => { + if (!value) return undefined + const d = new Date(value) + return Number.isNaN(d.getTime()) ? undefined : d + }, [value]) + + const hh = valueDate ? valueDate.getHours() : new Date().getHours() + const mm = valueDate ? valueDate.getMinutes() : new Date().getMinutes() + + // Combine date + HH:MM в ISO с локальным TZ-offset (preserve wall-clock). + const emitWithTime = (date: Date, hours: number, minutes: number) => { + const d = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + hours, + minutes, + 0, + 0, + ) + onChange(d.toISOString()) + } + + const handleDateChange = (d: Date | null) => { + if (!d) { + onClear() + return + } + emitWithTime(d, hh, mm) + } + + const handleHoursChange = (h: number) => { + if (!valueDate) { + // First time-edit без выбранной даты → принимаем «сегодня». + emitWithTime(new Date(), Math.max(0, Math.min(23, h)), mm) + return + } + emitWithTime(valueDate, Math.max(0, Math.min(23, h)), mm) + } + + const handleMinutesChange = (m: number) => { + if (!valueDate) { + emitWithTime(new Date(), hh, Math.max(0, Math.min(59, m))) + return + } + emitWithTime(valueDate, hh, Math.max(0, Math.min(59, m))) + } + + const applyPreset = (deltaMs: number) => { + const target = new Date(Date.now() + deltaMs) + onChange(target.toISOString()) + } + + // Relative-time строка: «3 ч назад», «через 2 дня». Intl.RelativeTimeFormat + // нативный, без depends, locale-aware. + const relativeLabel = useMemo(() => { + if (!valueDate) return null + const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }) + const diffMs = valueDate.getTime() - Date.now() + const absH = Math.abs(diffMs) / 3_600_000 + const absD = absH / 24 + if (absH < 1) { + return rtf.format(Math.round(diffMs / 60_000), 'minute') + } + if (absH < 48) { + return rtf.format(Math.round(diffMs / 3_600_000), 'hour') + } + if (absD < 60) { + return rtf.format(Math.round(diffMs / 86_400_000), 'day') + } + return rtf.format(Math.round(diffMs / (86_400_000 * 30)), 'month') + }, [valueDate, lang]) + + const presets: ReadonlyArray<{ key: string; label: string; deltaMs: number }> = [ + { key: 'now', label: t('timeTravel.preset.now'), deltaMs: 0 }, + { key: '-1h', label: t('timeTravel.preset.hourAgo'), deltaMs: -3_600_000 }, + { key: '-1d', label: t('timeTravel.preset.dayAgo'), deltaMs: -86_400_000 }, + { key: '-7d', label: t('timeTravel.preset.weekAgo'), deltaMs: -7 * 86_400_000 }, + { key: '-30d', label: t('timeTravel.preset.monthAgo'), deltaMs: -30 * 86_400_000 }, + { key: '-1y', label: t('timeTravel.preset.yearAgo'), deltaMs: -365 * 86_400_000 }, + ] + + return ( +