diff --git a/api/src/features/webauthn.js b/api/src/features/webauthn.js
index 4c0bd75..ed6ed60 100644
--- a/api/src/features/webauthn.js
+++ b/api/src/features/webauthn.js
@@ -37,17 +37,41 @@ async function webauthnFeature(app) {
return { data: rows[0], status: 'registered' };
});
- // Auth options
+ // Auth options (by email — no login required)
app.post('/webauthn/auth/options', async (req) => {
- const { user_id } = req.body;
- const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
+ const { user_id, email } = req.body;
+ let userId = user_id;
+ if (!userId && email) {
+ const { rows: u } = await app.db.query('SELECT id FROM users WHERE email=$1', [email]);
+ if (!u.length) throw { statusCode: 404, message: 'User not found' };
+ userId = u[0].id;
+ }
+ const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [userId]);
+ if (!rows.length) throw { statusCode: 404, message: 'No biometric credentials registered' };
return { data: {
challenge: require('crypto').randomBytes(32).toString('base64url'),
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
- timeout: 60000, userVerification: 'required'
+ timeout: 60000, userVerification: 'required',
+ _user_id: userId
}};
});
+ // Verify auth assertion — returns JWT
+ app.post('/webauthn/auth/verify', async (req) => {
+ const { credential_id } = req.body;
+ if (!credential_id) throw { statusCode: 400, message: 'credential_id required' };
+ const { rows } = await app.db.query(
+ `SELECT wc.user_id, wc.counter, u.id, u.email, u.name
+ FROM webauthn_credentials wc JOIN users u ON u.id = wc.user_id
+ WHERE wc.credential_id = $1`, [credential_id]);
+ if (!rows.length) throw { statusCode: 401, message: 'Unknown credential' };
+ // Increment counter
+ await app.db.query('UPDATE webauthn_credentials SET counter = counter + 1 WHERE credential_id = $1', [credential_id]);
+ const user = rows[0];
+ const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' });
+ return { data: { token, user: { id: user.id, email: user.email, name: user.name } } };
+ });
+
// List user's biometric devices
app.get('/webauthn/devices/:userId', async (req) => {
const { rows } = await app.db.query(
diff --git a/apps/tasks/app/login/page.tsx b/apps/tasks/app/login/page.tsx
index be1f775..387aa04 100644
--- a/apps/tasks/app/login/page.tsx
+++ b/apps/tasks/app/login/page.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { login } from "@/lib/api";
+import { login, webauthnAuthOptions, webauthnAuthVerify } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
@@ -12,11 +12,26 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
+ const [biometricLoading, setBiometricLoading] = useState(false);
const [error, setError] = useState("");
+ const [biometricAvailable, setBiometricAvailable] = useState(false);
+ const [savedEmail, setSavedEmail] = useState("");
const { setAuth } = useAuth();
const { t } = useTranslation();
const router = useRouter();
+ useEffect(() => {
+ // Check if WebAuthn is available
+ if (typeof window !== "undefined" && window.PublicKeyCredential) {
+ window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
+ .then(available => setBiometricAvailable(available))
+ .catch(() => {});
+ }
+ // Load last used email for biometric
+ const last = localStorage.getItem("taskteam_biometric_email");
+ if (last) setSavedEmail(last);
+ }, []);
+
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim()) {
@@ -36,6 +51,57 @@ export default function LoginPage() {
}
}
+ async function handleBiometricLogin() {
+ const biometricEmail = email.trim() || savedEmail;
+ if (!biometricEmail) {
+ setError("Zadejte email pro biometricke prihlaseni");
+ return;
+ }
+ setBiometricLoading(true);
+ setError("");
+ try {
+ // Get auth options from server
+ const optionsRes = await webauthnAuthOptions(biometricEmail);
+ const options = optionsRes.data;
+
+ // Create PublicKey credential request
+ const allowCredentials = (options.allowCredentials as Array<{ id: string; type: string }>).map(cred => ({
+ id: base64urlToBuffer(cred.id),
+ type: cred.type as PublicKeyCredentialType,
+ }));
+
+ const credential = await navigator.credentials.get({
+ publicKey: {
+ challenge: base64urlToBuffer(options.challenge as string),
+ allowCredentials,
+ timeout: 60000,
+ userVerification: "required" as UserVerificationRequirement,
+ },
+ }) as PublicKeyCredential;
+
+ if (!credential) throw new Error("Biometricke overeni selhalo");
+
+ // Send credential_id to server to get JWT
+ const credentialId = bufferToBase64url(credential.rawId);
+ const result = await webauthnAuthVerify(credentialId);
+
+ // Save email for next time
+ localStorage.setItem("taskteam_biometric_email", biometricEmail);
+
+ setAuth(result.data.token, result.data.user);
+ router.push("/tasks");
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Biometricke prihlaseni selhalo";
+ if (msg.includes("No biometric") || msg.includes("not found")) {
+ setError("Pro tento ucet neni nastaveno biometricke prihlaseni. Nastavte ho v Nastaveni.");
+ } else {
+ setError(msg);
+ }
+ } finally {
+ setBiometricLoading(false);
+ }
+ }
+
return (
@@ -93,6 +159,35 @@ export default function LoginPage() {
+ {/* Biometric Login */}
+ {biometricAvailable && (
+ <>
+
+
+
+ {savedEmail && !email && (
+
+ Posledni ucet: {savedEmail}
+
+ )}
+ >
+ )}
+
{t("auth.forgotPassword")}
@@ -110,3 +205,20 @@ export default function LoginPage() {
);
}
+
+// --- WebAuthn buffer helpers ---
+function base64urlToBuffer(base64url: string): ArrayBuffer {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+}
+
+function bufferToBase64url(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
diff --git a/apps/tasks/app/settings/page.tsx b/apps/tasks/app/settings/page.tsx
index 2a7b170..5009219 100644
--- a/apps/tasks/app/settings/page.tsx
+++ b/apps/tasks/app/settings/page.tsx
@@ -7,6 +7,7 @@ import { useTheme } from "@/components/ThemeProvider";
import { useTranslation, LOCALES } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import type { Group } from "@/lib/api";
+import { webauthnRegisterOptions, webauthnRegisterVerify, webauthnGetDevices, webauthnDeleteDevice } from "@/lib/api";
import Link from "next/link";
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
@@ -53,6 +54,9 @@ export default function SettingsPage() {
});
const [inactivityTimeout, setInactivityTimeout] = useState(5);
const [widgetSaved, setWidgetSaved] = useState(false);
+ const [biometricDevices, setBiometricDevices] = useState
>([]);
+ const [biometricAvailable, setBiometricAvailable] = useState(false);
+ const [biometricLoading, setBiometricLoading] = useState(false);
useEffect(() => {
if (!token) {
@@ -85,6 +89,21 @@ export default function SettingsPage() {
}
}, [token, router]);
+ // Check biometric availability and load devices
+ useEffect(() => {
+ if (typeof window !== "undefined" && window.PublicKeyCredential) {
+ window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
+ .then(available => setBiometricAvailable(available))
+ .catch(() => {});
+ }
+ }, []);
+
+ useEffect(() => {
+ if (token && user?.id) {
+ webauthnGetDevices(token, user.id).then(res => setBiometricDevices(res.data || [])).catch(() => {});
+ }
+ }, [token, user]);
+
useEffect(() => {
if (!token) return;
fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } })
@@ -163,6 +182,80 @@ export default function SettingsPage() {
setTimeout(() => setSavedGroup(null), 2000);
}
+ async function addBiometricDevice() {
+ if (!token || !user?.id) return;
+ setBiometricLoading(true);
+ try {
+ const optionsRes = await webauthnRegisterOptions(token, user.id);
+ const options = optionsRes.data;
+
+ const credential = await navigator.credentials.create({
+ publicKey: {
+ challenge: base64urlToBuffer(options.challenge as string),
+ rp: options.rp as { name: string; id: string },
+ user: {
+ id: base64urlToBuffer((options.user as { id: string }).id),
+ name: (options.user as { name: string }).name,
+ displayName: (options.user as { displayName: string }).displayName,
+ },
+ pubKeyCredParams: (options.pubKeyCredParams as Array<{ alg: number; type: string }>).map(p => ({
+ alg: p.alg,
+ type: p.type as PublicKeyCredentialType,
+ })),
+ authenticatorSelection: {
+ authenticatorAttachment: "platform" as AuthenticatorAttachment,
+ userVerification: "required" as UserVerificationRequirement,
+ },
+ timeout: 60000,
+ },
+ }) as PublicKeyCredential;
+
+ if (!credential) throw new Error("Registrace selhala");
+
+ const credentialId = bufferToBase64url(credential.rawId);
+ const response = credential.response as AuthenticatorAttestationResponse;
+ const publicKey = bufferToBase64url(response.getPublicKey?.() || response.attestationObject);
+
+ // Detect device name
+ const ua = navigator.userAgent;
+ let deviceName = "Biometric";
+ if (/iPhone|iPad/.test(ua)) deviceName = "Face ID (iOS)";
+ else if (/Mac/.test(ua)) deviceName = "Touch ID (Mac)";
+ else if (/Android/.test(ua)) deviceName = "Otisk prstu (Android)";
+ else if (/Windows/.test(ua)) deviceName = "Windows Hello";
+
+ await webauthnRegisterVerify(token, {
+ user_id: user.id,
+ credential_id: credentialId,
+ public_key: publicKey,
+ device_name: deviceName,
+ });
+
+ // Save email for biometric login
+ localStorage.setItem("taskteam_biometric_email", user.email || "");
+
+ // Refresh device list
+ const devRes = await webauthnGetDevices(token, user.id);
+ setBiometricDevices(devRes.data || []);
+ } catch (err) {
+ console.error("WebAuthn registration error:", err);
+ alert(err instanceof Error ? err.message : "Registrace biometrie selhala");
+ } finally {
+ setBiometricLoading(false);
+ }
+ }
+
+ async function removeBiometricDevice(deviceId: string) {
+ if (!token || !user?.id) return;
+ if (!confirm("Opravdu odebrat toto zarizeni?")) return;
+ try {
+ await webauthnDeleteDevice(token, deviceId);
+ setBiometricDevices(prev => prev.filter(d => d.id !== deviceId));
+ } catch {
+ alert("Nepodarilo se odebrat zarizeni");
+ }
+ }
+
function toggleWidget(key: WidgetType) {
setWidgetEnabled(prev => ({ ...prev, [key]: !prev[key] }));
}
@@ -208,6 +301,52 @@ export default function SettingsPage() {
+ {/* Biometric auth section */}
+ {biometricAvailable && (
+
+
Biometricke prihlaseni
+
+ {biometricDevices.length > 0 ? (
+
+ {biometricDevices.map(device => (
+
+
+
+
+
{device.device_name}
+
+ {new Date(device.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" })}
+
+
+
+
+
+ ))}
+
+ ) : (
+
Zadne zarizeni zatim nebylo pridano.
+ )}
+
+
+
+ )}
+
{/* Install section */}
{t("settings.install") || "Instalace"}
@@ -539,3 +678,20 @@ export default function SettingsPage() {
);
}
+
+// --- WebAuthn buffer helpers ---
+function base64urlToBuffer(base64url: string): ArrayBuffer {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+}
+
+function bufferToBase64url(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
diff --git a/apps/tasks/lib/api.ts b/apps/tasks/lib/api.ts
index 7a02625..795885c 100644
--- a/apps/tasks/lib/api.ts
+++ b/apps/tasks/lib/api.ts
@@ -53,6 +53,39 @@ export function getMe(token: string) {
return apiFetch<{ user: User }>("/api/v1/auth/me", { token });
}
+// WebAuthn
+export function webauthnRegisterOptions(token: string, userId: string) {
+ return apiFetch<{ data: Record }>("/api/v1/webauthn/register/options", {
+ method: "POST", token, body: { user_id: userId },
+ });
+}
+
+export function webauthnRegisterVerify(token: string, data: { user_id: string; credential_id: string; public_key: string; device_name: string }) {
+ return apiFetch<{ data: Record; status: string }>("/api/v1/webauthn/register/verify", {
+ method: "POST", token, body: data,
+ });
+}
+
+export function webauthnAuthOptions(email: string) {
+ return apiFetch<{ data: Record }>("/api/v1/webauthn/auth/options", {
+ method: "POST", body: { email },
+ });
+}
+
+export function webauthnAuthVerify(credentialId: string) {
+ return apiFetch<{ data: { token: string; user: User } }>("/api/v1/webauthn/auth/verify", {
+ method: "POST", body: { credential_id: credentialId },
+ });
+}
+
+export function webauthnGetDevices(token: string, userId: string) {
+ return apiFetch<{ data: Array<{ id: string; device_name: string; created_at: string }> }>(`/api/v1/webauthn/devices/${userId}`, { token });
+}
+
+export function webauthnDeleteDevice(token: string, deviceId: string) {
+ return apiFetch<{ status: string }>(`/api/v1/webauthn/devices/${deviceId}`, { method: "DELETE", token });
+}
+
// Tasks
export function getTasks(token: string, params?: Record) {
const qs = params ? "?" + new URLSearchParams(params).toString() : "";