feat: added a login page with Authentik authentication
This commit is contained in:
@@ -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') : []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user