Enable users to sign up and sign in using passkeys (biometrics/security keys) without needing a manually-created Authentik account. The existing SSO login remains as an alternative. Backend: - Add WebAuthn registration/authentication endpoints via py-webauthn - Issue HS256 JWTs for passkey users, with Redis-backed challenge storage - Dual JWT verification in auth middleware (issuer-based routing: passkey HS256 vs Authentik RS256) - PasskeyCredential model + migration making user.password nullable - UserRepository with full CRUD for users and credentials Frontend: - AuthUser type abstraction unifying OIDC and passkey users - Passkey service using @simplewebauthn/browser for WebAuthn ceremonies - LoginModal redesigned with Sign In / Sign Up tabs - Type migration from oidc-client-ts User to AuthUser across all services and components
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
import { getUser } from '@/auth/authService';
|
|
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
|
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
|
import { POLLING_INTERVALS } from '@/constants';
|
|
import { fetchTaskStatus, cancelTask } from '@/services';
|
|
import { TaskStatus, type TaskResult } from '@/types';
|
|
import React, { useEffect, useState } from 'react';
|
|
import AlertError from './AlertError';
|
|
import { Spinner } from './Spinner';
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
|
import { Progress } from './ui/progress';
|
|
import { Button } from './ui/button';
|
|
import { X } from 'lucide-react';
|
|
|
|
interface ActiveQueryProps {
|
|
taskID: string | null;
|
|
onTaskCancelled?: () => void;
|
|
}
|
|
|
|
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
useEffect(() => {
|
|
const passkeyUser = getStoredPasskeyUser();
|
|
if (passkeyUser) {
|
|
setUser(passkeyUser);
|
|
} else {
|
|
getUser().then((oidcUser) => {
|
|
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
|
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.PENDING);
|
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
|
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
|
|
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
|
|
const handleCancelTask = async () => {
|
|
if (!user || !taskID || isCancelling) return;
|
|
|
|
setIsCancelling(true);
|
|
try {
|
|
const result = await cancelTask(user, taskID);
|
|
if (result.success) {
|
|
setTaskStatus(TaskStatus.REVOKED);
|
|
onTaskCancelled?.();
|
|
} else {
|
|
setFetchStatusError(result.message);
|
|
setAlertDialogIsOpen(true);
|
|
}
|
|
} catch (error) {
|
|
setFetchStatusError(error instanceof Error ? error.message : 'Failed to cancel task');
|
|
setAlertDialogIsOpen(true);
|
|
} finally {
|
|
setIsCancelling(false);
|
|
}
|
|
};
|
|
|
|
const pollTaskStatus = async (interval: NodeJS.Timeout) => {
|
|
if (!user || !taskID) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await fetchTaskStatus(user, taskID);
|
|
setLastUpdateTime(new Date());
|
|
const status = data.status as TaskStatus;
|
|
setTaskStatus(status);
|
|
|
|
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
|
clearInterval(interval);
|
|
setFetchStatusError('Task failed with status: ' + status);
|
|
setAlertDialogIsOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (status === TaskStatus.SUCCESS) {
|
|
clearInterval(interval);
|
|
setProgressPercentage(100);
|
|
return;
|
|
}
|
|
|
|
// Only parse result for in-progress tasks
|
|
if (data.result) {
|
|
try {
|
|
const parsedResult: TaskResult = JSON.parse(data.result);
|
|
setProgressPercentage(parsedResult.progress * 100);
|
|
} catch {
|
|
// Result parsing failed, but task is still running - ignore
|
|
}
|
|
}
|
|
} catch (error) {
|
|
clearInterval(interval);
|
|
setTaskStatus(TaskStatus.FAILURE);
|
|
setAlertDialogIsOpen(true);
|
|
if (error instanceof Error) {
|
|
setFetchStatusError(error.message);
|
|
} else {
|
|
setFetchStatusError('Failed to update task status: ' + String(error));
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(
|
|
() => pollTaskStatus(interval),
|
|
POLLING_INTERVALS.TASK_STATUS_MS
|
|
);
|
|
return () => clearInterval(interval);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [taskID, user]);
|
|
|
|
if (!taskID) {
|
|
return null;
|
|
}
|
|
|
|
const isInProgress = taskStatus &&
|
|
taskStatus !== TaskStatus.SUCCESS &&
|
|
taskStatus !== TaskStatus.FAILURE &&
|
|
taskStatus !== TaskStatus.REVOKED;
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-2 p-2 border-t bg-muted/50">
|
|
<HoverCard>
|
|
<HoverCardTrigger className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
{taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
|
|
{isInProgress && <Spinner />}
|
|
</div>
|
|
<Progress value={progressPercentage} className="mt-1" />
|
|
</HoverCardTrigger>
|
|
<HoverCardContent>
|
|
Task ID: {taskID}
|
|
<br />
|
|
Last updated: {lastUpdateTime.toLocaleString()}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
{isInProgress && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCancelTask}
|
|
disabled={isCancelling}
|
|
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="ml-1 hidden sm:inline">Cancel</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ActiveQuery;
|