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:
Claude
2026-05-30 16:13:20 +03:00
parent 5e57fdfd11
commit 0c6deed98d
7 changed files with 161 additions and 41 deletions
+2
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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> };
}
+2 -2
View File
@@ -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) : {};
+4 -1
View File
@@ -7,12 +7,15 @@ export type PermissionRole = z.infer<typeof PermissionRole>;
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(),
});