feat: AYON single-sign-on (ticket exchange, task boards, browser entry)

- POST /api/auth/ayon/exchange: redeem single-use ticket (issued by the
  AYON addon) via AYON_EXCHANGE_URL, mint session JWT, get-or-create
  internal user row and the <project>/<task> board in the AYON collection
- db: getOrCreateAyonUser / getOrCreateAyonBoard / grantAyonCollectionAccess
- frontend: /b route redeems ticket from URL and forwards to the board
- password login/register paths untouched (legacy instance support)
This commit is contained in:
Hermes
2026-09-04 13:40:33 +00:00
parent 33d3109136
commit 5a4cfcbf64
4 changed files with 243 additions and 3 deletions
+2
View File
@@ -6,6 +6,7 @@ import CollectionList from './pages/CollectionList';
import CollectionDetail from './pages/CollectionDetail';
import Editor from './pages/Editor';
import Admin from './pages/Admin';
import AyonEntry from './pages/AyonEntry';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
@@ -32,6 +33,7 @@ function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/b" element={<AyonEntry />} />
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../auth';
import api from '../api';
/**
* AYON entry point: /b?ticket=…&project=…&task=…
* Redeems the single-use ticket issued by the AYON addon, stores the
* session token and forwards to the task board. No login form involved.
*/
export default function AyonEntry() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { login } = useAuth();
const [error, setError] = useState('');
useEffect(() => {
const ticket = params.get('ticket');
if (!ticket) {
setError('No ticket provided. Open RefBoard from the AYON launcher.');
return;
}
api.post('/api/auth/ayon/exchange', { ticket })
.then((res) => {
if (res.data?.token && res.data?.user) {
login(res.data.token, res.data.user);
navigate(res.data.board_url || '/', { replace: true });
} else {
setError('Unexpected response from server.');
}
})
.catch((err) => {
setError(err?.response?.data?.error || 'Ticket exchange failed.');
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const style: React.CSSProperties = {
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: '#1a1a1a', color: '#e0e0e0', gap: '12px',
};
return (
<div style={style}>
{error ? (
<>
<div style={{ fontSize: '18px' }}>{error}</div>
<a href="/login" style={{ color: '#8ab4f8' }}>Go to login</a>
</>
) : (
<div style={{ fontSize: '18px' }}>Signing you in via AYON</div>
)}
</div>
);
}