more multi-tenancy

This commit is contained in:
2025-10-23 16:06:56 +02:00
parent e175d28c2c
commit 0ad79c9bc1
35 changed files with 1229 additions and 534 deletions
+208 -51
View File
@@ -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>
);
};
+62
View File
@@ -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;
}