Files
task-team/apps/tasks/app/settings/page.tsx
Admin 1fbbc84d24 WebAuthn biometric UI: login button + device management in settings
- Login page: "Face ID / Otisk prstu" button with full WebAuthn flow
  (auth options → navigator.credentials.get → verify → JWT)
  Remembers last biometric email in localStorage
- Settings page: Biometric device management section
  (list registered devices, add new via navigator.credentials.create, remove)
  Auto-detects device type (Face ID, Touch ID, Android fingerprint, Windows Hello)
- API: Added POST /webauthn/auth/verify endpoint returning JWT token
  Updated auth/options to accept email (no login required for biometric)
- API client: Added 6 WebAuthn helper functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:41:38 +00:00

698 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
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";
const ALL_WIDGETS: { key: WidgetType; label: string }[] = [
{ key: "current_tasks", label: "Aktualni ukoly" },
{ key: "category_time", label: "Aktivni kategorie" },
{ key: "today_progress", label: "Dnesni pokrok" },
{ key: "next_task", label: "Pristi ukol" },
{ key: "motivace", label: "Motivace" },
{ key: "calendar_mini", label: "Mini kalendar" },
];
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
interface GroupSetting {
from: string;
to: string;
days: number[];
locationName: string;
gps: string;
radius: number;
}
export default function SettingsPage() {
const { token, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { t, locale, setLocale } = useTranslation();
const router = useRouter();
const [notifications, setNotifications] = useState({
push: true,
email: false,
taskReminders: true,
dailySummary: false,
});
const [saved, setSaved] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [savedGroup, setSavedGroup] = useState<string | null>(null);
const [widgetEnabled, setWidgetEnabled] = useState<Record<WidgetType, boolean>>(() => {
const defaults: Record<WidgetType, boolean> = { current_tasks: true, category_time: true, today_progress: true, next_task: false, motivace: false, calendar_mini: false };
return defaults;
});
const [inactivityTimeout, setInactivityTimeout] = useState(5);
const [widgetSaved, setWidgetSaved] = useState(false);
const [biometricDevices, setBiometricDevices] = useState<Array<{ id: string; device_name: string; created_at: string }>>([]);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLoading, setBiometricLoading] = useState(false);
useEffect(() => {
if (!token) {
router.replace("/login");
}
// Load saved preferences
if (typeof window !== "undefined") {
const savedNotifs = localStorage.getItem("taskteam_notifications");
if (savedNotifs) {
try {
setNotifications(JSON.parse(savedNotifs));
} catch {
// ignore
}
}
const savedWidgets = localStorage.getItem("widget_config");
if (savedWidgets) {
try {
const cfg = JSON.parse(savedWidgets);
if (Array.isArray(cfg.enabled)) {
const map: Record<WidgetType, boolean> = { current_tasks: false, category_time: false, today_progress: false, next_task: false, motivace: false, calendar_mini: false };
for (const w of cfg.enabled) map[w as WidgetType] = true;
setWidgetEnabled(map);
}
if (cfg.inactivityTimeout > 0) setInactivityTimeout(cfg.inactivityTimeout);
} catch {
// ignore
}
}
}
}, [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}` } })
.then(r => r.json())
.then(res => {
const data: Group[] = res.data || [];
setGroups(data);
const settings: Record<string, GroupSetting> = {};
for (const g of data) {
const tz = g.time_zones?.[0];
const loc = g.locations?.[0];
settings[g.id] = {
from: tz?.from || "",
to: tz?.to || "",
days: tz?.days || [],
locationName: loc?.name || "",
gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "",
radius: loc?.radius_m || 200,
};
}
setGroupSettings(settings);
})
.catch(() => {});
}, [token]);
function toggleGroup(id: string) {
setExpandedGroup(prev => prev === id ? null : id);
}
function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) {
setGroupSettings(prev => ({
...prev,
[groupId]: { ...prev[groupId], [key]: value },
}));
}
function toggleDay(groupId: string, day: number) {
const current = groupSettings[groupId]?.days || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
updateGroupSetting(groupId, "days", next);
}
function getCurrentLocation(groupId: string) {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(pos => {
const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`;
updateGroupSetting(groupId, "gps", gps);
});
}
async function saveGroupSettings(groupId: string) {
const s = groupSettings[groupId] || {} as GroupSetting;
const timeZones = (s.from && s.to) ? [{
days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6],
from: s.from,
to: s.to,
}] : [];
const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim()));
const lat = gpsParts[0] || null;
const lng = gpsParts[1] || null;
const locations = s.locationName ? [{
name: s.locationName,
lat: isNaN(lat as number) ? null : lat,
lng: isNaN(lng as number) ? null : lng,
radius_m: Number(s.radius) || 200,
}] : [];
await fetch(`/api/v1/groups/${groupId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ time_zones: timeZones, locations }),
});
setSavedGroup(groupId);
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] }));
}
function saveWidgetConfig() {
const enabled = ALL_WIDGETS.filter(w => widgetEnabled[w.key]).map(w => w.key);
const config = { enabled, inactivityTimeout };
localStorage.setItem("widget_config", JSON.stringify(config));
setWidgetSaved(true);
setTimeout(() => setWidgetSaved(false), 2000);
}
function handleSave() {
if (typeof window !== "undefined") {
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
function handleLogout() {
logout();
router.push("/login");
}
if (!token) return null;
return (
<div className="max-w-lg mx-auto space-y-6 px-4 pb-24 sm:pb-8">
<h1 className="text-xl font-bold">{t("settings.title")}</h1>
{/* Profile section */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.profile")}</h2>
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xl font-bold">
{(user?.name || user?.email || "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-semibold text-lg">{user?.name || t("settings.user")}</p>
<p className="text-sm text-muted truncate">{user?.email}</p>
</div>
</div>
</div>
{/* Biometric auth section */}
{biometricAvailable && (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Biometricke prihlaseni</h2>
{biometricDevices.length > 0 ? (
<div className="space-y-2 mb-4">
{biometricDevices.map(device => (
<div key={device.id} className="flex items-center justify-between py-3 px-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-blue-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a48.667 48.667 0 00-1.418 8.773M11.997 3.001A7.5 7.5 0 0014.5 10.5c0 2.887-.543 5.649-1.533 8.19M9.003 3.001a7.5 7.5 0 00-2.497 7.499 48.12 48.12 0 01-.544 5M12 10.5a2.25 2.25 0 10-4.5 0 2.25 2.25 0 004.5 0z" />
</svg>
<div>
<div className="text-sm font-medium">{device.device_name}</div>
<div className="text-xs text-gray-400">
{new Date(device.created_at).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" })}
</div>
</div>
</div>
<button
onClick={() => removeBiometricDevice(device.id)}
className="text-xs text-red-500 hover:text-red-600 font-medium px-2 py-1"
>
Odebrat
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 mb-4">Zadne zarizeni zatim nebylo pridano.</p>
)}
<button
onClick={addBiometricDevice}
disabled={biometricLoading}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 transition-colors disabled:opacity-50 text-sm font-medium"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-blue-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a48.667 48.667 0 00-1.418 8.773M12 10.5a2.25 2.25 0 10-4.5 0 2.25 2.25 0 004.5 0z" />
</svg>
{biometricLoading ? "Registruji..." : "Pridat Face ID / otisk prstu"}
</button>
</div>
)}
{/* Install section */}
<section id="install" className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
<h2 className="font-semibold mb-3">{t("settings.install") || "Instalace"}</h2>
<div className="space-y-2">
<a href="https://expo.dev/accounts/it-enterprise/projects/task-team/builds/b31c63a8-0e4f-44d6-80e9-98cb24174037"
target="_blank" rel="noopener"
className="flex items-center gap-3 px-4 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors">
<span className="text-xl">&#x1F916;</span>
<span>Android APK</span>
<span className="ml-auto text-sm opacity-75">St&#xe1;hnout</span>
</a>
<a href="https://tasks.hasdo.info"
target="_blank" rel="noopener"
className="flex items-center gap-3 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors">
<span className="text-xl">&#x1F310;</span>
<span>PWA Web App</span>
<span className="ml-auto text-sm opacity-75">Otev&#x159;&#xed;t</span>
</a>
<div className="flex items-center gap-3 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-500 rounded-lg">
<span className="text-xl">&#x1F34E;</span>
<span>iOS (App Store)</span>
<span className="ml-auto text-sm">P&#x159;ipravujeme</span>
</div>
<div className="flex items-center gap-3 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-500 rounded-lg">
<span className="text-xl">&#x1F4E6;</span>
<span>F-Droid</span>
<span className="ml-auto text-sm">P&#x159;ipravujeme</span>
</div>
</div>
</section>
{/* Appearance */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.appearance")}</h2>
{/* Theme toggle */}
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
{theme === "dark" ? (
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
<span className="text-sm font-medium">
{theme === "dark" ? t("settings.dark") : t("settings.light")}
</span>
</div>
<button
onClick={toggleTheme}
className={`relative w-12 h-7 rounded-full transition-colors ${
theme === "dark" ? "bg-blue-600" : "bg-gray-300"
}`}
aria-label={t("common.toggleTheme")}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
theme === "dark" ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
{/* Language */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.language")}</h2>
<div className="grid grid-cols-2 gap-2">
{LOCALES.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code as Locale)}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium transition-all ${
locale === lang.code
? "bg-blue-600 text-white shadow-md"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
<span className="text-lg">{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
{/* Notifications */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">{t("settings.notifications")}</h2>
<div className="space-y-1">
{[
{ key: "push" as const, label: t("settings.push") },
{ key: "email" as const, label: t("settings.email") },
{ key: "taskReminders" as const, label: t("settings.taskReminders") },
{ key: "dailySummary" as const, label: t("settings.dailySummary") },
].map((item) => (
<div key={item.key} className="flex items-center justify-between py-3">
<span className="text-sm font-medium">{item.label}</span>
<button
onClick={() =>
setNotifications((prev) => ({
...prev,
[item.key]: !prev[item.key],
}))
}
className={`relative w-12 h-7 rounded-full transition-colors ${
notifications[item.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={item.label}
>
<div
className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
notifications[item.key] ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
))}
</div>
</div>
{/* Widget & Lock Screen settings */}
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-muted uppercase tracking-wide mb-4">Widgety & Zamykaci obrazovka</h2>
<div className="space-y-1">
{ALL_WIDGETS.map(w => (
<div key={w.key} className="flex items-center justify-between py-3">
<span className="text-sm font-medium">{w.label}</span>
<button
onClick={() => toggleWidget(w.key)}
className={`relative w-12 h-7 rounded-full transition-colors ${
widgetEnabled[w.key] ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={w.label}
>
<div className={`absolute top-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform ${
widgetEnabled[w.key] ? "translate-x-5" : "translate-x-0.5"
}`} />
</button>
</div>
))}
</div>
{/* Inactivity timeout */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Neaktivita (min)</span>
<span className="text-sm text-muted">{inactivityTimeout} min</span>
</div>
<input
type="range"
min={1}
max={30}
step={1}
value={inactivityTimeout}
onChange={e => setInactivityTimeout(Number(e.target.value))}
className="w-full"
/>
<p className="text-xs text-muted mt-1">Po teto dobe neaktivity se zobrazi zamykaci obrazovka (pouze v PWA rezimu).</p>
</div>
{/* Preview lockscreen button */}
<Link
href="/lockscreen"
className="mt-4 w-full py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-center block hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
Nahled zamykaci obrazovky
</Link>
{/* Save widget config */}
<button
onClick={saveWidgetConfig}
className={`mt-3 w-full py-2.5 rounded-xl font-medium transition-all text-sm ${
widgetSaved ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{widgetSaved ? "Ulozeno \u2713" : "Ulozit nastaveni widgetu"}
</button>
</div>
{/* Groups settings */}
{groups.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
Skupiny
</div>
{groups.map(group => (
<div key={group.id} style={{
background: "#13131A", border: `1px solid #2A2A3A`,
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
borderRadius: 12, marginBottom: 8, overflow: "hidden",
}}>
<div onClick={() => toggleGroup(group.id)}
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
{group.display_name || group.name}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>
{group.time_zones?.[0] ? `${group.time_zones[0].from}${group.time_zones[0].to}` : ""}
</span>
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
</div>
{expandedGroup === group.id && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
{/* CAS AKTIVITY */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Čas aktivity (volitelné)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="time" value={groupSettings[group.id]?.from || ""}
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<span style={{ color: "#6B6B85" }}></span>
<input type="time" value={groupSettings[group.id]?.to || ""}
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
</div>
{/* DNY V TYDNU */}
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
const active = (groupSettings[group.id]?.days || []).includes(i);
return (
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
color: active ? (group.color || "#4F46E5") : "#6B6B85",
cursor: "pointer",
}}>{d}</button>
);
})}
</div>
</div>
{/* GPS MISTO */}
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
Místo výkonu (volitelné)
</div>
<input
placeholder="Název místa (např. Synagoga, Kancelář...)"
value={groupSettings[group.id]?.locationName || ""}
onChange={e => updateGroupSetting(group.id, "locationName", e.target.value)}
style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<input
placeholder="GPS souřadnice (lat, lng)"
value={groupSettings[group.id]?.gps || ""}
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
/>
<button onClick={() => getCurrentLocation(group.id)}
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
Moje GPS
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
<input type="range" min="50" max="1000" step="50"
value={groupSettings[group.id]?.radius || 200}
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
{groupSettings[group.id]?.radius || 200}m
</span>
</div>
</div>
{/* ULOZIT */}
<button onClick={() => saveGroupSettings(group.id)}
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Save button */}
<button
onClick={handleSave}
className={`w-full py-3 rounded-xl font-medium transition-all ${
saved
? "bg-green-600 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{saved ? t("settings.saved") : t("settings.save")}
</button>
{/* Logout */}
<button
onClick={handleLogout}
className="w-full py-3 rounded-xl font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
{t("auth.logout")}
</button>
{/* App info */}
<div className="text-center text-xs text-muted py-4">
<p>{t("common.appName")} {t("common.appVersion")}</p>
</div>
<section className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-200 dark:border-red-800 mt-6">
<h2 className="font-semibold text-red-600 dark:text-red-400 mb-2">Smazat ucet</h2>
<p className="text-sm text-red-500 mb-3">Trvale smazat ucet a vsechna data. Tuto akci nelze vratit.</p>
<button onClick={() => {
if (confirm("Opravdu chcete trvale smazat svuj ucet a vsechna data?")) {
const token = localStorage.getItem("taskteam_token");
fetch("/api/v1/auth/delete-account", { method: "DELETE", headers: { Authorization: "Bearer " + token } })
.then(r => r.json())
.then(() => { localStorage.clear(); window.location.href = "/login"; });
}
}} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium">
Smazat ucet
</button>
</section>
</div>
);
}
// --- 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(/=+$/, "");
}