Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions create-a-container/client/src/app/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@ import {
Box,
Building2,
Container as ContainerIcon,
ExternalLink,
Globe,
KeyRound,
Server,
Settings,
ShieldCheck,
Users,
UsersRound,
} from 'lucide-react';
Expand Down Expand Up @@ -68,8 +66,6 @@ export function AppSidebar() {
const { data: session } = useSession();
const { isCollapsed, isMobileViewport } = useSidebar();
const isAdmin = !!session?.isAdmin;
const mfaAdminUrl =
isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null;

const siteMatch = location.pathname.match(/^\/sites\/(\d+)(?:\/|$)/);
const urlSiteId = siteMatch ? siteMatch[1] : null;
Expand Down Expand Up @@ -188,16 +184,6 @@ export function AppSidebar() {
)}
<SidebarNav className="mt-2">
{ADMIN.filter((l) => !l.adminOnly || isAdmin).map(renderLink)}
{mfaAdminUrl && (
<SidebarNavItem
key="mfa-admin"
label="MFA Admin"
icon={<ShieldCheck className="size-4" />}
badge={compact ? undefined : <ExternalLink className="size-3" aria-hidden="true" />}
isActive={false}
onClick={() => window.open(mfaAdminUrl, '_blank', 'noopener,noreferrer')}
/>
)}
</SidebarNav>
</SidebarContent>
<SidebarFooter className="border-t border-neutral-200 p-2 dark:border-neutral-700">
Expand Down
52 changes: 26 additions & 26 deletions create-a-container/client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { api, ApiError, clearCsrfToken } from './api';
export interface SessionUser {
user: string;
isAdmin: boolean;
/** Configured push-notification service URL (admins only, empty if unset). */
pushNotificationUrl?: string;
}

export interface ServerInfo {
status: string;
isDev: boolean;
/** True when an OIDC identity provider is configured for SSO. */
oidcEnabled: boolean;
}

export const sessionKey = ['session'] as const;
Expand Down Expand Up @@ -47,25 +47,19 @@ export interface LoginInput {
}

export type LoginResult =
| { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string }
| { kind: '2fa'; challengeId: string };
| { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string };

interface LoginResponse {
user?: string;
isAdmin?: boolean;
redirect?: string;
challengeId?: string;
requires2FA?: boolean;
}

export function useLoginMutation() {
const qc = useQueryClient();
return useMutation<LoginResult, ApiError, LoginInput>({
mutationFn: async (input) => {
const data = await api.post<LoginResponse>('/api/v1/auth/login', input);
if (data.requires2FA && data.challengeId) {
return { kind: '2fa', challengeId: data.challengeId };
}
return {
kind: 'logged-in',
user: data.user || input.username,
Expand All @@ -84,34 +78,40 @@ export function useLoginMutation() {
isAdmin: result.isAdmin,
});
// Refetch from the server so the cached session reflects the
// authoritative state (including pushNotificationUrl) before the
// caller navigates into a guarded route.
// authoritative state before the caller navigates into a guarded route.
await qc.refetchQueries({ queryKey: sessionKey });
}
},
});
}

export interface ChallengeStatus {
status: 'pending' | 'approved' | 'rejected' | 'timeout' | 'failed' | 'unregistered';
user?: string;
isAdmin?: boolean;
redirect?: string;
message?: string;
registrationUrl?: string;
}

export async function fetchChallenge(id: string): Promise<ChallengeStatus> {
return api.get<ChallengeStatus>(`/api/v1/auth/login/challenge/${encodeURIComponent(id)}`);
}

export function useLogoutMutation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.post('/api/v1/auth/logout');
// When OIDC SSO is enabled the server returns a `logoutUrl` pointing at
// the IdP's end-session endpoint. We must visit it to terminate the IdP
// session; otherwise the live IdP session signs the user straight back in.
const data = await api.post<{ loggedOut: boolean; logoutUrl?: string | null }>(
'/api/v1/auth/logout',
);

// If we have an IdP logout URL, hand off to the browser *before* touching
// the query cache. Clearing the cache here would synchronously re-render
// guarded views and bounce the user to /login, whose own effect kicks off
// a fresh SSO redirect — racing (and beating) this navigation. Assigning
// first makes RP-initiated logout the only navigation that happens.
if (data?.logoutUrl) {
window.location.assign(data.logoutUrl);
// Block further React work this tick; the page is being replaced.
await new Promise(() => {});
}

return data;
},
onSettled: () => {
onSettled: (data) => {
// Reached only for the local-only logout path (no IdP end-session URL).
if (data?.logoutUrl) return;
clearCsrfToken();
qc.setQueryData<SessionUser | null>(sessionKey, null);
qc.clear();
Expand Down
4 changes: 0 additions & 4 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ export interface User {
status: 'pending' | 'active' | 'disabled';
groups?: { gidNumber: number; cn: string; isAdmin: boolean }[];
isAdmin: boolean;
twoFactorWarning?: string;
}

export interface Group {
Expand All @@ -161,9 +160,6 @@ export interface ApiKeyCreated extends ApiKey {
}

export interface AppSettings {
pushNotificationUrl: string;
pushNotificationEnabled: boolean;
pushNotificationApiKey: string;
smtpUrl: string;
smtpNoreplyAddress: string;
defaultContainerEnvVars: { key: string; value: string; description?: string }[];
Expand Down
Loading
Loading