feat: added a login page with Authentik authentication

This commit is contained in:
Zakaria
2026-10-16 19:35:27 -04:00
parent 668911ed6e
commit 924e354788
18 changed files with 441 additions and 54 deletions
+14 -6
View File
@@ -1217,6 +1217,13 @@ export function createApiClient(
return { url, init };
});
client.addRequestInterceptor((url, init) => {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(init.method ?? 'GET')) return {url, init};
const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1];
if (csrf) (init.headers as Record<string, string>)['X-Floci-CSRF'] = decodeURIComponent(csrf);
return {url, init};
});
// Auth interceptor — attaches Bearer token if available
if (getToken) {
client.addRequestInterceptor(async (url, init) => {
@@ -1237,12 +1244,13 @@ export function createApiClient(
});
}
// client.addResponseInterceptor((res) => {
// if (res.status === 401) {
// handleUnauthorizedApiResponse();
// }
// return res;
// });
client.addResponseInterceptor((res) => {
if (res.status === 401 && !res.url.includes('/api/auth/')) {
const returnTo = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
return res;
});
return client;
}
@@ -40,7 +40,7 @@ export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserPro
{imagesQuery.isLoading && <div className="muted padded">Loading images</div>}
{imagesQuery.isSuccess && images.length === 0 && <div className="empty compact"><h3>No images</h3><p>Push an image to this repository to see it here.</p></div>}
{images.length > 0 && <div className="cosmos-items-table">
<table>
<table className="table ecr-images-table">
<thead><tr><th>Tags</th><th>Digest</th><th>Pushed</th><th>Size</th><th/></tr></thead>
<tbody>{images.map((image) => {
const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : []
+10 -1
View File
@@ -1,5 +1,5 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, Moon, Search, Sun} from 'lucide-react'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react'
import theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme'
@@ -220,6 +220,9 @@ export function Layout() {
</button>
<div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/>
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
<LogOut size={14}/>
</button>
<div className={`connection ${isConnected ? 'connected' : 'disconnected'}`}>
<span className={connectionDotClass(status)} aria-hidden="true"/>
<span className="connection-state">{connectionLabel}</span>
@@ -234,6 +237,12 @@ export function Layout() {
)
}
async function signOut(): Promise<void> {
const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1]
await fetch('/api/auth/logout', {method: 'POST', headers: csrf ? {'X-Floci-CSRF': decodeURIComponent(csrf)} : {}})
window.location.assign('/login')
}
/**
* Topbar search bar.
*
@@ -98,3 +98,23 @@ test('shows an object extension instead of a generic object type', async () => {
expect(await screen.findByText('JSON file')).toBeInTheDocument()
})
test('opens the object action with copy selected', async () => {
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
}]})
apiMocks.listCloudResources.mockResolvedValue([bucket])
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['copy']} onSelectObject={() => {}}/>
</QueryClientProvider>,
)
await user.click(await screen.findByRole('button', {name: 'Copy or move order.json'}))
const copyTab = screen.getAllByRole('button', {name: 'Copy'}).find((button) => button.classList.contains('drawer-tab'))
expect(copyTab).toHaveClass('active')
})
@@ -524,7 +524,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
</a>
)}
{copyCapability && (
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Move / Copy ${object.name}`} onClick={() => setCopyObject(object)}>
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Copy or move ${object.name}`} aria-label={`Copy or move ${object.name}`} onClick={() => setCopyObject(object)}>
<Copy size={13}/>
</button>
)}
@@ -606,7 +606,7 @@ function MoveOrCopyModal({
}) {
const qc = useQueryClient()
const resourceLabel = cloud === 'azure' ? 'container' : 'bucket'
const [mode, setMode] = useState<'move' | 'copy'>('move')
const [mode, setMode] = useState<'move' | 'copy'>('copy')
const [destKey, setDestKey] = useState(() => {
const parts = srcObject.key.split('/')
const filename = parts.pop() ?? srcObject.key
+46 -38
View File
@@ -1,55 +1,55 @@
:root {
color-scheme: dark;
/* ── AWS dark palette ── */
--bg: #0f141a;
--surface: #161d26;
--elevated: #1b232d;
--raised: #232b37;
--raised-hover: #333843;
--border: #424650;
--border-faint: rgba(101, 104, 113, 0.55);
--border-lighter: rgba(101, 104, 113, 0.3);
--border-hover: #75cfff;
--active-nav-bg: #001129;
/* ── TheCloud dark palette ── */
--bg: #07111d;
--surface: #0c1a2b;
--elevated: #0d1c2f;
--raised: #10243a;
--raised-hover: #16304b;
--border: rgba(232, 241, 250, 0.16);
--border-faint: rgba(232, 241, 250, 0.1);
--border-lighter: rgba(232, 241, 250, 0.06);
--border-hover: #6cc4ff;
--active-nav-bg: #0d2943;
--text: #c6c6cd;
--text-bright: #f9f9fa;
--text-2: #b4b4bb;
--text-3: #a4a4ad;
--text: #bdd0e3;
--text-bright: #e8f1fa;
--text-2: #c9d9e8;
--text-3: #8aa3bc;
--text-muted: var(--text-3);
--text-nav: #c6c6cd;
--status-ok: #2bb534;
--status-warn: #fbd332;
--status-error: #ff7a7a;
--status-ok: #5ce0b0;
--status-warn: #ffb547;
--status-error: #ff9a9a;
--accent: #42b4ff;
--accent-tint: rgba(66, 180, 255, 0.16);
--accent-tint-faint: rgba(66, 180, 255, 0.07);
--accent-tint-border: rgba(66, 180, 255, 0.35);
--accent: #6cc4ff;
--accent-tint: rgba(108, 196, 255, 0.16);
--accent-tint-faint: rgba(108, 196, 255, 0.07);
--accent-tint-border: rgba(108, 196, 255, 0.35);
--link: #42b4ff;
--link-tint: rgba(66, 180, 255, 0.08);
--link-tint-hover: rgba(66, 180, 255, 0.14);
--link-tint-border: rgba(66, 180, 255, 0.3);
--link: #6cc4ff;
--link-tint: rgba(108, 196, 255, 0.08);
--link-tint-hover: rgba(108, 196, 255, 0.14);
--link-tint-border: rgba(108, 196, 255, 0.3);
--hover: rgba(255, 255, 255, 0.04);
--hover-md: rgba(255, 255, 255, 0.06);
--hover-lg: rgba(255, 255, 255, 0.07);
/* 3:1 vs raised so inputs/buttons meet non-text contrast. */
--input-border: #656871;
--input-bg: #161d26;
--connection-bg: #131920;
--kbd-border: #424650;
--scrollbar-thumb: #656871;
--service-card-bg: #161d26;
--service-card-hover: #1b232d;
--fn-card-hover: #232b37;
--bulk-bar-bg: #001129;
--bulk-bar-border: #42b4ff;
--bulk-bar-text: #f9f9fa;
--code-bg: #282c34;
--input-border: rgba(232, 241, 250, 0.28);
--input-bg: #0c1a2b;
--connection-bg: #091624;
--kbd-border: rgba(232, 241, 250, 0.2);
--scrollbar-thumb: #48627c;
--service-card-bg: #0c1a2b;
--service-card-hover: #0d1c2f;
--fn-card-hover: #10243a;
--bulk-bar-bg: #0d2943;
--bulk-bar-border: #6cc4ff;
--bulk-bar-text: #e8f1fa;
--code-bg: #050d17;
/* Workflow Studio: keep canvas surfaces readable independently of state accents. */
--workflow-save-border: #4e628f;
@@ -2300,6 +2300,14 @@ pre,
background: var(--link-tint);
}
.ecr-images-table { min-width: 680px; }
.ecr-images-table th, .ecr-images-table td { padding: 13px 16px; vertical-align: middle; }
.ecr-images-table th:nth-child(1) { width: 22%; }
.ecr-images-table th:nth-child(2) { width: 27%; }
.ecr-images-table th:nth-child(3) { width: 31%; }
.ecr-images-table th:nth-child(4) { width: 14%; }
.ecr-images-table th:last-child { width: 48px; }
.logs-query-panel {
display: grid;
margin-top: 12px;