Flatten repo structure: move crawler/ to root, remove vqa/ and immoweb/
The crawler subdirectory was the only active project. Moving it to the repo root simplifies paths and removes the unnecessary nesting. The vqa/ and immoweb/ directories were legacy/unused and have been removed. Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect the new flat structure.
This commit is contained in:
parent
e2247be700
commit
eafbc1ac52
221 changed files with 70 additions and 146140 deletions
45
frontend/src/auth/authService.ts
Normal file
45
frontend/src/auth/authService.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { User, UserManager } from 'oidc-client-ts';
|
||||
import { oidcConfig } from './config';
|
||||
import { parseOidcError, type AuthError } from './errors';
|
||||
|
||||
const userManager = new UserManager(oidcConfig);
|
||||
|
||||
export const login = async (): Promise<void> => {
|
||||
try {
|
||||
await userManager.signinRedirect();
|
||||
} catch (error) {
|
||||
console.error('Login redirect failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await userManager.signoutRedirect();
|
||||
} catch (error) {
|
||||
console.error('Logout redirect failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCallback = async (): Promise<User> => {
|
||||
try {
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Callback handling failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getUser = async (): Promise<User | null> => {
|
||||
try {
|
||||
const user = await userManager.getUser();
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type { AuthError };
|
||||
14
frontend/src/auth/config.ts
Normal file
14
frontend/src/auth/config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
export const oidcConfig = {
|
||||
authority: "https://authentik.viktorbarzin.me/application/o/wrongmove/",
|
||||
client_id: "5AJKRgcdgVm1OyApBzFkadDFfStW9a555zwv2MOe",
|
||||
redirect_uri: import.meta.env.MODE === 'development' ? "https://localhost/callback" : "https://wrongmove.viktorbarzin.me/callback",
|
||||
post_logout_redirect_uri: import.meta.env.MODE === 'development' ? "https://localhost/" : "https://wrongmove.viktorbarzin.me/",
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
response_type: 'code', // PKCE flow (recommended for SPAs)
|
||||
scope: 'openid profile email', // Requested scopes
|
||||
automaticSilentRenew: true, // Renew tokens silently
|
||||
filterProtocolClaims: true,
|
||||
loadUserInfo: true,
|
||||
};
|
||||
60
frontend/src/auth/errors.ts
Normal file
60
frontend/src/auth/errors.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export enum AuthErrorType {
|
||||
REDIRECT_FAILED = 'REDIRECT_FAILED',
|
||||
CALLBACK_FAILED = 'CALLBACK_FAILED',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
USER_CANCELLED = 'USER_CANCELLED',
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
type: AuthErrorType;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export function parseOidcError(error: unknown): AuthError {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorString = errorMessage.toLowerCase();
|
||||
|
||||
// Check for popup/redirect blocked errors
|
||||
if (errorString.includes('popup') || errorString.includes('blocked') || errorString.includes('window')) {
|
||||
return {
|
||||
type: AuthErrorType.REDIRECT_FAILED,
|
||||
message: 'Unable to redirect. Please check if popups are blocked.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for user cancellation
|
||||
if (errorString.includes('cancel') || errorString.includes('closed') || errorString.includes('denied')) {
|
||||
return {
|
||||
type: AuthErrorType.USER_CANCELLED,
|
||||
message: 'Sign in was cancelled.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for network errors
|
||||
if (errorString.includes('network') || errorString.includes('fetch') || errorString.includes('timeout') || errorString.includes('failed to fetch')) {
|
||||
return {
|
||||
type: AuthErrorType.NETWORK_ERROR,
|
||||
message: 'Unable to reach authentication server. Please check your connection.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for callback/state errors
|
||||
if (errorString.includes('state') || errorString.includes('invalid') || errorString.includes('mismatch') || errorString.includes('no matching state')) {
|
||||
return {
|
||||
type: AuthErrorType.CALLBACK_FAILED,
|
||||
message: 'Login verification failed. Please try again.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Default error
|
||||
return {
|
||||
type: AuthErrorType.CALLBACK_FAILED,
|
||||
message: errorMessage || 'An unexpected error occurred during sign in.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
155
frontend/src/auth/passkeyService.ts
Normal file
155
frontend/src/auth/passkeyService.ts
Normal 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);
|
||||
}
|
||||
19
frontend/src/auth/types.ts
Normal file
19
frontend/src/auth/types.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue