Add passkey (WebAuthn) authentication with self-registration

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
This commit is contained in:
Viktor Barzin 2026-02-07 00:34:47 +00:00
parent 95c0ddc4c6
commit a8b7eace48
No known key found for this signature in database
GPG key ID: 0EB088298288D958
26 changed files with 1229 additions and 129 deletions

View file

@ -22,7 +22,9 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^10.0.0",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@types/crossfilter": "^0.0.38",
@ -1544,6 +1546,43 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
@ -1698,6 +1737,66 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
@ -2170,6 +2269,22 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^10.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@ -3010,6 +3125,7 @@
"integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
@ -3026,6 +3142,7 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -3036,6 +3153,7 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -3105,6 +3223,7 @@
"integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.0",
"@typescript-eslint/types": "8.34.0",
@ -3336,6 +3455,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3901,6 +4021,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -4134,6 +4255,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5170,6 +5292,7 @@
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"jwt-decode": "^4.0.0"
},
@ -5384,6 +5507,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5414,6 +5538,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -5426,6 +5551,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz",
"integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -5824,6 +5950,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5898,6 +6025,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -5994,6 +6122,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -6082,6 +6211,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View file

@ -24,7 +24,9 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^10.0.0",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@types/crossfilter": "^0.0.38",

View file

@ -1,7 +1,8 @@
import type { User } from 'oidc-client-ts';
import { useEffect, useState, useRef, useCallback } from 'react';
import './App.css';
import { getUser } from './auth/authService';
import { getStoredPasskeyUser } from './auth/passkeyService';
import { fromOidcUser, type AuthUser } from './auth/types';
import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal';
import AuthCallback from './components/AuthCallback';
@ -20,7 +21,7 @@ import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type Streamin
function App() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
const [taskID, setTaskID] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
@ -41,10 +42,23 @@ function App() {
}
useEffect(() => {
// Load user data
getUser().then(setUser);
// Check passkey user first, then fall back to OIDC
const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) {
setUser(fromOidcUser(oidcUser));
}
});
}
}, []);
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
setUser(passkeyUser);
};
useEffect(() => {
if (!user) {
return;
@ -122,7 +136,7 @@ function App() {
}, [user, loadListings]);
if (!user) {
return <LoginModal isOpen={user === null} />;
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
}
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {

View file

@ -0,0 +1,155 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import type { AuthUser } from './types';
const PASSKEY_USER_KEY = 'passkey_user';
interface RegisterBeginResponse {
options: PublicKeyCredentialCreationOptionsJSON;
session_id: string;
}
interface LoginBeginResponse {
options: PublicKeyCredentialRequestOptionsJSON;
session_id: string;
}
interface AuthTokenResponse {
token: string;
}
// WebAuthn JSON types from the spec
type PublicKeyCredentialCreationOptionsJSON = Parameters<typeof startRegistration>[0];
type PublicKeyCredentialRequestOptionsJSON = Parameters<typeof startAuthentication>[0];
function parseJwt(token: string): Record<string, unknown> {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
}
export async function registerPasskey(email: string): Promise<AuthUser> {
// Step 1: Begin registration
const beginRes = await fetch('/api/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!beginRes.ok) {
const err = await beginRes.json();
throw new Error(err.detail || 'Failed to start registration');
}
const beginData: RegisterBeginResponse = await beginRes.json();
// Step 2: Browser WebAuthn ceremony
const attResp = await startRegistration(beginData.options);
// Step 3: Complete registration
const completeRes = await fetch('/api/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: beginData.session_id,
credential: attResp,
}),
});
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete registration');
}
const { token }: AuthTokenResponse = await completeRes.json();
const claims = parseJwt(token);
const user: AuthUser = {
sub: claims.sub as string,
email: claims.email as string,
name: (claims.name as string) || (claims.email as string),
accessToken: token,
provider: 'passkey',
};
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
return user;
}
export async function loginWithPasskey(): Promise<AuthUser> {
// Step 1: Begin authentication
const beginRes = await fetch('/api/passkey/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!beginRes.ok) {
const err = await beginRes.json();
throw new Error(err.detail || 'Failed to start login');
}
const beginData: LoginBeginResponse = await beginRes.json();
// Step 2: Browser WebAuthn ceremony
const assertionResp = await startAuthentication(beginData.options);
// Step 3: Complete authentication
const completeRes = await fetch('/api/passkey/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: beginData.session_id,
credential: assertionResp,
}),
});
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete login');
}
const { token }: AuthTokenResponse = await completeRes.json();
const claims = parseJwt(token);
const user: AuthUser = {
sub: claims.sub as string,
email: claims.email as string,
name: (claims.name as string) || (claims.email as string),
accessToken: token,
provider: 'passkey',
};
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
return user;
}
export function getStoredPasskeyUser(): AuthUser | null {
const stored = localStorage.getItem(PASSKEY_USER_KEY);
if (!stored) return null;
try {
const user: AuthUser = JSON.parse(stored);
// Check JWT expiration
const claims = parseJwt(user.accessToken);
const exp = claims.exp as number | undefined;
if (exp && exp * 1000 < Date.now()) {
localStorage.removeItem(PASSKEY_USER_KEY);
return null;
}
return user;
} catch {
localStorage.removeItem(PASSKEY_USER_KEY);
return null;
}
}
export function clearPasskeyUser(): void {
localStorage.removeItem(PASSKEY_USER_KEY);
}

