more multi-tenancy
This commit is contained in:
+208
-51
@@ -60,6 +60,7 @@ const initialAppState = {
|
||||
token: STORED_TOKEN,
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
};
|
||||
|
||||
const AppStateContext = React.createContext(null);
|
||||
@@ -68,11 +69,42 @@ const AppDispatchContext = React.createContext(null);
|
||||
const appStateReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN_REQUEST':
|
||||
return { ...state, status: 'authenticating', error: null };
|
||||
return { ...state, status: 'authenticating', error: null, tenantSelection: null };
|
||||
case 'LOGIN_SUCCESS':
|
||||
return { ...state, status: 'authenticated', token: action.token, error: null };
|
||||
return {
|
||||
...state,
|
||||
status: 'authenticated',
|
||||
token: action.token,
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false };
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
};
|
||||
case 'TENANT_SELECTION_REQUIRED':
|
||||
return {
|
||||
status: 'selecting-tenant',
|
||||
token: '',
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: {
|
||||
selectionToken: action.selectionToken,
|
||||
tenants: action.tenants,
|
||||
},
|
||||
};
|
||||
case 'CLEAR_TENANT_SELECTION':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
};
|
||||
case 'BOOTSTRAP_START':
|
||||
return { ...state, status: 'bootstrapping', error: null };
|
||||
case 'BOOTSTRAP_SUCCESS':
|
||||
@@ -87,11 +119,18 @@ const appStateReducer = (state, action) => {
|
||||
token: action.token,
|
||||
isRefreshing: false,
|
||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||
tenantSelection: null,
|
||||
};
|
||||
case 'TOKEN_REFRESH_FAILURE':
|
||||
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false };
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return { status: 'logged-out', token: '', error: null, isRefreshing: false };
|
||||
return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null };
|
||||
case 'RESET_ERROR':
|
||||
return { ...state, error: null };
|
||||
default:
|
||||
@@ -194,35 +233,79 @@ const DropOverlay = ({ active, folderName }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoginView = ({ onSubmit, status }) => (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Papercrate</h1>
|
||||
<p>Authenticate to manage your documents.</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<StatusBanner status={status} />
|
||||
const LoginView = ({
|
||||
onSubmit,
|
||||
status,
|
||||
tenantSelection,
|
||||
onSelectTenant,
|
||||
onCancelSelection,
|
||||
selectingTenantId,
|
||||
}) => {
|
||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Papercrate</h1>
|
||||
{hasTenantSelection ? (
|
||||
<div className="login-card__selection">
|
||||
<p>Select a tenant to finish signing in.</p>
|
||||
<div className="login-card__tenant-list">
|
||||
{tenantSelection.tenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.tenant_id}
|
||||
type="button"
|
||||
onClick={() => onSelectTenant?.(tenant)}
|
||||
disabled={Boolean(selectingTenantId)}
|
||||
className={
|
||||
selectingTenantId === tenant.tenant_id
|
||||
? 'login-card__tenant-button is-loading'
|
||||
: 'login-card__tenant-button'
|
||||
}
|
||||
>
|
||||
{tenant.slug}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__back-button"
|
||||
onClick={() => onCancelSelection?.()}
|
||||
disabled={Boolean(selectingTenantId)}
|
||||
>
|
||||
Use a different account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>Authenticate to manage your documents.</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<StatusBanner status={status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -603,7 +686,7 @@ const AppLayout = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (appStatus === 'logged-out') {
|
||||
if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
|
||||
resetWorkspaceState();
|
||||
}
|
||||
}, [appStatus, resetWorkspaceState]);
|
||||
@@ -3946,19 +4029,32 @@ const AppLayout = () => {
|
||||
setLoading(true);
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
appDispatch({
|
||||
type: 'LOGIN_FAILURE',
|
||||
error: error?.response?.data?.error || 'Login failed. Check credentials.',
|
||||
});
|
||||
notifyApiError(error, 'Login failed. Check credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyApiError, setStatusMessage],
|
||||
|
||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
@@ -4576,7 +4672,7 @@ const AppLayout = () => {
|
||||
],
|
||||
);
|
||||
|
||||
if (appStatus === 'logged-out' || appStatus === 'authenticating') {
|
||||
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/account/login"
|
||||
@@ -4755,10 +4851,11 @@ const DocumentsRoute = () => {
|
||||
};
|
||||
|
||||
const LoginRoute = () => {
|
||||
const { status: appStatus } = useAppState();
|
||||
const { status: appStatus, tenantSelection } = useAppState();
|
||||
const appDispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
@@ -4793,6 +4890,20 @@ const LoginRoute = () => {
|
||||
try {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
if (data?.selection_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.selection_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
@@ -4804,6 +4915,45 @@ const LoginRoute = () => {
|
||||
[appDispatch, notifyLoginError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenant) => {
|
||||
if (!tenantSelection?.selectionToken || !tenant?.tenant_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSelectingTenantId(tenant.tenant_id);
|
||||
const { data } = await api.post(
|
||||
'/auth/select-tenant',
|
||||
{ tenant_id: tenant.tenant_id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tenantSelection.selectionToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid tenant selection response.');
|
||||
}
|
||||
|
||||
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
||||
notifyLoginError(error, message, 'error');
|
||||
} finally {
|
||||
setSelectingTenantId(null);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyLoginError, setStatusMessage, tenantSelection],
|
||||
);
|
||||
|
||||
const handleCancelSelection = useCallback(() => {
|
||||
appDispatch({ type: 'CLEAR_TENANT_SELECTION' });
|
||||
setStatusMessage(null);
|
||||
}, [appDispatch, setStatusMessage]);
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const target = location.state?.from;
|
||||
if (typeof target === 'string' && target.startsWith('/')) {
|
||||
@@ -4812,13 +4962,20 @@ const LoginRoute = () => {
|
||||
return '/documents';
|
||||
}, [location.state]);
|
||||
|
||||
if (appStatus !== 'logged-out' && appStatus !== 'authenticating') {
|
||||
if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||
return <Navigate to={redirectTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<LoginView onSubmit={handleLogin} status={status} />
|
||||
<LoginView
|
||||
onSubmit={handleLogin}
|
||||
status={status}
|
||||
tenantSelection={tenantSelection}
|
||||
onSelectTenant={handleTenantSelect}
|
||||
onCancelSelection={handleCancelSelection}
|
||||
selectingTenantId={selectingTenantId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2050,6 +2050,68 @@ form.inline {
|
||||
.login-card .status-banner {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-card__selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.login-card__tenant-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.login-card__tenant-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.login-card__tenant-button:hover:not([disabled]) {
|
||||
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
|
||||
border-color: var(--border-strong, var(--border));
|
||||
}
|
||||
|
||||
.login-card__tenant-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.login-card__tenant-button.is-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-card__back-button {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-card__back-button:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-card__back-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.column-toolbar .filter-bar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user