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!;
|
const u = req.user!;
|
||||||
return {
|
return {
|
||||||
sub: u.sub,
|
sub: u.sub,
|
||||||
|
username: u.username,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
groups: u.groups,
|
groups: u.groups,
|
||||||
isSuperuser: u.isSuperuser,
|
isSuperuser: u.isSuperuser,
|
||||||
docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null,
|
docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script src="https://auth.queo.ru/widget.js" defer></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+70
-14
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Route, Routes } from 'react-router-dom';
|
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 { ClientsPage } from './pages/Clients.js';
|
||||||
import { ServicesPage } from './pages/Services.js';
|
import { ServicesPage } from './pages/Services.js';
|
||||||
import { CompaniesPage } from './pages/Companies.js';
|
import { CompaniesPage } from './pages/Companies.js';
|
||||||
@@ -13,7 +12,11 @@ import { ProjectsPage } from './pages/Projects.js';
|
|||||||
import { ProjectEditPage } from './pages/ProjectEdit.js';
|
import { ProjectEditPage } from './pages/ProjectEdit.js';
|
||||||
import { OrgSwitcher } from './components/OrgSwitcher.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 (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<h1>Doc_manager</h1>
|
<h1>Doc_manager</h1>
|
||||||
@@ -26,7 +29,15 @@ function Layout({ email }: { email: string }) {
|
|||||||
<Link to="/companies">Компании</Link>
|
<Link to="/companies">Компании</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<OrgSwitcher />
|
<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>
|
</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 (
|
return (
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<h2>Нет доступа</h2>
|
<h2>Нет доступа</h2>
|
||||||
<p>
|
<p>
|
||||||
Аккаунт <b>{email}</b> авторизован в Queo, но не имеет роли в Doc_manager. Попросите
|
Аккаунт <b>{displayName(me)}</b> авторизован в Queo, но не имеет роли в Doc_manager.
|
||||||
администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
|
Попросите администратора выдать <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>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -55,18 +112,17 @@ function Forbidden({ email }: { email: string }) {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
if (auth.status === 'loading') {
|
||||||
if (auth.status === 'unauthenticated') redirectToLogin();
|
|
||||||
}, [auth.status]);
|
|
||||||
|
|
||||||
if (auth.status === 'loading' || auth.status === 'unauthenticated') {
|
|
||||||
return <div className="loading">Проверка доступа…</div>;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Layout email={auth.me.email} />
|
<Layout me={auth.me} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DocumentsPage />} />
|
<Route path="/" element={<DocumentsPage />} />
|
||||||
<Route path="/documents" 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 {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, public code: string, public details?: unknown) {
|
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);
|
const res = await fetch(path, init);
|
||||||
if (res.status === 401) {
|
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;
|
if (res.status === 204) return undefined as T;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|||||||
+79
-22
@@ -3,7 +3,8 @@ import type { PermissionRole } from '@doc-manager/shared';
|
|||||||
|
|
||||||
export type Me = {
|
export type Me = {
|
||||||
sub: string;
|
sub: string;
|
||||||
email: string;
|
username?: string;
|
||||||
|
email?: string;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
isSuperuser: boolean;
|
isSuperuser: boolean;
|
||||||
docPermission: PermissionRole | null;
|
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';
|
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);
|
const returnTo = encodeURIComponent(window.location.href);
|
||||||
window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`;
|
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 =
|
export type AuthState =
|
||||||
@@ -23,36 +52,64 @@ export type AuthState =
|
|||||||
| { status: 'unauthenticated' }
|
| { status: 'unauthenticated' }
|
||||||
| { status: 'forbidden'; me: Me };
|
| { 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 [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch('/api/me', { credentials: 'include' })
|
fetchMe()
|
||||||
.then(async (r) => {
|
.then((me) => { if (!cancelled) setState(toState(me)); })
|
||||||
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 });
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.error('auth fetch failed', e);
|
console.error('auth fetch failed', e);
|
||||||
setState({ status: 'unauthenticated' });
|
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 () => {
|
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 { api, ApiError, type DocBody, type DocType, type DocumentTemplate } from '../api.js';
|
||||||
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
|
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
|
||||||
import { emptyRich } from '../lib/richtext.js';
|
import { emptyRich } from '../lib/richtext.js';
|
||||||
import { redirectToLogin } from '../auth.js';
|
import { openLogin } from '../auth.js';
|
||||||
|
|
||||||
const DOC_TYPE_LABEL: Record<DocType, string> = {
|
const DOC_TYPE_LABEL: Record<DocType, string> = {
|
||||||
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
|
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
|
||||||
@@ -97,7 +97,7 @@ export function TemplatesPage() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: fd,
|
body: fd,
|
||||||
});
|
});
|
||||||
if (res.status === 401) redirectToLogin();
|
if (res.status === 401) { openLogin(); return; }
|
||||||
setImportState({ stage: 'analyzing', filename: file.name });
|
setImportState({ stage: 'analyzing', filename: file.name });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const data = text ? JSON.parse(text) : {};
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ export type PermissionRole = z.infer<typeof PermissionRole>;
|
|||||||
|
|
||||||
export const AuthPayload = z.object({
|
export const AuthPayload = z.object({
|
||||||
sub: z.string().uuid(),
|
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([]),
|
groups: z.array(z.string()).default([]),
|
||||||
permissions: z.record(PermissionRole).default({}),
|
permissions: z.record(PermissionRole).default({}),
|
||||||
isSuperuser: z.boolean().default(false),
|
isSuperuser: z.boolean().default(false),
|
||||||
iat: z.number().optional(),
|
iat: z.number().optional(),
|
||||||
exp: z.number().optional(),
|
exp: z.number().optional(),
|
||||||
|
jti: z.string().optional(),
|
||||||
iss: z.literal('https://auth.queo.ru').optional(),
|
iss: z.literal('https://auth.queo.ru').optional(),
|
||||||
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
|
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user