View file

@ -0,0 +1,19 @@
import type { User } from 'oidc-client-ts';
export interface AuthUser {
sub: string;
email: string;
name: string;
accessToken: string;
provider: 'oidc' | 'passkey';
}
export function fromOidcUser(user: User): AuthUser {
return {
sub: user.profile.sub,
email: user.profile.email ?? '',
name: user.profile.name ?? user.profile.email ?? '',
accessToken: user.access_token,
provider: 'oidc',
};
}

View file

@ -1,8 +1,9 @@
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 type { User } from 'oidc-client-ts';
import React, { useEffect, useState } from 'react';
import AlertError from './AlertError';
import { Spinner } from './Spinner';
@ -17,9 +18,16 @@ interface ActiveQueryProps {
}
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
getUser().then(setUser);
const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) setUser(fromOidcUser(oidcUser));
});
}
}, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0);

View file

@ -1,13 +1,14 @@
import type { User } from 'oidc-client-ts';
import type { AuthUser } from '@/auth/types';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { LogOut, Home, Filter } from 'lucide-react';
import { logout } from '@/auth/authService';
import { clearPasskeyUser } from '@/auth/passkeyService';
import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator';
interface HeaderProps {
user: User;
user: AuthUser;
activeFilterCount?: number;
taskID?: string | null;
isLoading?: boolean;
@ -24,6 +25,15 @@ export function Header({
showFilterToggle = false,
onTaskCancelled,
}: HeaderProps) {
const handleLogout = async () => {
if (user.provider === 'passkey') {
clearPasskeyUser();
window.location.reload();
} else {
await logout();
}
};
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
{/* Logo / Brand */}
@ -63,12 +73,12 @@ export function Header({
{/* User Menu */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground hidden md:inline">
{user.profile.email}
{user.email}
</span>
<Button
variant="ghost"
size="sm"
onClick={logout}
onClick={handleLogout}
className="gap-2"
>
<LogOut className="h-4 w-4" />

View file

@ -1,44 +1,71 @@
import { login, type AuthError } from '@/auth/authService';
import { registerPasskey, loginWithPasskey } from '@/auth/passkeyService';
import type { AuthUser } from '@/auth/types';
import { Button } from "@/components/ui/button";
import { DialogDescription } from '@radix-ui/react-dialog';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onPasskeyLogin: (user: AuthUser) => void;
}
const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onPasskeyLogin }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
if (!isOpen) return null;
const handleLogin = async () => {
const handleSSOLogin = async () => {
setIsLoading(true);
setError(null);
try {
await login();
} catch (err) {
setError(err as AuthError);
setError((err as AuthError).message);
setIsLoading(false);
}
};
const handleRetry = () => {
const handlePasskeyLogin = async () => {
setIsLoading(true);
setError(null);
handleLogin();
try {
const user = await loginWithPasskey();
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
const handlePasskeyRegister = async () => {
if (!email.trim()) {
setError('Please enter your email address');
return;
}
setIsLoading(true);
setError(null);
setIsLoading(false);
try {
const user = await registerPasskey(email.trim());
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
};
const clearError = () => setError(null);
return (
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
<DialogHeader className="space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
@ -53,72 +80,107 @@ const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
</div>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Beta Notice */}
<div className="bg-muted/50 border rounded-lg p-4 text-sm">
<p className="text-muted-foreground">
We are currently in closed beta. Please contact Viktor to request an invitation.
</p>
</div>
<div className="py-2">
{/* Error State */}
{error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3 mb-4">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm text-destructive">{error.message}</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleRetry}
className="text-destructive border-destructive/30 hover:bg-destructive/10"
>
Try Again
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
>
Cancel
</Button>
</div>
<p className="text-sm text-destructive">{error}</p>
<Button
size="sm"
variant="ghost"
onClick={clearError}
>
Dismiss
</Button>
</div>
</div>
)}
{/* Loading State */}
{isLoading && !error && (
<div className="flex items-center justify-center gap-3 py-4 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Redirecting to login...</span>
</div>
)}
</div>
<Tabs defaultValue="signin">
<TabsList>
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<DialogFooter>
{!error && (
<Button
onClick={handleLogin}
disabled={isLoading}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<>
<TabsContent value="signin" className="space-y-4 pt-2">
<Button
onClick={handlePasskeyLogin}
disabled={isLoading}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
Signing in...
</>
) : (
<>
) : (
<Fingerprint className="h-4 w-4" />
)}
Sign in with Passkey
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
onClick={handleSSOLogin}
disabled={isLoading}
variant="outline"
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
Sign in with SSO
</>
)}
</Button>
)}
</DialogFooter>
)}
Sign in with SSO
</Button>
</TabsContent>
<TabsContent value="signup" className="space-y-4 pt-2">
<div className="space-y-2">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handlePasskeyRegister();
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
</div>
<Button
onClick={handlePasskeyRegister}
disabled={isLoading || !email.trim()}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Create account with Passkey
</Button>
<p className="text-xs text-muted-foreground text-center">
A passkey uses your device's biometrics or security key for secure, passwordless authentication.
</p>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);

