feat: multi tenant support
This commit is contained in:
parent
a660c591ff
commit
30e3391adb
17 changed files with 271 additions and 145 deletions
|
|
@ -19,6 +19,9 @@ import type {
|
|||
} from '../types'
|
||||
import { publishDomainEvent } from './domainEvents'
|
||||
|
||||
let activeTenant: string | null = null;
|
||||
|
||||
|
||||
const TOKEN_KEY = 'go-guess-token'
|
||||
const USER_KEY = 'go-guess-user'
|
||||
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
|
||||
|
|
@ -71,6 +74,12 @@ async function request<T>(
|
|||
options: RequestInit = {},
|
||||
protectedRequest = true,
|
||||
): Promise<T> {
|
||||
let activeTenant: string | undefined
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length > 1) {
|
||||
activeTenant = parts[0];
|
||||
}
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
|
|
@ -78,6 +87,7 @@ async function request<T>(
|
|||
Accept: 'application/json',
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
|
@ -106,9 +116,16 @@ async function request<T>(
|
|||
|
||||
async function requestBlob(path: string): Promise<Blob> {
|
||||
const token = authStorage.token()
|
||||
let activeTenant: string | undefined
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length > 1) {
|
||||
activeTenant = parts[0];
|
||||
}
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
|
|
@ -181,12 +198,16 @@ async function subscribeToInterviewEvents(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLiveTenant(): string | null {
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
return parts.length > 1 ? parts[0] : null;
|
||||
}
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<AuthResponse>(
|
||||
'/auth/login',
|
||||
{ method: 'POST', body: JSON.stringify({ email, password }) },
|
||||
{ method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} },
|
||||
false,
|
||||
),
|
||||
jobs: {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,30 @@
|
|||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { useAuthSession } from '../components/actions'
|
||||
import { NotFound } from '../components/ui/NotFound'
|
||||
import { ParticipantInterviewPage } from '../features/assessments'
|
||||
import { ParticipantMeetingPage } from '../features/interviews'
|
||||
import { LoginPage } from '../features/login/LoginPage'
|
||||
import { AuthenticatedApp } from './AuthenticatedApp'
|
||||
import { DataProvider } from './DataContext'
|
||||
import {Route, Routes, useLocation} from 'react-router-dom'
|
||||
import {useAuthSession} from '../components/actions'
|
||||
import {NotFound} from '../components/ui/NotFound'
|
||||
import {ParticipantInterviewPage} from '../features/assessments'
|
||||
import {ParticipantMeetingPage} from '../features/interviews'
|
||||
import {LoginPage} from '../features/login/LoginPage'
|
||||
import {AuthenticatedApp} from './AuthenticatedApp'
|
||||
import {DataProvider} from './DataContext'
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation()
|
||||
const { authenticated, sessionExpired, completeLogin, logout } = useAuthSession()
|
||||
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
const location = useLocation();
|
||||
const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<DataProvider>
|
||||
<AuthenticatedApp onLogout={logout}/>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired} />
|
||||
}
|
||||
return (
|
||||
<DataProvider>
|
||||
<AuthenticatedApp onLogout={logout} />
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
33
frontend/src/app/ApplicationRoutes.tsx
Normal file
33
frontend/src/app/ApplicationRoutes.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import {NotFound} from "../components/ui/NotFound";
|
||||
import {Route, Routes} from "react-router-dom";
|
||||
import {InvitationPage, ScheduledInterviewPage} from "../features/interviews";
|
||||
import {ProfilePage, SchedulePage, UsersPage} from "../features/users";
|
||||
import {CreateParticipantPage, ParticipantDetailPage, ParticipantsPage} from "../features/participants";
|
||||
import {QuestionLibraryPage, QuestionPage} from "../features/questions";
|
||||
import {CreateJobPage, JobDetailPage, JobsPage} from "../features/jobs";
|
||||
import {DashboardPage} from "../features/dashboard/DashboardPage";
|
||||
|
||||
export default function ApplicationRoutes() {
|
||||
return (
|
||||
<div className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<DashboardPage/>}/>
|
||||
<Route path="/jobs" element={<JobsPage/>}/>
|
||||
<Route path="/jobs/new" element={<CreateJobPage/>}/>
|
||||
<Route path="/jobs/:jobId" element={<JobDetailPage/>}/>
|
||||
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage/>}/>
|
||||
<Route path="/questions" element={<QuestionLibraryPage/>}/>
|
||||
<Route path="/participants" element={<ParticipantsPage/>}/>
|
||||
<Route path="/participants/new" element={<CreateParticipantPage/>}/>
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage/>}/>
|
||||
<Route path="/users" element={<UsersPage/>}/>
|
||||
<Route path="/profile" element={<ProfilePage/>}/>
|
||||
<Route path="/invitations" element={<InvitationPage/>}/>
|
||||
<Route path="/schedule" element={<SchedulePage/>}/>
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +1,22 @@
|
|||
import { useState } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { authStorage } from '../api/client'
|
||||
import { AppFooter } from '../components/layout/AppFooter'
|
||||
import { GoGuessHeader } from '../components/layout/GoGuessHeader'
|
||||
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
|
||||
import { NotFound } from '../components/ui/NotFound'
|
||||
import { DashboardPage } from '../features/dashboard/DashboardPage'
|
||||
import { InvitationPage, ScheduledInterviewPage } from '../features/interviews'
|
||||
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
|
||||
import {
|
||||
CreateParticipantPage,
|
||||
ParticipantDetailPage,
|
||||
ParticipantsPage,
|
||||
} from '../features/participants'
|
||||
import { QuestionLibraryPage, QuestionPage } from '../features/questions'
|
||||
import { SchedulePage, ProfilePage, UsersPage } from '../features/users'
|
||||
import {useState} from 'react'
|
||||
import {authStorage} from '../api/client'
|
||||
import {AppFooter} from '../components/layout/AppFooter'
|
||||
import {GoGuessHeader} from '../components/layout/GoGuessHeader'
|
||||
import {WorkspaceUtilities} from '../components/layout/WorkspaceUtilities'
|
||||
import ApplicationRoutes from "./ApplicationRoutes";
|
||||
|
||||
export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const user = authStorage.user()
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout} />
|
||||
<main className="main-content">
|
||||
<WorkspaceUtilities user={user} />
|
||||
<div className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/jobs/new" element={<CreateJobPage />} />
|
||||
<Route path="/jobs/:jobId" element={<JobDetailPage />} />
|
||||
<Route path="/jobs/:jobId/questions/new" element={<QuestionPage />} />
|
||||
<Route path="/questions" element={<QuestionLibraryPage />} />
|
||||
<Route path="/participants" element={<ParticipantsPage />} />
|
||||
<Route path="/participants/new" element={<CreateParticipantPage />} />
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/invitations" element={<InvitationPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const user = authStorage.user()
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
|
||||
<main className="main-content">
|
||||
<WorkspaceUtilities user={user}/>
|
||||
<ApplicationRoutes/>
|
||||
<AppFooter/>
|
||||
</main>
|
||||
</div>
|
||||
<AppFooter />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client'
|
||||
import {api} from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { errorMessage } from '../../components/actions'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
|
|
|
|||
|
|
@ -516,6 +516,7 @@ function InvitationsView({
|
|||
}
|
||||
|
||||
async function reviewAnswers(invitationId: number) {
|
||||
setReview(undefined)
|
||||
setReviewingId(invitationId)
|
||||
setError('')
|
||||
try {
|
||||
|
|
@ -681,7 +682,7 @@ function InvitationAnswerReview({
|
|||
<h2 id="answer-review-title">{current.invitation.participantName}</h2>
|
||||
<p>{current.invitation.participantEmail}</p>
|
||||
</div>
|
||||
{current.invitation.status === 'completed' && (
|
||||
{current.invitation.outcome !== 'passed' && (
|
||||
<div className="outcome-actions" aria-label="Candidate outcome">
|
||||
<div>
|
||||
<strong>Assessment decision</strong>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import {StrictMode} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import {BrowserRouter} from 'react-router-dom'
|
||||
import App from './app/App'
|
||||
import './styles/global.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App/>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -498,7 +498,7 @@ button {
|
|||
.main-content {
|
||||
margin-left: 255px;
|
||||
padding: clamp(1rem, 2vw, 1.8rem) clamp(1.2rem, 4vw, 4.8rem) 0;
|
||||
max-width: 1550px;
|
||||
/*max-width: 1550px;*/
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue