From 0c6deed98de763be34a0fa5527889d5bfb4ff8f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:13:20 +0300 Subject: [PATCH] feat: switch login to hosted QueoAuth widget; tolerate username-only users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.html: load https://auth.queo.ru/widget.js - auth.ts: openLogin() opens widget modal; useAuth() subscribes to widget onAuthChange so login in any tab updates the app instantly. Falls back to hosted login redirect if widget isn't loaded yet. - App.tsx: render Landing page for unauthenticated state instead of hard redirect. Display username; add Logout button to topbar and Forbidden screen. Header uses username || email || sub. - api.ts: throw ApiError(401) on 401 instead of redirecting — App.tsx re-fetches /api/me and shows the landing. - @doc-manager/shared AuthPayload: email is optional now, username and displayName accepted. Backend /api/me returns username. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/routes/me.ts | 2 + apps/web/index.html | 1 + apps/web/src/App.tsx | 84 ++++++++++++++++++++----- apps/web/src/api.ts | 5 +- apps/web/src/auth.ts | 101 +++++++++++++++++++++++------- apps/web/src/pages/Templates.tsx | 4 +- packages/shared/src/auth/types.ts | 5 +- 7 files changed, 161 insertions(+), 41 deletions(-) diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index ab907f2..c76ae94 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -6,7 +6,9 @@ export async function meRoutes(app: FastifyInstance) { const u = req.user!; return { sub: u.sub, + username: u.username, email: u.email, + displayName: u.displayName, groups: u.groups, isSuperuser: u.isSuperuser, docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null, diff --git a/apps/web/index.html b/apps/web/index.html index 872daac..b896235 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,6 +7,7 @@
+ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1847bb2..6bc0c22 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,5 @@ -import { useEffect } from 'react'; import { Link, Route, Routes } from 'react-router-dom'; -import { redirectToLogin, useAuth } from './auth.js'; +import { openLogin, logout, useAuth, type Me } from './auth.js'; import { ClientsPage } from './pages/Clients.js'; import { ServicesPage } from './pages/Services.js'; import { CompaniesPage } from './pages/Companies.js'; @@ -13,7 +12,11 @@ import { ProjectsPage } from './pages/Projects.js'; import { ProjectEditPage } from './pages/ProjectEdit.js'; import { OrgSwitcher } from './components/OrgSwitcher.js'; -function Layout({ email }: { email: string }) { +function displayName(me: Me): string { + return me.username || me.email || me.sub; +} + +function Layout({ me }: { me: Me }) { return (

Doc_manager

@@ -26,7 +29,15 @@ function Layout({ email }: { email: string }) { Компании - {email} + + {displayName(me)}{' '} + +
); } @@ -40,13 +51,59 @@ function Placeholder({ title }: { title: string }) { ); } -function Forbidden({ email }: { email: string }) { +function Landing({ onLogin }: { onLogin: () => void }) { + const wrap: React.CSSProperties = { + minHeight: '100vh', display: 'flex', flexDirection: 'column', + background: '#f7f8fb', fontFamily: 'system-ui, sans-serif', + }; + const header: React.CSSProperties = { + padding: '14px 24px', borderBottom: '1px solid #e5e7eb', background: '#fff', + display: 'flex', alignItems: 'center', gap: 12, + }; + const hero: React.CSSProperties = { + flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', + justifyContent: 'center', padding: 24, textAlign: 'center', + }; + const btn: React.CSSProperties = { + padding: '12px 28px', border: 'none', borderRadius: 10, + background: '#2a6df4', color: '#fff', fontSize: 15, fontWeight: 500, cursor: 'pointer', + }; + return ( +
+
+ Doc_manager + + +
+
+

Документооборот для команды

+

+ Клиенты, услуги, проекты, шаблоны и сами документы — в одном месте, + с шаблонизацией и едиными реквизитами организаций. +

+ +
+
+ doc.queo.ru +
+
+ ); +} + +function Forbidden({ me }: { me: Me }) { return (

Нет доступа

- Аккаунт {email} авторизован в Queo, но не имеет роли в Doc_manager. Попросите - администратора выдать doc_manager permission в auth.queo.ru. + Аккаунт {displayName(me)} авторизован в Queo, но не имеет роли в Doc_manager. + Попросите администратора выдать doc_manager permission в auth.queo.ru. +

+

+

); @@ -55,18 +112,17 @@ function Forbidden({ email }: { email: string }) { export function App() { const auth = useAuth(); - useEffect(() => { - if (auth.status === 'unauthenticated') redirectToLogin(); - }, [auth.status]); - - if (auth.status === 'loading' || auth.status === 'unauthenticated') { + if (auth.status === 'loading') { return
Проверка доступа…
; } - if (auth.status === 'forbidden') return ; + if (auth.status === 'unauthenticated') { + return openLogin(() => auth.refresh())} />; + } + if (auth.status === 'forbidden') return ; return ( <> - + } /> } /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 921d67d..a92723f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,4 @@ -import { redirectToLogin } from './auth.js'; +// auth-related 401 handling lives in App.tsx via useAuth — api.ts just bubbles up. export class ApiError extends Error { constructor(public status: number, public code: string, public details?: unknown) { @@ -82,7 +82,8 @@ async function request(method: string, path: string, body?: unknown): Promise } const res = await fetch(path, init); if (res.status === 401) { - redirectToLogin(); + // Don't redirect — App.tsx will re-fetch /api/me and render the landing. + throw new ApiError(401, 'unauthenticated'); } if (res.status === 204) return undefined as T; const text = await res.text(); diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index b261415..2ac4f70 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -3,7 +3,8 @@ import type { PermissionRole } from '@doc-manager/shared'; export type Me = { sub: string; - email: string; + username?: string; + email?: string; groups: string[]; isSuperuser: boolean; docPermission: PermissionRole | null; @@ -11,10 +12,38 @@ export type Me = { const AUTH_LOGIN_URL = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://auth.queo.ru/login'; -export function redirectToLogin(): never { +type QueoAuthAPI = { + openLogin: (opts: { onSuccess?: (u: unknown) => void; onClose?: () => void; title?: string; subtitle?: string }) => void; + requireLogin: () => Promise; + logout: () => Promise; + me: (opts?: { force?: boolean }) => Promise; + onAuthChange: (cb: (u: unknown) => void) => () => void; + close: () => void; +}; + +declare global { + interface Window { + QueoAuth?: QueoAuthAPI; + } +} + +/** Open the hosted login modal. Falls back to redirect if the widget hasn't loaded. */ +export function openLogin(onSuccess?: () => void): void { + if (typeof window !== 'undefined' && window.QueoAuth) { + window.QueoAuth.openLogin({ onSuccess: () => onSuccess?.() }); + return; + } + // Widget didn't load — fall back to the hosted login page. const returnTo = encodeURIComponent(window.location.href); window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`; - throw new Error('redirecting'); +} + +export async function logout(): Promise { + if (typeof window !== 'undefined' && window.QueoAuth) { + await window.QueoAuth.logout(); + } else { + await fetch('/api/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); + } } export type AuthState = @@ -23,36 +52,64 @@ export type AuthState = | { status: 'unauthenticated' } | { status: 'forbidden'; me: Me }; -export function useAuth(): AuthState { +async function fetchMe(): Promise { + const r = await fetch('/api/me', { credentials: 'include' }); + if (r.status === 401) return null; + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return (await r.json()) as Me; +} + +function toState(me: Me | null): AuthState { + if (!me) return { status: 'unauthenticated' }; + if (me.docPermission == null && !me.isSuperuser) return { status: 'forbidden', me }; + return { status: 'authenticated', me }; +} + +export function useAuth(): AuthState & { refresh: () => Promise } { const [state, setState] = useState({ status: 'loading' }); + const refresh = async () => { + try { + const me = await fetchMe(); + setState(toState(me)); + } catch (e) { + console.error('auth fetch failed', e); + setState({ status: 'unauthenticated' }); + } + }; + useEffect(() => { let cancelled = false; - fetch('/api/me', { credentials: 'include' }) - .then(async (r) => { - if (cancelled) return; - if (r.status === 401) { - setState({ status: 'unauthenticated' }); - return; - } - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const me = (await r.json()) as Me; - // Для M1 любой залогиненный пользователь видит шелл; запрет отдельных страниц — позже. - if (me.docPermission == null && !me.isSuperuser) { - setState({ status: 'forbidden', me }); - return; - } - setState({ status: 'authenticated', me }); - }) + fetchMe() + .then((me) => { if (!cancelled) setState(toState(me)); }) .catch((e) => { if (cancelled) return; console.error('auth fetch failed', e); setState({ status: 'unauthenticated' }); }); + return () => { cancelled = true; }; + }, []); + + // React to login/logout from widget (including other tabs via BroadcastChannel). + useEffect(() => { + const tryAttach = () => { + if (!window.QueoAuth) return null; + return window.QueoAuth.onAuthChange(() => { refresh(); }); + }; + let unsub = tryAttach(); + let pollId: number | undefined; + if (!unsub) { + pollId = window.setInterval(() => { + const u = tryAttach(); + if (u) { unsub = u; window.clearInterval(pollId); } + }, 200); + window.setTimeout(() => { if (pollId) window.clearInterval(pollId); }, 5000); + } return () => { - cancelled = true; + if (pollId) window.clearInterval(pollId); + if (unsub) unsub(); }; }, []); - return state; + return { ...state, refresh } as AuthState & { refresh: () => Promise }; } diff --git a/apps/web/src/pages/Templates.tsx b/apps/web/src/pages/Templates.tsx index 5b96793..bc09084 100644 --- a/apps/web/src/pages/Templates.tsx +++ b/apps/web/src/pages/Templates.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { api, ApiError, type DocBody, type DocType, type DocumentTemplate } from '../api.js'; import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js'; import { emptyRich } from '../lib/richtext.js'; -import { redirectToLogin } from '../auth.js'; +import { openLogin } from '../auth.js'; const DOC_TYPE_LABEL: Record = { contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД', @@ -97,7 +97,7 @@ export function TemplatesPage() { credentials: 'include', body: fd, }); - if (res.status === 401) redirectToLogin(); + if (res.status === 401) { openLogin(); return; } setImportState({ stage: 'analyzing', filename: file.name }); const text = await res.text(); const data = text ? JSON.parse(text) : {}; diff --git a/packages/shared/src/auth/types.ts b/packages/shared/src/auth/types.ts index bf21bab..d810dc6 100644 --- a/packages/shared/src/auth/types.ts +++ b/packages/shared/src/auth/types.ts @@ -7,12 +7,15 @@ export type PermissionRole = z.infer; export const AuthPayload = z.object({ sub: z.string().uuid(), - email: z.string().email(), + username: z.string().optional(), + email: z.string().email().optional(), + displayName: z.string().optional(), groups: z.array(z.string()).default([]), permissions: z.record(PermissionRole).default({}), isSuperuser: z.boolean().default(false), iat: z.number().optional(), exp: z.number().optional(), + jti: z.string().optional(), iss: z.literal('https://auth.queo.ru').optional(), aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(), });