View file

@ -1,8 +1,9 @@
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, clearAllTasks } from '@/services';
import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Button } from './ui/button';
@ -15,7 +16,7 @@ interface TaskIndicatorProps {
}
export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [processed, setProcessed] = useState<number | null>(null);
const [total, setTotal] = useState<number | null>(null);
@ -26,7 +27,14 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
getUser().then(setUser);
const passkeyUser = getStoredPasskeyUser();
if (passkeyUser) {
setUser(passkeyUser);
} else {
getUser().then((oidcUser) => {
if (oidcUser) setUser(fromOidcUser(oidcUser));
});
}
}, []);
useEffect(() => {

View file

@ -0,0 +1,60 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" {...props} />
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm flex-1",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -1,6 +1,6 @@
// Generic API client with authentication
import type { User } from 'oidc-client-ts';
import type { AuthUser } from '@/auth/types';
import { ApiError } from '@/types';
export interface RequestOptions {
@ -31,12 +31,11 @@ function buildQueryString(params: Record<string, string | number | boolean | Dat
* Generic authenticated API request
*/
export async function apiRequest<T>(
user: User,
user: AuthUser,
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { method = 'GET', params } = options;
const accessToken = user.access_token;
let url = endpoint;
if (params) {
@ -49,7 +48,7 @@ export async function apiRequest<T>(
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${user.accessToken}`,
'Content-Type': 'application/json',
},
});

View file

@ -1,6 +1,6 @@
// Listing service for fetching and refreshing listings
import type { User } from 'oidc-client-ts';
import type { AuthUser } from '@/auth/types';
import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
import { apiRequest } from './apiClient';
@ -31,7 +31,7 @@ function buildListingParams(parameters: ParameterValues): Record<string, string
* Fetch listing data as GeoJSON
*/
export async function fetchListingGeoJSON(
user: User,
user: AuthUser,
parameters: ParameterValues
): Promise<GeoJSONFeatureCollection> {
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
@ -44,7 +44,7 @@ export async function fetchListingGeoJSON(
* Trigger a listing refresh task
*/
export async function refreshListings(
user: User,
user: AuthUser,
parameters: ParameterValues
): Promise<RefreshListingsResponse> {
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {

View file

@ -1,6 +1,6 @@
// Streaming service for progressive listing data loading
import type { User } from 'oidc-client-ts';
import type { AuthUser } from '@/auth/types';
import type { PropertyFeature } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
import { ApiError } from '@/types';
@ -65,7 +65,7 @@ export interface StreamingProgress {
* Yields batches of features as they arrive from the server.
*/
export async function* streamListingGeoJSON(
user: User,
user: AuthUser,
parameters: ParameterValues,
onProgress?: (progress: StreamingProgress) => void
): AsyncGenerator<PropertyFeature[], void, unknown> {
@ -77,7 +77,7 @@ export async function* streamListingGeoJSON(
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${user.access_token}`,
Authorization: `Bearer ${user.accessToken}`,
},
});

View file

@ -1,6 +1,6 @@
// Task service for fetching task status
import type { User } from 'oidc-client-ts';
import type { AuthUser } from '@/auth/types';
import type { TaskStatusResponse } from '@/types';
import { apiRequest } from './apiClient';
import { API_ENDPOINTS } from '@/constants';
@ -19,7 +19,7 @@ export interface ClearAllTasksResponse {
/**
* Fetch all active tasks for the current user
*/
export async function fetchTasksForUser(user: User): Promise<string[]> {
export async function fetchTasksForUser(user: AuthUser): Promise<string[]> {
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
}
@ -27,7 +27,7 @@ export async function fetchTasksForUser(user: User): Promise<string[]> {
* Fetch the status of a specific task
*/
export async function fetchTaskStatus(
user: User,
user: AuthUser,
taskId: string
): Promise<TaskStatusResponse> {
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
@ -39,7 +39,7 @@ export async function fetchTaskStatus(
* Cancel a running task
*/
export async function cancelTask(
user: User,
user: AuthUser,
taskId: string
): Promise<CancelTaskResponse> {
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
@ -51,7 +51,7 @@ export async function cancelTask(
/**
* Clear all tasks for the current user
*/
export async function clearAllTasks(user: User): Promise<ClearAllTasksResponse> {
export async function clearAllTasks(user: AuthUser): Promise<ClearAllTasksResponse> {
return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, {
method: 'POST',
});