feat: switch login to hosted QueoAuth widget; tolerate username-only users
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="https://auth.queo.ru/widget.js" defer></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+70
-14
@@ -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 (
|
||||
<header className="topbar">
|
||||
<h1>Doc_manager</h1>
|
||||
@@ -26,7 +29,15 @@ function Layout({ email }: { email: string }) {
|
||||
<Link to="/companies">Компании</Link>
|
||||
</nav>
|
||||
<OrgSwitcher />
|
||||
<span className="user">{email}</span>
|
||||
<span className="user">
|
||||
{displayName(me)}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="logout-btn"
|
||||
onClick={() => { logout().then(() => window.location.reload()); }}
|
||||
style={{ marginLeft: 8, padding: '2px 10px', borderRadius: 6, border: '1px solid #ccc', background: 'transparent', cursor: 'pointer', fontSize: 12 }}
|
||||
>Выйти</button>
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={wrap}>
|
||||
<header style={header}>
|
||||
<strong>Doc_manager</strong>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button style={btn} onClick={onLogin}>Войти</button>
|
||||
</header>
|
||||
<section style={hero}>
|
||||
<h1 style={{ fontSize: 38, margin: '0 0 12px' }}>Документооборот для команды</h1>
|
||||
<p style={{ fontSize: 17, color: '#555', maxWidth: 620, lineHeight: 1.5, margin: '0 0 28px' }}>
|
||||
Клиенты, услуги, проекты, шаблоны и сами документы — в одном месте,
|
||||
с шаблонизацией и едиными реквизитами организаций.
|
||||
</p>
|
||||
<button style={btn} onClick={onLogin}>Войти в систему</button>
|
||||
</section>
|
||||
<footer style={{ padding: 20, textAlign: 'center', color: '#888', fontSize: 12, borderTop: '1px solid #e5e7eb', background: '#fff' }}>
|
||||
doc.queo.ru
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Forbidden({ me }: { me: Me }) {
|
||||
return (
|
||||
<main className="content">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>
|
||||
Аккаунт <b>{email}</b> авторизован в Queo, но не имеет роли в Doc_manager. Попросите
|
||||
администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
|
||||
Аккаунт <b>{displayName(me)}</b> авторизован в Queo, но не имеет роли в Doc_manager.
|
||||
Попросите администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { logout().then(() => window.location.reload()); }}
|
||||
style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid #2a6df4', background: 'transparent', color: '#2a6df4', cursor: 'pointer' }}
|
||||
>Войти под другим пользователем</button>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
@@ -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 <div className="loading">Проверка доступа…</div>;
|
||||
}
|
||||
if (auth.status === 'forbidden') return <Forbidden email={auth.me.email} />;
|
||||
if (auth.status === 'unauthenticated') {
|
||||
return <Landing onLogin={() => openLogin(() => auth.refresh())} />;
|
||||
}
|
||||
if (auth.status === 'forbidden') return <Forbidden me={auth.me} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout email={auth.me.email} />
|
||||
<Layout me={auth.me} />
|
||||
<Routes>
|
||||
<Route path="/" element={<DocumentsPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
|
||||
+3
-2
@@ -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<T>(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();
|
||||
|
||||
+79
-22
@@ -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<unknown | null>;
|
||||
logout: () => Promise<unknown>;
|
||||
me: (opts?: { force?: boolean }) => Promise<unknown | null>;
|
||||
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<void> {
|
||||
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<Me | null> {
|
||||
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<void> } {
|
||||
const [state, setState] = useState<AuthState>({ 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<void> };
|
||||
}
|
||||
|
||||
@@ -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<DocType, string> = {
|
||||
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) : {};
|
||||
|
||||
Reference in New Issue
Block a user