diff --git a/app.json b/app.json index 449c754..80aa4d1 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Scale Manager", "slug": "scaleManager", - "version": "1.2.4", + "version": "1.2.7", "orientation": "portrait", "icon": "./assets/images/noBgScaleManagerLogo.png", "scheme": "myapp", @@ -41,7 +41,8 @@ } ], "expo-font", - "expo-web-browser" + "expo-web-browser", + "expo-status-bar" ], "experiments": { "typedRoutes": true diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 69be916..8871524 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,14 +1,14 @@ import { Tabs } from "expo-router"; -import { MaterialCommunityIcons, Entypo } from "@expo/vector-icons"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { getServerConfig } from "../utils/getServer"; import { useEffect, useState } from "react"; import { isV026OrHigher } from "../utils/headscaleVersion"; - - - +import { useTheme } from "@/theme"; +import { spacing } from "@/theme/spacing"; export default function TabLayout() { const [hideRoutes, setHideRoutes] = useState(false); + const { theme } = useTheme(); useEffect(() => { async function checkVersion() { @@ -23,8 +23,23 @@ export default function TabLayout() { return ( @@ -34,7 +49,7 @@ export default function TabLayout() { title: "Home", headerShown: false, tabBarIcon: ({ color }) => ( - + ), }} /> @@ -45,7 +60,7 @@ export default function TabLayout() { title: "Users", headerShown: false, tabBarIcon: ({ color }) => ( - + ), }} /> @@ -56,7 +71,7 @@ export default function TabLayout() { title: "Devices", headerShown: false, tabBarIcon: ({ color }) => ( - + ), }} /> @@ -67,7 +82,7 @@ export default function TabLayout() { title: "Auth Keys", headerShown: false, tabBarIcon: ({ color }) => ( - + ), }} /> @@ -78,7 +93,7 @@ export default function TabLayout() { title: "API Keys", headerShown: false, tabBarIcon: ({ color }) => ( - + ), }} /> @@ -88,13 +103,12 @@ export default function TabLayout() { options={{ title: "Routes", headerShown: false, - // Hide the tab completely when hideRoutes is true - href: hideRoutes ? null : '/routes', + href: hideRoutes ? null : "/routes", tabBarIcon: ({ color }) => ( ), }} @@ -109,11 +123,11 @@ export default function TabLayout() { ), }} /> ); -} \ No newline at end of file +} diff --git a/app/(tabs)/acl.tsx b/app/(tabs)/acl.tsx index 160b085..db1cb08 100644 --- a/app/(tabs)/acl.tsx +++ b/app/(tabs)/acl.tsx @@ -13,8 +13,11 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import SetupGuideModal from "@/components/SetupGuideModal"; import { useACL } from "@/hooks/useACL"; +import { useTheme } from "@/theme"; export default function ACLScreen() { + const { theme } = useTheme(); + const colors = theme.colors; const { // State policy, @@ -39,10 +42,11 @@ export default function ACLScreen() { setShowVersions, setShowSetupGuide, setEditText, + serverVersion, } = useACL(); return ( - + } @@ -52,44 +56,48 @@ export default function ACLScreen() { - ACL Policy - Access Control List Management + ACL Policy + Access Control List Management + {serverVersion?.startsWith("0.29") && ( + + v0.29: supports grants, nodeAttrs, tests & sshTests. Policy is checked before save. + + )} setShowSetupGuide(true)} - className="bg-green-600 p-3 rounded-xl shadow-lg" + className="p-3 rounded-xl shadow-lg" style={{ backgroundColor: colors.success }} activeOpacity={0.8} > - + setShowVersions(true)} - className={`p-3 rounded-xl shadow-lg ${ - policyVersions.length === 0 ? 'bg-zinc-700' : 'bg-purple-600' - }`} + className="p-3 rounded-xl shadow-lg" + style={{ backgroundColor: policyVersions.length === 0 ? colors.surfaceMuted : colors.primary }} disabled={policyVersions.length === 0} activeOpacity={0.8} > 0 ? "white" : "#6b7280"} + color={policyVersions.length > 0 ? colors.onPrimary : colors.muted} /> @@ -97,9 +105,9 @@ export default function ACLScreen() { {/* Version count badge */} {policyVersions.length > 0 && ( - - - + + + {policyVersions.length} saved version{policyVersions.length !== 1 ? 's' : ''} @@ -108,25 +116,25 @@ export default function ACLScreen() { {/* Setup Help Banner - Only show if no policy */} {!policy && !loading && ( - + - - + + - + Need Help Setting Up ACL? - + If you're having trouble accessing your ACL policy, you might need to configure Headscale properly. Our setup guide will walk you through it step-by-step. setShowSetupGuide(true)} - className="bg-blue-600 px-5 py-3 rounded-xl flex-row items-center self-start shadow-md" + className="px-5 py-3 rounded-xl flex-row items-center self-start shadow-md" style={{ backgroundColor: colors.primary }} activeOpacity={0.8} > - - View Setup Guide + + View Setup Guide @@ -134,18 +142,18 @@ export default function ACLScreen() { )} {/* Policy Display */} - + - - + + - Current Policy + Current Policy {loading && ( - - Loading... + + Loading... )} @@ -154,61 +162,60 @@ export default function ACLScreen() { - + {policy} ) : ( - - - + + + - No Policy Loaded - Pull to refresh or check setup + No Policy Loaded + Pull to refresh or check setup )} {/* Quick Actions */} - Quick Actions + Quick Actions - - + + - Refresh + Refresh setShowVersions(true)} - className="bg-zinc-800 flex-1 min-w-[45%] p-4 rounded-xl border border-zinc-700 flex-row items-center shadow-md" + className="flex-1 min-w-[45%] p-4 rounded-xl border flex-row items-center shadow-md" style={{ backgroundColor: colors.surface, borderColor: colors.border }} disabled={policyVersions.length === 0} activeOpacity={0.7} > - 0 ? 'bg-purple-600' : 'bg-zinc-700' - }`}> + 0 ? colors.primary : colors.surfaceMuted }} + > 0 ? "white" : "#6b7280"} + color={policyVersions.length > 0 ? colors.onPrimary : colors.muted} /> - 0 ? 'text-white' : 'text-gray-500' - }`}> + 0 ? colors.text : colors.muted }}> Versions {policyVersions.length > 0 && ( - + {policyVersions.length} saved )} @@ -224,16 +231,16 @@ export default function ACLScreen() { animationType="slide" presentationStyle="pageSheet" > - - + + - Cancel + Cancel - Edit Policy + Edit Policy {saving ? ( - + ) : ( - Save + Save )} - - - + + + {editText.split('\n').length} lines • {editText.length} characters @@ -260,10 +267,10 @@ export default function ACLScreen() { onChangeText={setEditText} multiline textAlignVertical="top" - className="bg-zinc-900 text-white p-4 font-mono text-sm" - style={{ minHeight: 500 }} + className="p-4 font-mono text-sm" + style={{ backgroundColor: colors.background, color: colors.text, minHeight: 500 }} placeholder="Enter your ACL policy JSON here..." - placeholderTextColor="#4b5563" + placeholderTextColor={colors.muted} autoCapitalize="none" autoCorrect={false} spellCheck={false} @@ -279,40 +286,40 @@ export default function ACLScreen() { animationType="slide" presentationStyle="pageSheet" > - - + + setShowVersions(false)} className="px-4 py-2" activeOpacity={0.7} > - Close + Close - Version History + Version History {policyVersions.length === 0 ? ( - - + + - No Saved Versions - + No Saved Versions + Versions are saved automatically when you update ) : ( policyVersions.map((version, index) => ( - + - - v{policyVersions.length - index} + + v{policyVersions.length - index} - + {version.timestamp.toLocaleString()} @@ -322,23 +329,23 @@ export default function ACLScreen() { className="p-2" activeOpacity={0.7} > - + - - + + {version.policy.substring(0, 200) + (version.policy.length > 200 ? '...' : '')} restoreVersion(version)} - className="bg-purple-600 py-3 px-4 rounded-xl flex-row items-center justify-center shadow-md" + className="py-3 px-4 rounded-xl flex-row items-center justify-center shadow-md" style={{ backgroundColor: colors.primary }} activeOpacity={0.8} > - - Restore This Version + + Restore This Version )) diff --git a/app/(tabs)/apikeys.tsx b/app/(tabs)/apikeys.tsx index 27c402b..3916ee1 100644 --- a/app/(tabs)/apikeys.tsx +++ b/app/(tabs)/apikeys.tsx @@ -1,13 +1,81 @@ import React, { useEffect, useState } from "react"; import { Text, View, TouchableOpacity, - TextInput, ScrollView, ActivityIndicator, RefreshControl, + TextInput, ScrollView, RefreshControl, Alert, Modal, Clipboard } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import Toast from "react-native-toast-message"; import { useApiKeys } from "@/app/funcs/apikeys"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; + +function KeyDisplayModal({ + visible, + apiKey, + onClose, + onCopy, +}: { + visible: boolean; + apiKey: string | null; + onClose: () => void; + onCopy: (value: string) => void; +}) { + const { theme } = useTheme(); + const colors = theme.colors; + return ( + + + + + + + New API Key Created + + + Save this key securely - it won't be shown again + + + + + + {apiKey} + + + + + apiKey && onCopy(apiKey)} + className="flex-1 py-3 rounded-lg flex-row items-center justify-center" style={{ backgroundColor: colors.primary }} + activeOpacity={0.7} + > + + Copy Key + + + + Done + + + + + This is the only time you'll see the full API key + + + + + ); +} export default function ApiKeysScreen() { const { @@ -21,6 +89,8 @@ export default function ApiKeysScreen() { handleExpireKey, isExpired, } = useApiKeys(); + const { theme } = useTheme(); + const colors = theme.colors; const [newApiKey, setNewApiKey] = useState(null); const [showKeyModal, setShowKeyModal] = useState(false); @@ -63,65 +133,10 @@ export default function ApiKeysScreen() { setNewApiKey(null); }; - const KeyDisplayModal = () => ( - - - - - - - New API Key Created - - - Save this key securely - it won't be shown again - - - - - - {newApiKey} - - - - - copyToClipboard(newApiKey!)} - className="flex-1 bg-blue-600 py-3 rounded-lg flex-row items-center justify-center" - activeOpacity={0.7} - > - - Copy Key - - - - Done - - - - - This is the only time you'll see the full API key - - - - - ); - return ( - + {loading ? ( - - - Loading API Keys... - + ) : ( {/* Header */} - API Keys + API Keys - + {/* Active Key Status */} - + - - + + Current Key Status - + Active Key Expires: {activeKeyExpire || "Unknown"} {/* Create New Key */} - - + + Create New API Key - Expiration Time + Expiration Time - + Examples: 1h (1 hour), 7d (7 days), 90d (90 days), 1y (1 year) - - + + Create New Key @@ -184,17 +199,17 @@ export default function ApiKeysScreen() { {/* Existing Keys */} - + Existing Keys ({apiKeys.length}) {apiKeys.length === 0 ? ( - - - + + + No API keys found - + Create your first API key above @@ -204,27 +219,28 @@ export default function ApiKeysScreen() { return ( - + Key #{index + 1} - - + + {expired ? 'EXPIRED' : 'ACTIVE'} @@ -232,18 +248,18 @@ export default function ApiKeysScreen() { - Prefix: - {key.prefix} + Prefix: + {key.prefix} - Created: - + Created: + {new Date(key.createdAt).toLocaleString()} - Expires: - + Expires: + {new Date(key.expiration).toLocaleString()} @@ -259,23 +275,22 @@ export default function ApiKeysScreen() { { text: "Expire Key", style: "destructive", - onPress: () => handleExpireKey(key.prefix), + onPress: () => handleExpireKey({ id: key.id, prefix: key.prefix }), }, ] ); }} disabled={expired} - className={`p-3 rounded-lg flex-row items-center justify-center ${ - expired ? "bg-zinc-600" : "bg-red-600" - }`} + className="p-3 rounded-lg flex-row items-center justify-center" + style={{ backgroundColor: expired ? colors.secondaryPressed : colors.error }} activeOpacity={0.7} > - + {expired ? "Already Expired" : "Expire Key"} @@ -287,7 +302,12 @@ export default function ApiKeysScreen() { )} - + ); } \ No newline at end of file diff --git a/app/(tabs)/devices.tsx b/app/(tabs)/devices.tsx index 7f47f19..c1abdaf 100644 --- a/app/(tabs)/devices.tsx +++ b/app/(tabs)/devices.tsx @@ -4,7 +4,6 @@ import { View, ScrollView, TouchableOpacity, - ActivityIndicator, RefreshControl, TextInput, } from "react-native"; @@ -12,6 +11,8 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import { useDevices } from "@/app/funcs/devices"; import { RegisterDeviceModal } from "@/app/components/RegisterDeviceModal"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; export default function DevicesScreen() { const { @@ -33,7 +34,12 @@ export default function DevicesScreen() { setDeviceKey, handleModalClose, handleModalRegister, + handleModalApprove, + handleModalReject, + serverVersion, } = useDevices(); + const { theme } = useTheme(); + const { colors } = theme; const [searchQuery, setSearchQuery] = useState(""); const [filterOnline, setFilterOnline] = useState<"all" | "online" | "offline">("all"); @@ -61,37 +67,36 @@ export default function DevicesScreen() { return ( - + {loading ? ( - - - Loading Devices... - + ) : ( } + refreshControl={} > {/* Header */} - Devices - + Devices + {getOnlineDevicesCount()} online • {devices.length} total - - + + - - Register + + Register @@ -99,18 +104,19 @@ export default function DevicesScreen() { {/* Search and Filter */} {/* Search Bar */} - - + + {searchQuery.length > 0 && ( - setSearchQuery("")}> - + setSearchQuery("")} activeOpacity={0.7}> + )} @@ -128,13 +134,11 @@ export default function DevicesScreen() { setFilterOnline(filter.key as any)} - className={`px-3 mr-2 py-2 rounded-lg ${ - filterOnline === filter.key ? 'bg-blue-600' : 'bg-zinc-700' - }`} + activeOpacity={0.8} + className="px-3 mr-2 py-2 rounded-lg" + style={{ backgroundColor: filterOnline === filter.key ? colors.primary : colors.surfaceMuted }} > - + {filter.label} ({filter.count}) @@ -147,10 +151,12 @@ export default function DevicesScreen() { const nextSort = sortBy === "name" ? "lastSeen" : sortBy === "lastSeen" ? "user" : "name"; setSortBy(nextSort); }} - className="bg-zinc-700 px-3 mt-2 rounded-lg flex-row items-center" + activeOpacity={0.8} + className="px-3 mt-2 rounded-lg flex-row items-center" + style={{ backgroundColor: colors.surfaceMuted }} > - - {sortBy} + + {sortBy} @@ -158,11 +164,11 @@ export default function DevicesScreen() { {/* Devices List */} {filteredAndSortedDevices.length === 0 ? ( - - + + {searchQuery ? "No devices match your search" : "No Devices Found"} - + {searchQuery ? "Try adjusting your search terms" : "Register your first device to get started"} @@ -173,39 +179,39 @@ export default function DevicesScreen() { handleDevicePress(device)} - className="bg-zinc-800 rounded-xl p-4 mb-3 border border-zinc-700" + className="rounded-xl p-4 mb-3 border" + style={{ backgroundColor: colors.surface, borderColor: colors.border }} activeOpacity={0.7} > {/* Device Header */} - + - + {device.givenName || device.name || "Unnamed Device"} - + {device.user?.name || "Unknown User"} - {/* Online Status Badge */} - - + + {device.online ? 'ONLINE' : 'OFFLINE'} @@ -214,20 +220,20 @@ export default function DevicesScreen() { {/* Device Details */} - + IP: {device.ipAddresses?.[0] || "N/A"} - + Last Seen: {getLastSeenText(device.lastSeen)} {device.registerMethod && ( - + Method: {device.registerMethod.replace('REGISTER_METHOD_', '').replace('_', ' ')} @@ -235,11 +241,11 @@ export default function DevicesScreen() { {/* Routes Info */} {(device.approvedRoutes?.length > 0 || device.availableRoutes?.length > 0) && ( - + {device.approvedRoutes?.length > 0 && ( - Routes: - + Routes: + {device.approvedRoutes.slice(0, 2).join(', ')} {device.approvedRoutes.length > 2 && ` +${device.approvedRoutes.length - 2} more`} @@ -252,13 +258,13 @@ export default function DevicesScreen() { {displayTags.length > 0 && ( {displayTags.slice(0, 3).map((tag, index) => ( - - {tag} + + {tag} ))} {displayTags.length > 3 && ( - - + + +{displayTags.length - 3} @@ -269,8 +275,8 @@ export default function DevicesScreen() { {/* Tap to View Indicator */} - Tap for details - + Tap for details + )}) @@ -288,6 +294,9 @@ export default function DevicesScreen() { deviceKey={deviceKey} onKeyChange={setDeviceKey} onRegister={handleModalRegister} + serverVersion={serverVersion} + onApprove={handleModalApprove} + onReject={handleModalReject} /> ); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 193c69f..81a27a9 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,21 +1,67 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Text, View, + Pressable, TouchableOpacity, - ActivityIndicator, ScrollView, RefreshControl, Linking, Alert, + StyleSheet, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useRouter } from "expo-router"; -import { FontAwesome, MaterialIcons, Ionicons } from "@expo/vector-icons"; +import { FontAwesome, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; import { useDashboardData } from "@/app/funcs/tabsHome"; +import { exportFrontendLogs } from "@/app/utils/frontendLog"; +import { useTheme } from "@/theme"; +import Toast from "react-native-toast-message"; + +const ICON_SIZE = 20; + +function SkeletonBar({ + width, + height = 14, +}: { + width: number | `${number}%`; + height?: number; +}) { + const { theme } = useTheme(); + const opacity = useSharedValue(0.4); + + useEffect(() => { + opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true); + }, [opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + + ); +} export default function IndexScreen() { const router = useRouter(); + const { theme, toggleTheme } = useTheme(); + const { colors, spacing, radii, shadows, typography } = theme; + const [exportingLogs, setExportingLogs] = useState(false); const { devices, usersCount, @@ -29,6 +75,8 @@ export default function IndexScreen() { handleSignOut } = useDashboardData(); + const showSkeleton = loading && devices.length === 0 && usersCount === 0; + const onRefresh = async () => { await measurePing(); await fetchData(); @@ -55,7 +103,8 @@ export default function IndexScreen() { useEffect(() => { measurePing(); fetchData(); - }, []); + }, [measurePing, fetchData]); + const handleGitHub = async () => { const url = "https://github.com/loqtek/ScaleManager/issues"; const supported = await Linking.canOpenURL(url); @@ -71,6 +120,30 @@ export default function IndexScreen() { } }; + const handleExportLogs = async () => { + if (exportingLogs) return; + setExportingLogs(true); + try { + await exportFrontendLogs(); + Toast.show({ + type: "success", + position: "top", + text1: "Logs ready", + text2: "Save or share the log file to debug this session.", + }); + } catch (error) { + console.error("Failed to export logs:", error); + Toast.show({ + type: "error", + position: "top", + text1: "Export failed", + text2: "Could not write the log file.", + }); + } finally { + setExportingLogs(false); + } + }; + const handleStarRepo = async () => { const url = "https://github.com/loqtek/ScaleManager"; const supported = await Linking.canOpenURL(url); @@ -86,116 +159,216 @@ export default function IndexScreen() { } }; - useEffect(() => { - measurePing(); - fetchData(); - }, []); + const card = { + backgroundColor: colors.surface, + borderRadius: radii.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + padding: spacing.lg, + marginBottom: spacing.xl, + ...shadows.sm, + }; + + const quickActions: { + label: string; + route: "/(tabs)/users" | "/(tabs)/devices" | "/(tabs)/preauthkeys" | "/(tabs)/apikeys" | "/(tabs)/acl"; + icon: React.ComponentProps["name"]; + }[] = [ + { label: "Users", route: "/(tabs)/users", icon: "people" }, + { label: "Devices", route: "/(tabs)/devices", icon: "devices" }, + { label: "Auth Keys", route: "/(tabs)/preauthkeys", icon: "vpn-key" }, + { label: "API Keys", route: "/(tabs)/apikeys", icon: "key" }, + { label: "ACL", route: "/(tabs)/acl", icon: "security" }, + ]; return ( - + } - contentContainerStyle={{ paddingBottom: 40 }} + style={{ flex: 1, paddingHorizontal: spacing.lg, paddingTop: spacing.lg }} + refreshControl={ + + } + contentContainerStyle={{ paddingBottom: spacing.xxxl }} > - {/* Header */} - - - Scale Manager - Headscale Network Dashboard + + + Scale Manager + + Headscale Network Dashboard + - - - - - - - + + ({ + padding: spacing.sm, + borderRadius: radii.full, + backgroundColor: pressed ? colors.surfaceMuted : colors.surface, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + opacity: pressed ? 0.8 : 1, + })} + > + + + ({ + padding: spacing.sm, + borderRadius: radii.full, + opacity: pressed ? 0.6 : 1, + })} + > + + + ({ + padding: spacing.sm, + borderRadius: radii.full, + opacity: pressed ? 0.6 : 1, + })} + > + + - {/* Server Status Card */} - - - - Server Status + + + + Server Status {loading ? ( - + + + ) : ( - + {pingTime !== null ? `${pingTime}ms response time` : "No connection"} )} - ({ + backgroundColor: pressed ? colors.secondaryPressed : colors.secondary, + padding: spacing.md, + borderRadius: radii.md, + opacity: pressed ? 0.85 : 1, + })} > - - + + - {/* Overview Stats */} - - - Network Overview - + + Network Overview + ({ + backgroundColor: pressed ? colors.secondaryPressed : colors.secondary, + padding: spacing.sm, + borderRadius: radii.md, + opacity: pressed ? 0.85 : 1, + })} > - - + + - - - - - Users - - {usersCount} - - - - - - Online - - {onlineDevices.length} - - - - - - Offline - - {offlineDevices.length} - - - - - - Total + + {( + [ + { label: "Users", value: usersCount, icon: "account-group" as const, tint: colors.primaryMuted }, + { label: "Online", value: onlineDevices.length, icon: "wifi" as const, tint: colors.success }, + { label: "Offline", value: offlineDevices.length, icon: "wifi-off" as const, tint: colors.error }, + { label: "Total", value: devices.length, icon: "server" as const, tint: colors.primaryMuted }, + ] + ).map((stat) => ( + + + + + {stat.label} + + + {showSkeleton ? ( + + + + ) : ( + + {stat.value} + + )} - {devices.length} - + ))} - {/* Recent Activity */} {topActiveDevices.length > 0 && ( - - Recent Activity - {topActiveDevices.map((device) => ( - - - - + + + Recent Activity + + {topActiveDevices.map((device, index) => ( + + + + + + {device.givenName || device.name} - + {new Date(device.lastSeen).toLocaleString()} @@ -204,24 +377,21 @@ export default function IndexScreen() { )} - {/* Quick Actions */} - - Quick Actions + + + Quick Actions + - {[ - { label: "Users", route: "/(tabs)/users" as const, icon: "people" as const }, - { label: "Devices", route: "/(tabs)/devices" as const, icon: "devices" as const }, - { label: "Auth Keys", route: "/(tabs)/preauthkeys" as const, icon: "vpn-key" as const }, - { label: "API Keys", route: "/(tabs)/apikeys" as const, icon: "key" as const }, - { label: "ACL", route: "/(tabs)/acl" as const, icon: "security" as const }, - ].map((item) => ( + {quickActions.map((item) => ( router.push(item.route as any)} - className="bg-zinc-800 w-[48%] p-4 rounded-xl mb-3 border border-zinc-700 flex-row items-center" + activeOpacity={0.8} + className="w-[48%] p-4 rounded-xl mb-3 border flex-row items-center" + style={{ backgroundColor: colors.surface, borderColor: colors.border }} > - - + + {item.label} @@ -229,17 +399,23 @@ export default function IndexScreen() { - {/* GitHub Footer Section */} - - - - - + + + + + Help Improve Scale Manager - + Found a bug or have a feature idea? We'd love to hear from you! Your feedback helps make this app better for everyone. @@ -247,42 +423,62 @@ export default function IndexScreen() { - - + + Report Issues & Suggestions + + + + {exportingLogs ? "Preparing log file..." : "Export debug logs"} + + + - - + + Star on GitHub + - - + + Join Our Discord - - + github.com/loqtek/ScaleManager - + discord.gg/fRMxHmwm4z @@ -290,4 +486,4 @@ export default function IndexScreen() { ); -} \ No newline at end of file +} diff --git a/app/(tabs)/preauthkeys.tsx b/app/(tabs)/preauthkeys.tsx index 3c48fa5..83a5616 100644 --- a/app/(tabs)/preauthkeys.tsx +++ b/app/(tabs)/preauthkeys.tsx @@ -1,14 +1,19 @@ import React, { useEffect, useState } from "react"; import { Text, View, ScrollView, TouchableOpacity, - ActivityIndicator, RefreshControl, Modal, TextInput, Alert, Clipboard + RefreshControl, Modal, TextInput, Alert, Clipboard, + KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import Toast from "react-native-toast-message"; import { usePreAuthManager } from "@/app/funcs/preauthkeys"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; export default function PreAuthKeysScreen() { + const { theme } = useTheme(); + const colors = theme.colors; const { users, preAuthKeys, @@ -27,7 +32,7 @@ export default function PreAuthKeysScreen() { useEffect(() => { fetchData(); - }, []); + }, [fetchData]); const copyToClipboard = async (text: string, label: string) => { try { @@ -72,10 +77,10 @@ export default function PreAuthKeysScreen() { }; const getKeyStatus = (key: any) => { - if (!key.used && !isExpired(key.expiration)) return { status: "Active", color: "text-green-400", bg: "bg-green-500/20" }; - if (key.used) return { status: "Used", color: "text-blue-400", bg: "bg-blue-500/20" }; - if (isExpired(key.expiration)) return { status: "Expired", color: "text-red-400", bg: "bg-red-500/20" }; - return { status: "Unknown", color: "text-gray-400", bg: "bg-gray-500/20" }; + if (!key.used && !isExpired(key.expiration)) return { status: "Active", color: colors.success, bg: colors.successSoft }; + if (key.used) return { status: "Used", color: colors.primaryMuted, bg: colors.primarySoft }; + if (isExpired(key.expiration)) return { status: "Expired", color: colors.error, bg: colors.errorSoft }; + return { status: "Unknown", color: colors.muted, bg: colors.surfaceMuted }; }; const toggleUserExpansion = (userName: string) => { @@ -130,12 +135,9 @@ export default function PreAuthKeysScreen() { }; return ( - + {loading ? ( - - - Loading Pre-Auth Keys... - + ) : ( - Pre-Auth Keys - + Pre-Auth Keys + API Version: {apiVersion} • {users.length} users - + setShowCreateModal(true)} - className="bg-blue-600 py-2 px-4 rounded-lg flex-row items-center" + className="py-2 px-4 rounded-lg flex-row items-center" style={{ backgroundColor: colors.primary }} > - - Create Key + + Create Key @@ -168,9 +170,9 @@ export default function PreAuthKeysScreen() { {/* Users List */} {users.length === 0 ? ( - - No Users Found - Create users first to generate pre-auth keys + + No Users Found + Create users first to generate pre-auth keys ) : ( users.map((user: any) => { @@ -180,57 +182,57 @@ export default function PreAuthKeysScreen() { const isExpanded = expandedUsers.has(user.name); return ( - + {/* User Header */} toggleUserExpansion(user.name)} className="flex-row items-center justify-between mb-3" > - - + + - {user.name} - ID: {user.id} + {user.name} + ID: {user.id} - {activeKeys} - Active + {activeKeys} + Active - {totalKeys} - Total + {totalKeys} + Total {/* Keys List (Expandable) */} {isExpanded && ( - + {userKeys.length === 0 ? ( - No keys found for this user + No keys found for this user ) : ( userKeys.map((key: any) => { const keyStatus = getKeyStatus(key); return ( - + {/* Key Header */} - Key #{key.id} - - + Key #{key.id} + + {keyStatus.status} @@ -239,26 +241,26 @@ export default function PreAuthKeysScreen() { {/* Key Properties */} - Reusable: - + Reusable: + {key.reusable ? "Yes" : "No"} - Used: - + Used: + {key.used ? "Yes" : "No"} - Created: - + Created: + {formatDate(key.createdAt)} - Expires: - + Expires: + {formatDate(key.expiration)} @@ -270,18 +272,18 @@ export default function PreAuthKeysScreen() { {!!key.key && ( copyToClipboard(key.key, "Pre-auth key")} - className="bg-blue-600 p-2 rounded" + className="p-2 rounded" style={{ backgroundColor: colors.primary }} > - + )} {!key.used && !isExpired(key.expiration) && ( confirmExpireKey(String(key.id), user.name)} - className="bg-red-600 p-2 rounded ml-2" + className="p-2 rounded ml-2" style={{ backgroundColor: colors.error }} > - + )} @@ -289,14 +291,14 @@ export default function PreAuthKeysScreen() { {/* Key Value */} {!!key.key && ( - + - Pre-Auth Key: + Pre-Auth Key: copyToClipboard(key.key, "Key")}> - Tap to copy + Tap to copy - + {key.key} @@ -315,25 +317,42 @@ export default function PreAuthKeysScreen() { )} {/* Create Key Modal */} - - - - Create Pre-Auth Key + { + setShowCreateModal(false); + setSelectedUser(""); + setExpireTime("24h"); + setIsReusable(false); + }} + > + + + + + Create Pre-Auth Key {/* User Selection */} - Select User: - + Select User: + {users.map((user: any) => ( setSelectedUser(user.name)} - className={`p-3 border-b border-zinc-600 ${ - selectedUser === user.name ? 'bg-blue-600' : '' - }`} + activeOpacity={0.8} + className="p-3 rounded-lg mb-2" + style={{ + backgroundColor: selectedUser === user.name ? colors.primary : colors.surfaceMuted, + }} > - {user.name} - ID: {user.id} + {user.name} + ID: {user.id} ))} @@ -341,15 +360,15 @@ export default function PreAuthKeysScreen() { {/* Expiration Time */} - Expiration Time: + Expiration Time: - + Examples: 1h (1 hour), 24h (24 hours), 7d (7 days), 30d (30 days) @@ -362,11 +381,11 @@ export default function PreAuthKeysScreen() { - Reusable Key - Allow this key to be used multiple times + Reusable Key + Allow this key to be used multiple times @@ -379,20 +398,22 @@ export default function PreAuthKeysScreen() { setExpireTime("24h"); setIsReusable(false); }} - className="flex-1 bg-gray-600 py-3 rounded-lg" + className="flex-1 py-3 rounded-lg" style={{ backgroundColor: colors.secondary }} > - Cancel + Cancel - Create Key + Create Key - + + + ); diff --git a/app/(tabs)/routes.tsx b/app/(tabs)/routes.tsx index 3b2b2a8..7c4e26e 100644 --- a/app/(tabs)/routes.tsx +++ b/app/(tabs)/routes.tsx @@ -3,7 +3,6 @@ import { Text, View, ScrollView, - ActivityIndicator, TouchableOpacity, Alert, RefreshControl, @@ -11,12 +10,16 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import { useRoutes } from "@/app/funcs/routes"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; function formatDate(dateString: string) { return new Date(dateString).toLocaleString(); } export default function RoutesScreen() { + const { theme } = useTheme(); + const { colors } = theme; const { handleDisableRoute, handleEnableRoute, @@ -31,40 +34,33 @@ export default function RoutesScreen() { useEffect(() => { fetchRoutes(); - }, []); + }, [fetchRoutes]); if (loading) { return ( - - - Loading Routes... + + ); } return ( - - {loading ? ( - - - Loading Routes... - - ) : ( + + } > - - - - + + Routes + + {routes.length === 0 && ( - + No routes found. )} @@ -72,50 +68,54 @@ export default function RoutesScreen() { {routes.map((route: any) => ( - + {route.prefix} - Advertised: - + Advertised: + {route.advertised ? "✅ Yes" : "❌ No"} - Enabled: - + Enabled: + {route.enabled ? "✅ Yes" : "❌ No"} - Primary: - + Primary: + {route.isPrimary ? "🌟 Primary" : "—"} - Created: - + Created: + {formatDate(route.createdAt)} - Updated: - + Updated: + {formatDate(route.updatedAt)} Alert.alert( "Enable Route", @@ -131,9 +131,10 @@ export default function RoutesScreen() { ) } > - + Alert.alert( "Disable Route", @@ -149,13 +150,12 @@ export default function RoutesScreen() { ) } > - + ))} - )} ); } diff --git a/app/(tabs)/users.tsx b/app/(tabs)/users.tsx index b0e176a..553b066 100644 --- a/app/(tabs)/users.tsx +++ b/app/(tabs)/users.tsx @@ -1,13 +1,17 @@ import React from "react"; import { - Text, View, ScrollView, ActivityIndicator, + Text, View, ScrollView, TouchableOpacity, RefreshControl, Alert } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import { useUsers } from "@/app/funcs/users"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; export default function UsersScreen() { + const { theme } = useTheme(); + const { colors } = theme; const { users, loading, @@ -20,7 +24,7 @@ export default function UsersScreen() { const confirmDelete = (user: any) => { const deviceCount = getUserDeviceCount(user.id); - + Alert.alert( "Delete User", `Are you sure you want to delete "${user.name}"?\n\n` + @@ -51,134 +55,132 @@ export default function UsersScreen() { }; return ( - + {loading ? ( - - - Loading Users... - + ) : ( + } > - {/* Header */} - Users - + Users + {users.length} {users.length === 1 ? 'user' : 'users'} total - + - - + + - + - - Add User + + Add User - {/* Users List */} {users.length === 0 ? ( - - + + No Users Found - + Create your first user to get started ) : ( users.map((user) => { const deviceCount = getUserDeviceCount(user.id); - + return ( - {/* User Header */} - - + + - + {user.name || "Unnamed User"} - + ID: {user.id} - - {/* Device Count Badge */} - - - + + + + {deviceCount} - {/* User Details */} - + Created: {user.createdAt ? formatDate(user.createdAt) : "Unknown"} - + {user.displayName && ( - Display: - {user.displayName} + Display: + {user.displayName} )} - + {user.email && ( - Email: - {user.email} + Email: + {user.email} )} - + {user.provider && ( - Provider: - {user.provider} + Provider: + {user.provider} )} - {/* Action Buttons */} handleRenameUser(user.name, user.id)} - className="flex-1 bg-yellow-600 py-2 rounded-lg flex-row items-center justify-center mr-2" + activeOpacity={0.8} + className="flex-1 py-2 rounded-lg flex-row items-center justify-center mr-2" + style={{ backgroundColor: colors.warning }} > - - Rename + + Rename confirmDelete(user)} - className="flex-1 bg-red-600 py-2 rounded-lg flex-row items-center justify-center ml-2" + activeOpacity={0.8} + className="flex-1 py-2 rounded-lg flex-row items-center justify-center ml-2" + style={{ backgroundColor: colors.error }} > - - Delete + + Delete @@ -189,4 +191,4 @@ export default function UsersScreen() { )} ); -} \ No newline at end of file +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 91d496b..43c1756 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,18 +1,86 @@ import { Slot } from "expo-router"; +import { StatusBar } from "expo-status-bar"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { ThemeProvider, useTheme } from "@/theme"; +import { installFrontendLogger } from "./utils/frontendLog"; +import Toast, { + BaseToast, + ErrorToast, + InfoToast, + type ToastConfig, +} from "react-native-toast-message"; import "../global.css"; -import Toast from "react-native-toast-message"; -export default function RootLayout() { +installFrontendLogger(); + +function ThemedChrome() { + const { theme } = useTheme(); + const toastBaseStyle = { + height: null as unknown as number, + minHeight: 60, + paddingVertical: theme.spacing.md, + width: "90%" as const, + backgroundColor: theme.colors.surface, + borderLeftWidth: 4, + }; + + const toastConfig: ToastConfig = { + success: (props) => ( + + ), + error: (props) => ( + + ), + info: (props) => ( + + ), + }; + return ( - + <> + + + ); +} + +export default function RootLayout() { + return ( + + + + - ) + ); } diff --git a/app/accounts.tsx b/app/accounts.tsx index 965043d..47758d3 100644 --- a/app/accounts.tsx +++ b/app/accounts.tsx @@ -14,10 +14,98 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; import { useAccountsManager, HeadscaleVersion } from "@/app/funcs/accounts"; +import { HttpInsecureWarning, useHttpRiskAck } from "@/app/components/HttpInsecureWarning"; +import { useTheme } from "@/theme"; -const VERSION_OPTIONS: HeadscaleVersion[] = ["0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"]; +const VERSION_OPTIONS: HeadscaleVersion[] = ["0.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"]; + +function InfoButton({ + field, + showInfo, + onPress, +}: { + field: string; + showInfo: string | null; + onPress: () => void; +}) { + const { theme } = useTheme(); + return ( + + + + ); +} + +function InfoText({ + field, + showInfo, + children, +}: { + field: string; + showInfo: string | null; + children: React.ReactNode; +}) { + const { theme } = useTheme(); + if (showInfo !== field) return null; + return ( + + {children} + + ); +} + +function VersionSelector({ + currentVersion, + onVersionChange, +}: { + currentVersion: HeadscaleVersion; + onVersionChange: (version: HeadscaleVersion) => void; +}) { + const { theme } = useTheme(); + const { colors } = theme; + return ( + + {VERSION_OPTIONS.map((version) => { + const selected = currentVersion === version; + return ( + onVersionChange(version)} + activeOpacity={0.8} + className="mr-3 px-3 py-2 rounded-lg border-2" + style={{ + backgroundColor: selected ? colors.primary : colors.surfaceMuted, + borderColor: selected ? colors.primaryMuted : colors.border, + }} + > + + {version} + + + ); + })} + + ); +} export default function Accounts() { + const { theme } = useTheme(); + const { colors } = theme; const { accounts, loading, @@ -26,16 +114,17 @@ export default function Accounts() { handleAddAccount, updateAccountVersion, } = useAccountsManager(); - + const [modalVisible, setModalVisible] = useState(false); const [customName, setCustomName] = useState(""); const [server, setServer] = useState(""); const [apiKey, setApiKey] = useState(""); - const [selectedVersion, setSelectedVersion] = useState("0.26.x"); + const [selectedVersion, setSelectedVersion] = useState("0.29.x"); const [showInfo, setShowInfo] = useState(null); const [editingVersion, setEditingVersion] = useState(null); - + const router = useRouter(); + const httpRisk = useHttpRiskAck(server); const toggleInfo = (field: string) => { setShowInfo(showInfo === field ? null : field); @@ -47,6 +136,7 @@ export default function Accounts() { server, apiKey, version: selectedVersion, + httpRiskAcknowledged: httpRisk.canProceed, onSuccess: () => { setModalVisible(false); setCustomName(""); @@ -61,78 +151,23 @@ export default function Accounts() { return new Date(dateString).toLocaleDateString(); }; - const InfoButton = ({ field, onPress }: { field: string; onPress: () => void }) => ( - - - - ); - - const InfoText = ({ field, children }: { field: string; children: React.ReactNode }) => ( - showInfo === field ? ( - - - {children} - - - ) : null - ); - - const VersionSelector = ({ - currentVersion, - onVersionChange - }: { - currentVersion: HeadscaleVersion; - onVersionChange: (version: HeadscaleVersion) => void; - }) => ( - - {VERSION_OPTIONS.map((version) => ( - onVersionChange(version)} - className={`mr-3 px-3 py-2 rounded-lg border-2 ${ - currentVersion === version - ? 'bg-blue-600 border-blue-500' - : 'bg-zinc-700 border-zinc-600' - }`} - > - - {version} - - - ))} - - ); - return ( - - {/* Header */} + - router.push("/(tabs)")}> - {"< Back"} + router.push("/(tabs)")} activeOpacity={0.7}> + {"<- Back"} - setModalVisible(true)}> - + Add + setModalVisible(true)} activeOpacity={0.7}> + + Add - {/* Account List */} - Saved Servers - + Saved Servers + {accounts.length === 0 ? ( - + No servers saved yet.{'\n'}Tap "Add" to connect to your first Headscale server. @@ -140,49 +175,52 @@ export default function Accounts() { accounts.map((acc) => ( handleSelectAccount(acc.name)} className="flex-1 pr-4" + activeOpacity={0.7} > - + {acc.name} - + {acc.server} - + Added: {formatDate(acc.addedOn)} - - handleRemoveAccount(acc.name)} className="p-2" + activeOpacity={0.7} > - + - {/* Version Section */} - + - Headscale Version: + Headscale Version: setEditingVersion(editingVersion === acc.name ? null : acc.name)} + activeOpacity={0.7} > - - + {editingVersion === acc.name ? ( - { updateAccountVersion(acc.name, newVersion); @@ -190,8 +228,8 @@ export default function Accounts() { }} /> ) : ( - - {acc.version} + + {acc.version} )} @@ -200,103 +238,110 @@ export default function Accounts() { )} - {/* Add Account Modal */} - - - + + Add New Server - {/* Custom Name Input */} - Server Name - toggleInfo("name")} /> + Server Name + toggleInfo("name")} /> - + Give this server a memorable name to identify it easily. - {/* Server URL Input */} - Server URL - toggleInfo("server")} /> + Server URL + toggleInfo("server")} /> - + Enter the full URL including http:// or https:// + - {/* Version Selector */} - Headscale Version - toggleInfo("version")} /> + Headscale Version + toggleInfo("version")} /> - - + Select your server's Headscale version for API compatibility. Check with: {'\n'} - headscale version + headscale version - {/* API Key Input */} - API Key - toggleInfo("key")} /> + API Key + toggleInfo("key")} /> - - Generate with: {'\n'} - headscale apikey create --expiration 90d + + Generate a management API key (not a pre-auth key):{"\n"} + headscale apikeys create --expiration 90d + {"\n"} + v0.28+ format: hskey-api-… - {/* Buttons */} { setModalVisible(false); setShowInfo(null); @@ -306,17 +351,20 @@ export default function Accounts() { setSelectedVersion("0.26.x"); }} > - Cancel + Cancel - + {loading ? ( - + ) : ( - Add Server + Add Server )} @@ -326,4 +374,4 @@ export default function Accounts() { ); -} \ No newline at end of file +} diff --git a/app/api/acl.ts b/app/api/acl.ts index 7fe9025..72978c5 100644 --- a/app/api/acl.ts +++ b/app/api/acl.ts @@ -22,21 +22,20 @@ export async function updateACLPolicy(policy: any) { const config = await getApiEndpoints(); if (!config) return null; - const { endpoints, serverConf } = config; - + const { endpoints } = config; + const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy); - + const requestBody = JSON.stringify({ policy: policyString }); - + const updateConfig = endpoints.acl.updatePolicy(policy); - + const response = await makeApiRequest(updateConfig.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${serverConf.apiKey}`, }, body: requestBody, }); @@ -46,4 +45,31 @@ export async function updateACLPolicy(policy: any) { console.error("Update ACL policy error:", error); throw error; } -} \ No newline at end of file +} + +/** + * Validate a policy without applying it (v0.29+). + * Runs ACL/grants/ssh tests when present; returns server error payload on failure. + */ +export async function checkACLPolicy(policy: any) { + try { + const config = await getApiEndpoints(); + if (!config) return null; + + const check = config.endpoints.acl.checkPolicy; + if (!check) { + return { skipped: true }; + } + + const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy); + const apiCall = check(policyString); + + return await makeApiRequest(apiCall.url, { + method: apiCall.method, + body: JSON.stringify(apiCall.body), + }); + } catch (error) { + console.error("Check ACL policy error:", error); + throw error; + } +} diff --git a/app/api/apikeys.ts b/app/api/apikeys.ts index 00c0e19..3286ac1 100644 --- a/app/api/apikeys.ts +++ b/app/api/apikeys.ts @@ -1,4 +1,5 @@ import { getServerConfig } from "../utils/getServer"; +import { buildExpireApiKeyBody, normalizeApiKey } from "../utils/apiKeyUtils"; import { fetchWithFallback } from "../utils/apiUtils"; export async function getAPIKeys() { @@ -11,7 +12,7 @@ export async function getAPIKeys() { } const server = serverConf.server; - const authKey = serverConf.apiKey; + const authKey = normalizeApiKey(serverConf.apiKey); const response = await fetchWithFallback(server, authKey, `/api/v1/apikey`, { method: 'GET', @@ -41,7 +42,7 @@ export async function createAPIKey(expiration: string) { } const server = serverConf.server; - const authKey = serverConf.apiKey; + const authKey = normalizeApiKey(serverConf.apiKey); const response = await fetchWithFallback(server, authKey, `/api/v1/apikey`, { method: 'POST', @@ -61,7 +62,8 @@ export async function createAPIKey(expiration: string) { } } -export async function expireAPIKey(prefix: string) { +/** Expire an API key by id (preferred on v0.28+) or listed prefix. */ +export async function expireAPIKey(key: { id?: number | string; prefix?: string }) { try { const serverConf = await getServerConfig(); @@ -71,11 +73,12 @@ export async function expireAPIKey(prefix: string) { } const server = serverConf.server; - const authKey = serverConf.apiKey; + const authKey = normalizeApiKey(serverConf.apiKey); + const body = buildExpireApiKeyBody(key); const response = await fetchWithFallback(server, authKey, `/api/v1/apikey/expire`, { method: 'POST', - body: JSON.stringify({ prefix }), + body: JSON.stringify(body), }); if (!response.ok) { @@ -90,4 +93,3 @@ export async function expireAPIKey(prefix: string) { return null; } } - diff --git a/app/api/auth.ts b/app/api/auth.ts new file mode 100644 index 0000000..2f2b891 --- /dev/null +++ b/app/api/auth.ts @@ -0,0 +1,35 @@ +import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils"; + +/** Approve a pending auth request (SSH check or web auth). v0.29+ */ +export async function approveAuth(authId: string) { + const config = await getApiEndpoints(); + if (!config?.endpoints.auth) { + return { + error: true, + message: "Auth approve requires Headscale v0.29+", + }; + } + + const apiCall = config.endpoints.auth.approve(authId.trim()); + return await makeApiRequest(apiCall.url, { + method: apiCall.method, + body: JSON.stringify(apiCall.body), + }); +} + +/** Reject a pending auth request. v0.29+ */ +export async function rejectAuth(authId: string) { + const config = await getApiEndpoints(); + if (!config?.endpoints.auth) { + return { + error: true, + message: "Auth reject requires Headscale v0.29+", + }; + } + + const apiCall = config.endpoints.auth.reject(authId.trim()); + return await makeApiRequest(apiCall.url, { + method: apiCall.method, + body: JSON.stringify(apiCall.body), + }); +} diff --git a/app/api/devices.ts b/app/api/devices.ts index 03b5d39..b95463c 100644 --- a/app/api/devices.ts +++ b/app/api/devices.ts @@ -1,5 +1,5 @@ import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils"; -import { isV026OrHigher } from "../utils/headscaleVersion"; +import { isV026OrHigher, isV029OrHigher } from "../utils/headscaleVersion"; export async function getDevices() { const config = await getApiEndpoints(); @@ -9,15 +9,37 @@ export async function getDevices() { return await makeApiRequest(endpoints.devices.get, { method: 'GET' }); } -export async function registerDevice(user: string | number, key: string) { +/** + * Register a device. + * - v0.29+: POST /api/v1/auth/register JSON { user, authId } (user = username) + * - v0.28-: POST /api/v1/node/register?user=&key= (query params, not JSON body) + * + * Headscale looks up the user by *name* (GetUserByName), not numeric ID. + */ +export async function registerDevice(user: string | number, keyOrAuthId: string) { const config = await getApiEndpoints(); if (!config) return null; - const { endpoints } = config; - const apiCall = endpoints.devices.registerDevice(user as number, key); - - return await makeApiRequest(apiCall.url, { - method: apiCall.method, - body: apiCall.body ? JSON.stringify(apiCall.body) : undefined, + const { endpoints, serverConf } = config; + + // Always pass username — Headscale RegisterNode uses GetUserByName(). + const userName = String(user); + + if (isV029OrHigher(serverConf.version)) { + const apiCall = endpoints.devices.registerDevice(userName, keyOrAuthId); + return await makeApiRequest(apiCall.url, { + method: apiCall.method, + body: JSON.stringify(apiCall.body ?? { user: userName, authId: keyOrAuthId }), + }); + } + + // v0.28 and older: grpc-gateway binds RegisterNode fields as query parameters. + const params = new URLSearchParams({ + user: userName, + key: keyOrAuthId, + }); + + return await makeApiRequest(`/api/v1/node/register?${params.toString()}`, { + method: 'POST', }); } @@ -27,10 +49,8 @@ export async function renameDevice(idOrName: string | number, newName: string) { const { endpoints, serverConf } = config; - // Check if we're using v0.26 or higher (which uses integer IDs) const usesNumericIds = isV026OrHigher(serverConf.version); - // Convert to appropriate type based on version const deviceId = usesNumericIds ? Number(idOrName) : idOrName; const apiCall = endpoints.devices.renameDevice(deviceId as number, newName); @@ -45,10 +65,8 @@ export async function deleteDevice(id: string) { const { endpoints, serverConf } = config; - // Check if we're using v0.26 or higher (which uses integer IDs) const usesNumericIds = isV026OrHigher(serverConf.version); - // Convert to appropriate type based on version const deviceId = usesNumericIds ? Number(id) : id; const apiCall = endpoints.devices.deleteDevice(deviceId as number); @@ -63,13 +81,10 @@ export async function addTags(idOrName: string | number, tags: string[]) { const { endpoints, serverConf } = config; - // Check if we're using v0.26 or higher (which uses integer IDs) const usesNumericIds = isV026OrHigher(serverConf.version); - // Convert to appropriate type based on version const deviceId = usesNumericIds ? Number(idOrName) : idOrName; - // Format tags with "tag:" prefix and normalize const formattedTags = tags.map(tag => `tag:${tag.trim().toLowerCase()}`); const apiCall = endpoints.devices.addTags(deviceId as number, formattedTags); @@ -80,7 +95,6 @@ export async function addTags(idOrName: string | number, tags: string[]) { } export async function removeTags(id: string, tags: string[]) { - // TODO: Implement when API endpoint is available console.warn("removeTags not yet implemented - API endpoint needed"); return null; } @@ -91,10 +105,8 @@ export async function changeUser(idOrName: number, user: any) { const { endpoints, serverConf } = config; - // Check if we're using v0.26 or higher (which uses integer IDs) const usesNumericIds = isV026OrHigher(serverConf.version); - // Convert to appropriate types based on version const deviceId = usesNumericIds ? Number(idOrName) : idOrName; const userId = usesNumericIds ? Number(user.id) : user.name; const apiCall = endpoints.devices.changeUser(deviceId as number, userId); @@ -103,4 +115,4 @@ export async function changeUser(idOrName: number, user: any) { method: apiCall.method, body: apiCall.body ? JSON.stringify(apiCall.body) : undefined, }); -} \ No newline at end of file +} diff --git a/app/api/login.ts b/app/api/login.ts index 7fb9257..ae2d18a 100644 --- a/app/api/login.ts +++ b/app/api/login.ts @@ -1,30 +1,102 @@ import { fetchWithFallback } from "../utils/apiUtils"; +import { + getApiKeyKind, + isUsableApiKey, + normalizeApiKey, +} from "../utils/apiKeyUtils"; -export async function testAPIKey(server: string, apiKey: string): Promise { - try { - // Uses the fallback-aware fetch so a singular/plural endpoint mismatch - // (which returns 404) doesn't get misreported as an invalid API key. - const response = await fetchWithFallback(server, apiKey, "/api/v1/apikey", { - method: "GET", - }); - - if (response.ok) { - return true; - } - - // 401/403 means the key really is invalid; anything else (e.g. a 404 on - // both endpoint spellings, or a 5xx) is logged so it isn't silently - // treated as an auth problem. - console.error("API Error:", response.status, await response.text()); - return false; - } catch (error) { - console.error("Fetch error:", error); - return false; - } +export type ApiKeyTestResult = { + ok: boolean; + /** Short user-facing reason when ok is false */ + message?: string; + status?: number; +}; + +export async function testAPIKey( + server: string, + apiKey: string, +): Promise { + const result = await testAPIKeyDetailed(server, apiKey); + return result.ok; } - +export async function testAPIKeyDetailed( + server: string, + apiKey: string, +): Promise { + const key = normalizeApiKey(apiKey); + + if (!key) { + return { ok: false, message: "API key is empty." }; + } + + const kind = getApiKeyKind(key); + if (kind === "preauth") { + return { + ok: false, + message: + "That looks like a pre-auth key (hskey-auth-…), not an API key. Create an API key with: headscale apikeys create", + }; + } + if (kind === "registration") { + return { + ok: false, + message: + "That looks like a registration key (hskey-reg-…), not an API key. Use a management API key (hskey-api-…).", + }; + } + if (!isUsableApiKey(key)) { + return { + ok: false, + message: + "Unrecognized key format. Headscale v0.28+ API keys look like hskey-api-{prefix}-{secret}.", + }; + } + + try { + // Uses the fallback-aware fetch so a singular/plural endpoint mismatch + // (which returns 404) doesn't get misreported as an invalid API key. + const response = await fetchWithFallback(server, key, "/api/v1/apikey", { + method: "GET", + }); + if (response.ok) { + return { ok: true }; + } + const body = await response.text(); + console.error("API Error:", response.status, body); + if (response.status === 401 || response.status === 403) { + return { + ok: false, + status: response.status, + message: + kind === "api-legacy" + ? "API key rejected. If this server is Headscale v0.28+, create a new key with: headscale apikeys create" + : "API key rejected by the server. Confirm you pasted the full hskey-api-… key.", + }; + } + if (response.status === 404) { + return { + ok: false, + status: response.status, + message: "Could not reach the Headscale API (404). Check the server URL.", + }; + } + + return { + ok: false, + status: response.status, + message: `Server returned ${response.status}. Check URL and API key.`, + }; + } catch (error) { + console.error("Fetch error:", error); + return { + ok: false, + message: + "Could not reach the server. Check the URL, HTTPS/HTTP, and network connectivity.", + }; + } +} diff --git a/app/api/preauthkeys.ts b/app/api/preauthkeys.ts index a6d83f8..be72531 100644 --- a/app/api/preauthkeys.ts +++ b/app/api/preauthkeys.ts @@ -20,10 +20,22 @@ export async function createPreAuthKey(userIdentifier: string | number, expirati const { endpoints } = config; const apiCall = endpoints.preauthkeys.createPreauthKey(userIdentifier, expiration, reusable); - + const rawUser = apiCall.body.user; + const userValue = + typeof rawUser === "number" + ? rawUser + : /^\d+$/.test(String(rawUser)) + ? Number(rawUser) + : rawUser; + + const body = { + ...apiCall.body, + user: userValue, + }; + return await makeApiRequest(apiCall.url, { method: apiCall.method, - body: JSON.stringify(apiCall.body), + body: JSON.stringify(body), }); } diff --git a/app/api/users.ts b/app/api/users.ts index 9853376..8793f7b 100644 --- a/app/api/users.ts +++ b/app/api/users.ts @@ -36,8 +36,8 @@ export async function deleteUser(userNameOrId: string | number) { const config = await getApiEndpoints(); if (!config) return null; - const { endpoints, serverConf } = config; - + const { endpoints } = config; + const apiCall = endpoints.users.deleteUser(userNameOrId); return await makeApiRequest(apiCall.url, { @@ -49,8 +49,8 @@ export async function renameUser(oldNameOrId: string | number, newName: string) const config = await getApiEndpoints(); if (!config) return null; - const { endpoints, serverConf } = config; - + const { endpoints } = config; + const apiCall = endpoints.users.renameUser(oldNameOrId, newName); return await makeApiRequest(apiCall.url, { diff --git a/app/components/HttpInsecureWarning.tsx b/app/components/HttpInsecureWarning.tsx new file mode 100644 index 0000000..478f760 --- /dev/null +++ b/app/components/HttpInsecureWarning.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { MaterialIcons } from "@expo/vector-icons"; +import { useTheme } from "@/theme"; + +const WAIT_SECONDS = 5; + +export function isInsecureHttpUrl(server: string): boolean { + return server.trim().toLowerCase().startsWith("http://"); +} + +export function useHttpRiskAck(server: string) { + const insecure = isInsecureHttpUrl(server); + const [acknowledged, setAcknowledged] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(0); + const [trackedInsecure, setTrackedInsecure] = useState(insecure); + + if (insecure !== trackedInsecure) { + setTrackedInsecure(insecure); + setAcknowledged(false); + setSecondsLeft(insecure ? WAIT_SECONDS : 0); + } + + useEffect(() => { + if (!insecure) return; + + const started = Date.now(); + const timer = setInterval(() => { + const remaining = Math.max( + 0, + WAIT_SECONDS - Math.floor((Date.now() - started) / 1000), + ); + setSecondsLeft(remaining); + if (remaining === 0) clearInterval(timer); + }, 200); + + return () => clearInterval(timer); + }, [insecure]); + + const canProceed = !insecure || (acknowledged && secondsLeft === 0); + + const toggleAcknowledged = () => { + if (secondsLeft > 0) return; + setAcknowledged((current) => !current); + }; + + return { + insecure, + acknowledged, + secondsLeft, + canProceed, + toggleAcknowledged, + }; +} + +export function HttpInsecureWarning({ + insecure, + acknowledged, + secondsLeft, + onToggle, +}: { + insecure: boolean; + acknowledged: boolean; + secondsLeft: number; + onToggle: () => void; +}) { + const { theme } = useTheme(); + const { colors } = theme; + if (!insecure) return null; + + const locked = secondsLeft > 0; + + return ( + + + HTTP does not encrypt traffic. By checking this box you understand that + this risks your admin API token and is insecure. Please only proceed + with caution and ensure you understand the risks. + + + + + {locked + ? `Read the warning before continuing (${secondsLeft}s)` + : "I understand the risk and want to continue over HTTP"} + + + + ); +} diff --git a/app/components/InfoRow.tsx b/app/components/InfoRow.tsx index 87bb141..33de08e 100644 --- a/app/components/InfoRow.tsx +++ b/app/components/InfoRow.tsx @@ -3,33 +3,39 @@ import { View, Text, TouchableOpacity } from "react-native"; import { MaterialIcons } from "@expo/vector-icons"; import { InfoRowProps } from "../types"; import { copyToClipboard } from "../utils/deviceUtils"; +import { useTheme } from "@/theme"; -export const InfoRow: React.FC = ({ - label, - value, - copyable = false, - onEdit -}) => ( - - - {label}: - - {copyable && ( - copyToClipboard(value, label)}> - - - )} - {onEdit && ( - - - - )} +export const InfoRow: React.FC = ({ + label, + value, + copyable = false, + onEdit +}) => { + const { theme } = useTheme(); + const { colors } = theme; + + return ( + + + {label}: + + {copyable && ( + copyToClipboard(value, label)} activeOpacity={0.7}> + + + )} + {onEdit && ( + + + + )} + + + + + {value || "N/A"} + - - - {value || "N/A"} - - - -); + ); +}; diff --git a/app/components/RegisterDeviceModal.tsx b/app/components/RegisterDeviceModal.tsx index 2f20e60..d0f246c 100644 --- a/app/components/RegisterDeviceModal.tsx +++ b/app/components/RegisterDeviceModal.tsx @@ -12,6 +12,8 @@ import { Keyboard, } from "react-native"; import Toast from "react-native-toast-message"; +import { isV029OrHigher } from "../utils/headscaleVersion"; +import { useTheme } from "@/theme"; interface RegisterDeviceModalProps { visible: boolean; @@ -22,6 +24,9 @@ interface RegisterDeviceModalProps { deviceKey: string; onKeyChange: (key: string) => void; onRegister: () => void; + serverVersion?: string; + onApprove?: () => void; + onReject?: () => void; } export const RegisterDeviceModal: React.FC = ({ @@ -33,9 +38,16 @@ export const RegisterDeviceModal: React.FC = ({ deviceKey, onKeyChange, onRegister, + serverVersion, + onApprove, + onReject, }) => { + const { theme } = useTheme(); + const { colors } = theme; + const isV029 = isV029OrHigher(serverVersion); + const handleRegister = () => { - if (!selectedUser) { + if (!selectedUser && !/headscale\s+auth\s+(approve|reject)/i.test(deviceKey)) { Toast.show({ type: "error", position: "top", @@ -48,8 +60,10 @@ export const RegisterDeviceModal: React.FC = ({ Toast.show({ type: "error", position: "top", - text1: "⚠️ No Key", - text2: "Please enter a device key", + text1: isV029 ? "⚠️ No Auth ID" : "⚠️ No Key", + text2: isV029 + ? "Enter an auth ID or paste a headscale auth command" + : "Please enter a device key", }); return; } @@ -64,95 +78,132 @@ export const RegisterDeviceModal: React.FC = ({ onRequestClose={onClose} > - + - - - Register Device + + + {isV029 ? "Register / Auth" : "Register Device"} + {isV029 && ( + + v0.29 uses auth IDs. Paste an auth ID or a full{" "} + headscale auth …{" "} + command. + + )} - {/* User Selection */} - Select User: + Select User: {users.length > 0 ? ( - {users.map((user) => ( - onSelectUser(user)} - className={`p-3 rounded-lg mb-2 ${ - selectedUser?.id === user.id - ? "bg-blue-600" - : "bg-zinc-700" - }`} - > - - {user.name} - - { + const selected = selectedUser?.id === user.id; + return ( + onSelectUser(user)} + activeOpacity={0.8} + className="p-3 rounded-lg mb-2" + style={{ backgroundColor: selected ? colors.primary : colors.surfaceMuted }} > - ID: {user.id} - - - ))} + + {user.name} + + + ID: {user.id} + + + ); + })} ) : ( - - + + No users available. Please add a user first. )} - {/* Key Input */} - Device Key: + + {isV029 ? "Auth ID or command:" : "Device Key:"} + + {isV029 && ( + + Examples:{"\n"} + headscale auth register --user alice --auth-id …{"\n"} + headscale auth approve --auth-id …{"\n"} + headscale auth reject --auth-id … + + )} - {/* Buttons */} - + - + Cancel - + Register + + {isV029 && onApprove && onReject && ( + + + + Approve + + + + + Reject + + + + )} @@ -160,4 +211,3 @@ export const RegisterDeviceModal: React.FC = ({ ); }; - diff --git a/app/components/RoutesModal.tsx b/app/components/RoutesModal.tsx index 69d9c37..0e34246 100644 --- a/app/components/RoutesModal.tsx +++ b/app/components/RoutesModal.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Modal, View, Text, TouchableOpacity, ScrollView } from "react-native"; import { MaterialIcons } from "@expo/vector-icons"; +import { useTheme } from "@/theme"; interface RoutesModalProps { visible: boolean; @@ -19,6 +20,9 @@ export const RoutesModal: React.FC = ({ setSelectedRoutes, onApproveRoutes, }) => { + const { theme } = useTheme(); + const { colors } = theme; + const handleRouteToggle = (route: string) => { if (selectedRoutes.includes(route)) { setSelectedRoutes(selectedRoutes.filter(r => r !== route)); @@ -34,62 +38,71 @@ export const RoutesModal: React.FC = ({ return ( - - - Approve Routes - + + + Approve Routes + Select routes to approve from available routes: - + - {availableRoutes?.map((route, index) => ( - handleRouteToggle(route)} - className={`flex-row items-center justify-between py-3 px-3 mb-2 rounded-lg ${ - selectedRoutes.includes(route) ? 'bg-green-600/20 border border-green-500' : 'bg-zinc-700' - }`} - > - - {route} - - - - ))} + {availableRoutes?.map((route, index) => { + const selected = selectedRoutes.includes(route); + return ( + handleRouteToggle(route)} + activeOpacity={0.8} + className="flex-row items-center justify-between py-3 px-3 mb-2 rounded-lg border" + style={{ + backgroundColor: selected ? colors.successSoft : colors.surfaceMuted, + borderColor: selected ? colors.success : "transparent", + }} + > + + {route} + + + + ); + })} {selectedRoutes.length > 0 && ( - - Selected routes ({selectedRoutes.length}): - + + Selected routes ({selectedRoutes.length}): + {selectedRoutes.join(", ")} )} - + 0 ? 'bg-green-600' : 'bg-gray-600' - }`} + activeOpacity={0.8} + className="flex-1 py-3 rounded-lg mx-2" + style={{ backgroundColor: selectedRoutes.length > 0 ? colors.success : colors.secondary }} > - + 0 ? colors.onPrimary : colors.onSecondary }}> Approve {selectedRoutes.length > 0 ? `(${selectedRoutes.length})` : ''} - Cancel + Cancel diff --git a/app/components/ScreenLoading.tsx b/app/components/ScreenLoading.tsx new file mode 100644 index 0000000..11975a5 --- /dev/null +++ b/app/components/ScreenLoading.tsx @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { Text, View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; +import { useTheme } from "@/theme"; + +function Bar({ width }: { width: `${number}%` }) { + const { theme } = useTheme(); + const opacity = useSharedValue(0.45); + + useEffect(() => { + opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true); + }, [opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + + ); +} + +export function ScreenLoading({ label }: { label: string }) { + const { theme } = useTheme(); + const { colors, radii, spacing } = theme; + + return ( + + {[0, 1, 2].map((item) => ( + + + + + ))} + + {label} + + + ); +} diff --git a/app/components/TagsModal.tsx b/app/components/TagsModal.tsx index a260c95..179c753 100644 --- a/app/components/TagsModal.tsx +++ b/app/components/TagsModal.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Modal, View, Text, TextInput, TouchableOpacity } from "react-native"; +import { useTheme } from "@/theme"; interface TagsModalProps { visible: boolean; @@ -16,31 +17,39 @@ export const TagsModal: React.FC = ({ setNewTags, onAddTags, }) => { + const { theme } = useTheme(); + const { colors } = theme; + return ( - - - Add Tags + + + Add Tags - Add Tags + Add Tags - Cancel + Cancel diff --git a/app/components/UserSelectionModal.tsx b/app/components/UserSelectionModal.tsx index fc2d2a7..a46b0b9 100644 --- a/app/components/UserSelectionModal.tsx +++ b/app/components/UserSelectionModal.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Modal, View, Text, TouchableOpacity, ScrollView } from "react-native"; import { User } from "../types"; +import { useTheme } from "@/theme"; interface UserSelectionModalProps { visible: boolean; @@ -15,30 +16,37 @@ export const UserSelectionModal: React.FC = ({ users, onSelectUser, }) => { + const { theme } = useTheme(); + const { colors } = theme; + return ( - - - Select User + + + Select User {users.map((user) => ( onSelectUser(user.name)} - className="py-3 border-b border-zinc-700" + activeOpacity={0.7} + className="py-3 border-b" + style={{ borderBottomColor: colors.border }} > - {user.name} + {user.name} {user.displayName && ( - {user.displayName} + {user.displayName} )} ))} - Cancel + Cancel diff --git a/app/config/apiVersions.ts b/app/config/apiVersions.ts index 3031864..cb95cc2 100644 --- a/app/config/apiVersions.ts +++ b/app/config/apiVersions.ts @@ -7,7 +7,7 @@ export interface ApiEndpoints { devices: { get: string; - registerDevice: (user: number, key: string) => { url: string; method: string; body?: any }; + registerDevice: (user: string | number, key: string) => { url: string; method: string; body?: any }; renameDevice: (id: number, newName: string) => { url: string; method: string }; deleteDevice: (id: number) => { url: string; method: string }; addTags: (id: number, tags: string[]) => { url: string; method: string; body?: any }; @@ -43,6 +43,14 @@ export interface ApiEndpoints { acl: { getPolicy: string; updatePolicy: (policy: any) => { url: string; method: string; body: any }; + /** Headscale v0.29+: validate policy (incl. tests/grants) before applying */ + checkPolicy?: (policy: string) => { url: string; method: string; body: any }; + }; + + /** Headscale v0.29+: SSH check / web auth approval flow */ + auth?: { + approve: (authId: string) => { url: string; method: string; body: any }; + reject: (authId: string) => { url: string; method: string; body: any }; }; } @@ -519,6 +527,8 @@ export const API_VERSION_MAP: Record = { }, 'v0.28': { // v0.28 follows v0.27 for most endpoints. + // API keys use format hskey-api-{prefix}-{secret}; list returns masked prefixes. + // Expire/delete accept id (preferred) or prefix — see app/api/apikeys.ts. apikeys: { get: '/api/v1/apikey', createApiKey: (expiration: string) => ({ @@ -526,10 +536,12 @@ export const API_VERSION_MAP: Record = { method: 'POST', body: { expiration } }), - expireApiKey: (prefix: string) => ({ + expireApiKey: (prefixOrId: string | number) => ({ url: `/api/v1/apikey/expire`, method: 'POST', - body: { prefix } + body: typeof prefixOrId === 'number' || /^\d+$/.test(String(prefixOrId)) + ? { id: Number(prefixOrId) } + : { prefix: String(prefixOrId) } }), }, @@ -615,4 +627,124 @@ export const API_VERSION_MAP: Record = { }), }, }, + + 'v0.29': { + // v0.29 builds on v0.28 with auth routes and policy check. + // Device registration prefers /api/v1/auth/register { user, authId } (JSON). + // Policy supports grants, nodeAttrs, tests, sshTests; check before save. + apikeys: { + get: '/api/v1/apikey', + createApiKey: (expiration: string) => ({ + url: `/api/v1/apikey`, + method: 'POST', + body: { expiration } + }), + expireApiKey: (prefixOrId: string | number) => ({ + url: `/api/v1/apikey/expire`, + method: 'POST', + body: typeof prefixOrId === 'number' || /^\d+$/.test(String(prefixOrId)) + ? { id: Number(prefixOrId) } + : { prefix: String(prefixOrId) } + }), + }, + + devices: { + get: '/api/v1/node', + // Preferred registration path on v0.29 (nodes register is deprecated). + registerDevice: (user: number | string, authId: string) => ({ + url: `/api/v1/auth/register`, + method: 'POST', + body: { user: String(user), authId }, + }), + renameDevice: (id: number, newName: string) => ({ + url: `/api/v1/node/${id}/rename/${newName}`, + method: 'POST', + }), + deleteDevice: (id: number) => ({ + url: `/api/v1/node/${id}`, + method: 'DELETE', + }), + addTags: (id: number, tags: string[]) => ({ + url: `/api/v1/node/${id}/tags`, + method: 'POST', + body: { tags } + }), + // MoveNode removed in v0.28; kept for type compatibility. + changeUser: (id: number, user: number) => ({ + url: `/api/v1/node/${id}/user`, + method: 'POST', + body: { user } + }), + }, + + preauthkeys: { + get: () => ({ + url: `/api/v1/preauthkey`, + method: 'GET', + }), + createPreauthKey: (user: number, expiration: string, reusable: boolean) => ({ + url: `/api/v1/preauthkey`, + method: 'POST', + body: { user, expiration, reusable }, + }), + expirePreauthKey: (_user: number, keyId: string) => ({ + url: `/api/v1/preauthkey/expire`, + method: 'POST', + body: { id: Number(keyId) }, + }), + }, + + routes: { + get: '/api/v1/routes', + update: (id: string, routes: string[]) => ({ + url: `/api/v1/node/${id}/approve_routes`, + method: 'POST', + body: { routes } + }), + }, + + users: { + get: '/api/v1/user', + addUser: (name: string) => ({ + url: `/api/v1/user`, + method: 'POST', + body: { name } + }), + deleteUser: (id: number) => ({ + url: `/api/v1/user/${id}`, + method: 'DELETE', + }), + renameUser: (id: number, newName: string) => ({ + url: `/api/v1/user/${id}/rename/${newName}`, + method: 'POST', + }), + }, + + acl: { + getPolicy: '/api/v1/policy', + updatePolicy: (policy: any) => ({ + url: '/api/v1/policy', + method: 'PUT', + body: JSON.stringify(policy) + }), + checkPolicy: (policy: string) => ({ + url: '/api/v1/policy/check', + method: 'POST', + body: { policy }, + }), + }, + + auth: { + approve: (authId: string) => ({ + url: '/api/v1/auth/approve', + method: 'POST', + body: { authId }, + }), + reject: (authId: string) => ({ + url: '/api/v1/auth/reject', + method: 'POST', + body: { authId }, + }), + }, + }, }; \ No newline at end of file diff --git a/app/customScreens/[id].tsx b/app/customScreens/[id].tsx index 9f08b98..eeabb89 100644 --- a/app/customScreens/[id].tsx +++ b/app/customScreens/[id].tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { useState } from "react"; import { View, Text, TextInput, TouchableOpacity, Animated @@ -8,15 +8,19 @@ import { MaterialIcons } from "@expo/vector-icons"; import { useRouter, useLocalSearchParams } from "expo-router"; import { useDeviceDetail } from "../funcs/deviceDetail"; import { formatDate, getTimeAgo, copyToClipboard } from "../utils/deviceUtils"; +import { isNullExpiry } from "../utils/registrationUtils"; import { InfoRow } from "../components/InfoRow"; import { UserSelectionModal } from "../components/UserSelectionModal"; import { TagsModal } from "../components/TagsModal"; import { RoutesModal } from "../components/RoutesModal"; +import { useTheme } from "@/theme"; export default function DeviceDetailScreen() { + const { theme } = useTheme(); + const colors = theme.colors; const { device: deviceData } = useLocalSearchParams<{ device: string }>(); const router = useRouter(); - const scrollY = useRef(new Animated.Value(0)).current; + const [scrollY] = useState(() => new Animated.Value(0)); const { device, @@ -41,6 +45,7 @@ export default function DeviceDetailScreen() { handleApproveRoutes, handleRemoveRoute, handleDelete, + canChangeUser, } = useDeviceDetail(deviceData); const appliedTags = device?.tags || device?.validTags || []; @@ -76,23 +81,23 @@ export default function DeviceDetailScreen() { if (!device) { return ( - - - Loading device details... + + + Loading device details... ); } return ( - + {/* Sticky Header */} router.push("/(tabs)/devices")} className="flex-row items-center" > - - Back + + Back - - + + {device.online ? 'ONLINE' : 'OFFLINE'} @@ -129,10 +133,10 @@ export default function DeviceDetailScreen() { }} className="items-center" > - + {device.givenName || device.name} - + {device.user?.name} • {device.ipAddresses?.[0]} @@ -147,10 +151,10 @@ export default function DeviceDetailScreen() { }} className="items-center" > - + {device.givenName || device.name} - + {device.ipAddresses?.[0]} @@ -166,26 +170,26 @@ export default function DeviceDetailScreen() { > {/* Basic Information */} - - Basic Information + + Basic Information {editingField === "name" ? ( - Device Name: + Device Name: - - + + - setEditingField(null)} className="bg-gray-600 px-4 py-3 rounded-lg"> - + setEditingField(null)} className="px-4 py-3 rounded-lg" style={{ backgroundColor: colors.secondary }}> + @@ -201,22 +205,31 @@ export default function DeviceDetailScreen() { )} + {device.name && device.givenName && device.name !== device.givenName && ( + + )} - - {device.expiry && device.expiry !== "0001-01-01T00:00:00Z" && ( - + + {!isNullExpiry(device.expiry) ? ( + + ) : ( + )} {/* User Information */} - + - User Assignment - setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded"> - Change - + User Assignment + {canChangeUser ? ( + setShowUserModal(true)} className="px-3 py-1 rounded" style={{ backgroundColor: colors.primary }}> + Change + + ) : ( + Fixed at registration (v0.28+) + )} @@ -230,20 +243,20 @@ export default function DeviceDetailScreen() { {/* Network Keys */} - - Network Keys + + Network Keys {/* Routes */} - + - Route Management + Route Management {device.availableRoutes?.length > 0 && ( - setShowRoutesModal(true)} className="bg-green-600 px-3 py-1 rounded"> - Approve Routes + setShowRoutesModal(true)} className="px-3 py-1 rounded" style={{ backgroundColor: colors.success }}> + Approve Routes )} @@ -251,17 +264,17 @@ export default function DeviceDetailScreen() { {/* Approved Routes */} {device.approvedRoutes?.length > 0 && ( - Approved Routes: - + Approved Routes: + {device.approvedRoutes.map((route, index) => ( - {route} + {route} copyToClipboard(route, "Route")}> - + handleRemoveRoute(route)}> - + @@ -273,18 +286,18 @@ export default function DeviceDetailScreen() { {/* Available Routes */} {device.availableRoutes?.length > 0 && ( - Available Routes: - + Available Routes: + {device.availableRoutes.map((route, index) => ( - {route} + {route} copyToClipboard(route, "Route")}> - + ))} - + Tap "Approve Routes" to move available routes to approved routes @@ -293,13 +306,13 @@ export default function DeviceDetailScreen() { {/* Subnet Routes */} {device.subnetRoutes?.length > 0 && ( - Subnet Routes: - + Subnet Routes: + {device.subnetRoutes.map((route, index) => ( - {route} + {route} copyToClipboard(route, "Route")}> - + ))} @@ -308,16 +321,16 @@ export default function DeviceDetailScreen() { )} {(!device.approvedRoutes?.length && !device.availableRoutes?.length && !device.subnetRoutes?.length) && ( - No routes configured + No routes configured )} {/* Tags */} - + - Tags - setShowTagsModal(true)} className="bg-yellow-600 px-3 py-1 rounded"> - Add + Tags + setShowTagsModal(true)} className="px-3 py-1 rounded" style={{ backgroundColor: colors.warning }}> + Add @@ -332,14 +345,14 @@ export default function DeviceDetailScreen() { )} {(!appliedTags.length && !device.forcedTags?.length && !device.invalidTags?.length) && ( - No tags assigned + No tags assigned )} {/* Pre-Auth Key Info */} {device.preAuthKey && ( - - Pre-Auth Key + + Pre-Auth Key @@ -350,14 +363,14 @@ export default function DeviceDetailScreen() { )} {/* Actions */} - - Actions + + Actions - - Delete Device + + Delete Device diff --git a/app/funcs/accounts.ts b/app/funcs/accounts.ts index 04efa82..14eda45 100644 --- a/app/funcs/accounts.ts +++ b/app/funcs/accounts.ts @@ -2,10 +2,12 @@ import { useState, useEffect } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useRouter } from "expo-router"; import Toast from "react-native-toast-message"; -import { testAPIKey } from "../api/login"; +import { testAPIKeyDetailed } from "../api/login"; +import { normalizeApiKey } from "../utils/apiKeyUtils"; import { parseVersion } from "../utils/getServer"; +import { isInsecureHttpUrl } from "../components/HttpInsecureWarning"; -export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x"; +export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x" | "0.29.x"; interface ServerAccount { name: string; @@ -20,6 +22,7 @@ interface AddAccountParams { server: string; apiKey: string; version: HeadscaleVersion; + httpRiskAcknowledged?: boolean; onSuccess?: () => void; onFail?: () => void; } @@ -86,6 +89,7 @@ export function useAccountsManager() { server, apiKey, version, + httpRiskAcknowledged = false, onSuccess, onFail, }: AddAccountParams) => { @@ -111,6 +115,17 @@ export function useAccountsManager() { return; } + if (isInsecureHttpUrl(server) && !httpRiskAcknowledged) { + Toast.show({ + type: "error", + position: "top", + text1: "HTTP confirmation required", + text2: "Confirm you understand the risk before sending your API token over HTTP.", + }); + if (onFail) onFail(); + return; + } + const existing = await AsyncStorage.getItem("servers"); let parsed: ServerAccount[] = []; if (existing) { @@ -143,23 +158,27 @@ export function useAccountsManager() { } setLoading(true); - - let isValid = false; + + const normalizedKey = normalizeApiKey(apiKey); + let authResult: Awaited>; // temp for apple login demo, will do nothing - if (server === "https://appledemo.login.ieouiudhmpac.com" && apiKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7") { - isValid = true; + if ( + server === "https://appledemo.login.ieouiudhmpac.com" && + normalizedKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7" + ) { + authResult = { ok: true }; } else { - isValid = await testAPIKey(server, apiKey); + authResult = await testAPIKeyDetailed(server, normalizedKey); } - + setLoading(false); - if (!isValid) { + if (!authResult.ok) { Toast.show({ type: "error", position: "top", - text1: "⚠️ Invalid API Key", - text2: "Check the key and try again.", + text1: "⚠️ Connection Failed", + text2: authResult.message || "Check the key and try again.", }); if (onFail) onFail(); return; @@ -168,7 +187,7 @@ export function useAccountsManager() { const newEntry: ServerAccount = { name: customName.trim(), server: server.trim(), - apiKey: apiKey.trim(), + apiKey: normalizedKey, addedOn: new Date().toISOString(), version: parseVersion(version), }; diff --git a/app/funcs/apikeys.ts b/app/funcs/apikeys.ts index 0472910..6bbc00d 100644 --- a/app/funcs/apikeys.ts +++ b/app/funcs/apikeys.ts @@ -1,6 +1,7 @@ import { useState } from "react"; import Toast from "react-native-toast-message"; import { getAPIKeys, createAPIKey, expireAPIKey } from "../api/apikeys"; +import { apiKeyMatchesListedPrefix } from "../utils/apiKeyUtils"; import { calculateExpirationDate } from "../utils/time"; import { getServerConfig } from "../utils/getServer"; @@ -24,7 +25,10 @@ export function useApiKeys() { const currentKey = serverConfig.apiKey; if (currentKey) { - const matchedKey = allKeys.find((key) => key.prefix && currentKey.startsWith(key.prefix)); + // v0.28 lists prefixes as `hskey-api-{prefix}-***` — match with asterisks stripped + const matchedKey = allKeys.find((key: any) => + apiKeyMatchesListedPrefix(currentKey, key.prefix), + ); if (matchedKey?.expiration) { const expirationDate = new Date(matchedKey.expiration).toLocaleString(); setActiveKeyExpire(expirationDate); @@ -42,53 +46,56 @@ export function useApiKeys() { } }; -const handleCreateKey = async () => { - if (!newKeyExpire.trim()) { - Toast.show({ - type: "error", - position: "top", - text1: "⚠️ Expiration Required", - text2: "Please enter an expiration time", - }); - return null; - } - - // Validate expiration format - const regex = /^(0|[1-9]\d*)([smhdy])$/; - if (!regex.test(newKeyExpire)) { - Toast.show({ - type: "error", - position: "top", - text1: "⚠️ Invalid Format", - text2: "Use format like: 24h, 7d, 30d, 1y", - }); - return null; - } + const handleCreateKey = async () => { + if (!newKeyExpire.trim()) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ Expiration Required", + text2: "Please enter an expiration time", + }); + return null; + } - try { - // Convert expiration to timestamp format your API expects - const expirationTimestamp = calculateExpirationDate(newKeyExpire); - - // Call your API to create the key - const result = await createAPIKey(expirationTimestamp); - - if (result && result.apiKey) { + // Validate expiration format + const regex = /^(0|[1-9]\d*)([smhdy])$/; + if (!regex.test(newKeyExpire)) { Toast.show({ - type: "success", + type: "error", position: "top", - text1: "✅ API Key Created", - text2: "New API key generated successfully", + text1: "⚠️ Invalid Format", + text2: "Use format like: 24h, 7d, 30d, 1y", }); - - // Refresh the keys list - await fetchApiKeys(); - - // Clear the input - setNewKeyExpire(""); - - // Return the result so the component can display the key - return result; - } else { + return null; + } + + try { + const expirationTimestamp = calculateExpirationDate(newKeyExpire); + if (!expirationTimestamp) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ Invalid Format", + text2: "Use format like: 24h, 7d, 30d", + }); + return null; + } + + const result = await createAPIKey(expirationTimestamp); + + if (result && result.apiKey) { + Toast.show({ + type: "success", + position: "top", + text1: "✅ API Key Created", + text2: "New API key generated successfully", + }); + + await fetchApiKeys(); + setNewKeyExpire(""); + return result; + } + Toast.show({ type: "error", position: "top", @@ -96,21 +103,20 @@ const handleCreateKey = async () => { text2: "Failed to create API key", }); return null; + } catch (error) { + console.error("Error creating API key:", error); + Toast.show({ + type: "error", + position: "top", + text1: "❌ Creation Failed", + text2: "An error occurred while creating the key", + }); + return null; } - } catch (error) { - console.error("Error creating API key:", error); - Toast.show({ - type: "error", - position: "top", - text1: "❌ Creation Failed", - text2: "An error occurred while creating the key", - }); - return null; - } -}; + }; - const handleExpireKey = async (prefix: string) => { - const result = await expireAPIKey(prefix); + const handleExpireKey = async (key: { id?: number | string; prefix?: string }) => { + const result = await expireAPIKey(key); if (result) { Toast.show({ type: "success", diff --git a/app/funcs/deviceDetail.ts b/app/funcs/deviceDetail.ts index 2bca66a..2c2bc33 100644 --- a/app/funcs/deviceDetail.ts +++ b/app/funcs/deviceDetail.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Alert } from "react-native"; import Toast from "react-native-toast-message"; import { Device, User } from "../types"; @@ -8,8 +8,20 @@ import { updateRoute } from "../api/routes"; import { getVersionInfo } from "../utils/deviceUtils"; import { isV028OrHigher } from "../utils/headscaleVersion"; +function parseDeviceParam(deviceData: string | undefined): Device | null { + if (!deviceData) return null; + try { + return JSON.parse(deviceData) as Device; + } catch (error) { + console.error("Failed to parse device data:", error); + return null; + } +} + export function useDeviceDetail(deviceData: string | undefined) { - const [device, setDevice] = useState(null); + const parsedDevice = parseDeviceParam(deviceData); + const [device, setDevice] = useState(parsedDevice); + const [loadedFrom, setLoadedFrom] = useState(deviceData); const [users, setUsers] = useState([]); const [editingField, setEditingField] = useState(null); const [tempValue, setTempValue] = useState(""); @@ -18,26 +30,14 @@ export function useDeviceDetail(deviceData: string | undefined) { const [showRoutesModal, setShowRoutesModal] = useState(false); const [selectedRoutes, setSelectedRoutes] = useState([]); const [newTags, setNewTags] = useState(""); + const [canChangeUser, setCanChangeUser] = useState(true); - useEffect(() => { - if (deviceData) { - try { - const parsedDevice: Device = JSON.parse(deviceData); - setDevice(parsedDevice); - loadUsers(); - } catch (error) { - console.error("Failed to parse device data:", error); - Toast.show({ - type: "error", - position: "top", - text1: "⚠️ Data Error", - text2: "Failed to load device data", - }); - } - } - }, [deviceData]); + if (deviceData !== loadedFrom) { + setLoadedFrom(deviceData); + setDevice(parsedDevice); + } - const loadUsers = async () => { + const loadUsers = useCallback(async () => { try { const usersData = await getUsers(); if (usersData?.users) { @@ -46,7 +46,32 @@ export function useDeviceDetail(deviceData: string | undefined) { } catch (error) { console.error("Failed to load users:", error); } - }; + }, []); + + useEffect(() => { + if (!deviceData) return; + if (!parseDeviceParam(deviceData)) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ Data Error", + text2: "Failed to load device data", + }); + return; + } + + const handle = setTimeout(() => { + void loadUsers(); + void (async () => { + const versionInfo = await getVersionInfo(); + if (versionInfo?.versionKey) { + setCanChangeUser(!isV028OrHigher(versionInfo.versionKey.replace(/^v/, ""))); + } + })(); + }, 0); + + return () => clearTimeout(handle); + }, [deviceData, loadUsers]); const handleRename = async () => { if (!device || !tempValue.trim()) return; @@ -386,5 +411,6 @@ export function useDeviceDetail(deviceData: string | undefined) { handleApproveRoutes, handleRemoveRoute, handleDelete, + canChangeUser, }; } diff --git a/app/funcs/devices.ts b/app/funcs/devices.ts index e5ffd4b..27aef7b 100644 --- a/app/funcs/devices.ts +++ b/app/funcs/devices.ts @@ -1,10 +1,12 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getDevices, registerDevice } from "../api/devices"; +import { approveAuth, rejectAuth } from "../api/auth"; import { getUsers } from "../api/users"; import Toast from "react-native-toast-message"; import { useRouter } from "expo-router"; -import { getApiEndpoints } from "../utils/apiUtils"; -import { isV026OrHigher } from "../utils/headscaleVersion"; +import { getApiEndpoints, isApiSuccess } from "../utils/apiUtils"; +import { isV029OrHigher } from "../utils/headscaleVersion"; +import { parseRegistrationInput } from "../utils/registrationUtils"; import { Device } from "../types"; export function useDevices() { @@ -14,11 +16,17 @@ export function useDevices() { const [showRegisterModal, setShowRegisterModal] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [deviceKey, setDeviceKey] = useState(""); + const [serverVersion, setServerVersion] = useState(""); const router = useRouter(); - const fetchDevices = async () => { + const fetchDevices = useCallback(async () => { setLoading(true); try { + const config = await getApiEndpoints(); + if (config?.serverConf?.version) { + setServerVersion(config.serverConf.version); + } + const [devicesData, usersData] = await Promise.all([ getDevices(), getUsers() @@ -47,13 +55,15 @@ export function useDevices() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { - fetchDevices(); - }, []); + const handle = setTimeout(() => { + void fetchDevices(); + }, 0); + return () => clearTimeout(handle); + }, [fetchDevices]); - // Get appropriate icon based on device name/type const getDeviceTypeIcon = (deviceName: string = ""): any => { const name = deviceName.toLowerCase(); @@ -69,7 +79,6 @@ export function useDevices() { return 'devices-other'; }; - // Format last seen time const getLastSeenText = (lastSeen: string): string => { try { const lastSeenDate = new Date(lastSeen); @@ -91,39 +100,38 @@ export function useDevices() { } }; - // Get count of online devices const getOnlineDevicesCount = (): number => { return devices.filter(device => device.online).length; }; - // Sort devices by different criteria const sortDevices = (deviceList: Device[], sortBy: "name" | "lastSeen" | "user"): Device[] => { return [...deviceList].sort((a, b) => { switch (sortBy) { - case "name": + case "name": { const nameA = (a.givenName || a.name || "").toLowerCase(); const nameB = (b.givenName || b.name || "").toLowerCase(); return nameA.localeCompare(nameB); - - case "lastSeen": + } + case "lastSeen": { const dateA = new Date(a.lastSeen || 0).getTime(); const dateB = new Date(b.lastSeen || 0).getTime(); - return dateB - dateA; // Most recent first - - case "user": + return dateB - dateA; + } + case "user": { const userA = (a.user?.name || "").toLowerCase(); const userB = (b.user?.name || "").toLowerCase(); return userA.localeCompare(userB); - + } default: return 0; } }); }; - const confirmAndRegister = async (user: any, key: string) => { + const registrationFailed = (result: unknown) => !isApiSuccess(result); + + const confirmAndRegister = async (user: any, keyOrAuthId: string) => { try { - // Check server version to determine whether to use ID or name const config = await getApiEndpoints(); if (!config) { Toast.show({ @@ -135,22 +143,17 @@ export function useDevices() { return; } - const { serverConf } = config; - const useNumericIds = isV026OrHigher(serverConf.version); - - // Use user ID for v0.26+ or name for older versions - const userParam = useNumericIds ? user.id : user.name; - console.log(userParam, key) - const result = await registerDevice(userParam, key); + // Headscale RegisterNode resolves user by name, not numeric ID. + const result = await registerDevice(user.name, keyOrAuthId); - if (result) { + if (!registrationFailed(result)) { Toast.show({ type: "success", position: "top", text1: "✅ Device Registered", text2: `Device registered successfully for ${user.name}!`, }); - await fetchDevices(); // Refresh device list + await fetchDevices(); setShowRegisterModal(false); setSelectedUser(null); setDeviceKey(""); @@ -159,7 +162,9 @@ export function useDevices() { type: "error", position: "top", text1: "⚠️ Registration Failed", - text2: "Failed to register device. Check your credentials.", + text2: + result?.message || + "Failed to register device. Check the auth ID / key and user.", }); } } catch (error) { @@ -173,6 +178,44 @@ export function useDevices() { } }; + const confirmAuthAction = async ( + action: "approve" | "reject", + authId: string, + ) => { + try { + const result = + action === "approve" + ? await approveAuth(authId) + : await rejectAuth(authId); + + if (!registrationFailed(result)) { + Toast.show({ + type: "success", + position: "top", + text1: action === "approve" ? "✅ Auth Approved" : "✅ Auth Rejected", + text2: `Auth request ${authId} ${action}d.`, + }); + setShowRegisterModal(false); + setDeviceKey(""); + } else { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ Auth Action Failed", + text2: result?.message || `Failed to ${action} auth request.`, + }); + } + } catch (error) { + console.error("Auth action error:", error); + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ Auth Action Error", + text2: `An error occurred while trying to ${action}.`, + }); + } + }; + const handleRegisterDevice = () => { if (users.length === 0) { Toast.show({ @@ -186,45 +229,78 @@ export function useDevices() { setShowRegisterModal(true); }; - const handleKeyInput = (input: string) => { - // Regex to match "headscale nodes register --user USERNAME --key KEY" - const fullCommandMatch = input.match( - /headscale\s+nodes?\s+register\s+--user\s+([^\s]+)\s+--key\s+([A-Za-z0-9:_-]+)/i + const resolveUser = (nameOrId?: string) => { + if (!nameOrId) return selectedUser; + return ( + users.find( + (u) => + u.name === nameOrId || + String(u.id) === String(nameOrId), + ) || selectedUser ); - - if (fullCommandMatch) { - //const username = fullCommandMatch[1]; - const preAuthKey = fullCommandMatch[2]; - if (preAuthKey) { - setDeviceKey(preAuthKey); - confirmAndRegister(selectedUser, preAuthKey); - } else { + }; + + const handleKeyInput = async (input: string) => { + const parsed = parseRegistrationInput(input); + + if (parsed.kind === "auth-approve") { + await confirmAuthAction("approve", parsed.authId); + return; + } + if (parsed.kind === "auth-reject") { + await confirmAuthAction("reject", parsed.authId); + return; + } + + if (parsed.kind === "auth-register") { + const user = resolveUser(parsed.user); + if (!user) { Toast.show({ type: "error", position: "top", - text1: "⚠️ User Not Found", - text2: `User "${selectedUser}" not found in the system`, + text1: "⚠️ No User Selected", + text2: "Select a user or include --user in the auth register command.", }); + return; } - } else { - console.log("Treating as key only"); - // Treat as just a key input - const trimmedKey = input.trim(); - setDeviceKey(trimmedKey); - - if (selectedUser) { - confirmAndRegister(selectedUser, trimmedKey); - } else { + setDeviceKey(parsed.authId); + await confirmAndRegister(user, parsed.authId); + return; + } + + if (parsed.kind === "node-register") { + const user = resolveUser(parsed.user); + if (!user) { Toast.show({ type: "error", position: "top", text1: "⚠️ No User Selected", - text2: "Please select a user before registering with just a key.", + text2: "Select a user or include --user in the register command.", }); + return; } + setDeviceKey(parsed.key); + await confirmAndRegister(user, parsed.key); + return; + } + + const trimmedKey = parsed.value; + setDeviceKey(trimmedKey); + + if (!selectedUser) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ No User Selected", + text2: isV029OrHigher(serverVersion) + ? "Select a user before registering with an auth ID." + : "Please select a user before registering with just a key.", + }); + return; } + + await confirmAndRegister(selectedUser, trimmedKey); }; - const handleDevicePress = (device: Device) => { router.push({ @@ -235,12 +311,10 @@ export function useDevices() { }); }; - // Get devices by user const getDevicesByUser = (userId: string): Device[] => { return devices.filter(device => device.user?.id === userId); }; - // Get device statistics const getDeviceStats = () => { const totalDevices = devices.length; const onlineDevices = devices.filter(d => d.online).length; @@ -274,8 +348,7 @@ export function useDevices() { }; const handleModalRegister = () => { - console.log(selectedUser, deviceKey) - if (!selectedUser) { + if (!selectedUser && !/headscale\s+auth\s+(approve|reject)/i.test(deviceKey)) { Toast.show({ type: "error", position: "top", @@ -288,15 +361,57 @@ export function useDevices() { Toast.show({ type: "error", position: "top", - text1: "⚠️ No Key", - text2: "Please enter a device key", + text1: isV029OrHigher(serverVersion) ? "⚠️ No Auth ID" : "⚠️ No Key", + text2: isV029OrHigher(serverVersion) + ? "Enter an auth ID or paste a headscale auth command" + : "Please enter a device key", }); return; } - console.log(deviceKey) handleKeyInput(deviceKey); }; + const extractAuthId = (input: string) => { + const parsed = parseRegistrationInput(input); + if ( + parsed.kind === "auth-approve" || + parsed.kind === "auth-reject" || + parsed.kind === "auth-register" + ) { + return parsed.authId; + } + if (parsed.kind === "raw") return parsed.value; + return ""; + }; + + const handleModalApprove = () => { + const authId = extractAuthId(deviceKey); + if (!authId) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ No Auth ID", + text2: "Enter an auth ID to approve.", + }); + return; + } + confirmAuthAction("approve", authId); + }; + + const handleModalReject = () => { + const authId = extractAuthId(deviceKey); + if (!authId) { + Toast.show({ + type: "error", + position: "top", + text1: "⚠️ No Auth ID", + text2: "Enter an auth ID to reject.", + }); + return; + } + confirmAuthAction("reject", authId); + }; + return { devices, users, @@ -311,7 +426,7 @@ export function useDevices() { getDevicesByUser, getDeviceStats, router, - // Modal state and handlers + serverVersion, showRegisterModal, selectedUser, deviceKey, @@ -319,5 +434,7 @@ export function useDevices() { setDeviceKey, handleModalClose, handleModalRegister, + handleModalApprove, + handleModalReject, }; -} \ No newline at end of file +} diff --git a/app/funcs/index.ts b/app/funcs/index.ts index 8e04dc6..e94109f 100644 --- a/app/funcs/index.ts +++ b/app/funcs/index.ts @@ -1,11 +1,13 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useRouter } from "expo-router"; import Toast from "react-native-toast-message"; -import { testAPIKey } from "../api/login"; +import { testAPIKeyDetailed } from "../api/login"; +import { normalizeApiKey } from "../utils/apiKeyUtils"; import { parseVersion } from "../utils/getServer"; +import { isInsecureHttpUrl, useHttpRiskAck } from "../components/HttpInsecureWarning"; -export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x"; +export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x" | "0.29.x"; export function useLogin() { const router = useRouter(); @@ -13,48 +15,72 @@ export function useLogin() { const [customName, setCustomName] = useState(""); const [server, setServer] = useState(""); const [apiKey, setApiKey] = useState(""); - const [headscaleVersion, setHeadscaleVersion] = useState("0.26.x"); + const [headscaleVersion, setHeadscaleVersion] = useState("0.29.x"); const [showInfo, setShowInfo] = useState(null); const [loading, setLoading] = useState(true); + const httpRisk = useHttpRiskAck(server); // Toggle info display - close if same item clicked, open if different const toggleInfo = (field: string) => { setShowInfo(showInfo === field ? null : field); }; - const checkForPreviousKey = async () => { + const checkForPreviousKey = useCallback(async () => { setLoading(true); const selectedName = await AsyncStorage.getItem("selectedServer"); const serversJson = await AsyncStorage.getItem("servers"); - if (!selectedName || !serversJson) { + if (!serversJson) { setLoading(false); return; } - const servers = JSON.parse(serversJson); - const selected = servers.find(s => s.name === selectedName); + let servers: { name: string; server: string; apiKey: string }[] = []; + try { + servers = JSON.parse(serversJson); + } catch (e) { + console.warn("Failed to parse saved servers:", e); + setLoading(false); + return; + } - if (!selected) { + if (!Array.isArray(servers) || servers.length === 0) { setLoading(false); return; } - const isValid = await testAPIKey(selected.server, selected.apiKey); + // Try the selected server first, then the rest of the saved list. + const ordered = selectedName + ? [ + ...servers.filter((s) => s.name === selectedName), + ...servers.filter((s) => s.name !== selectedName), + ] + : servers; + + for (const candidate of ordered) { + const result = await testAPIKeyDetailed(candidate.server, candidate.apiKey); + if (!result.ok) continue; + + const fellBack = Boolean(selectedName) && candidate.name !== selectedName; + if (candidate.name !== selectedName) { + await AsyncStorage.setItem("selectedServer", candidate.name); + } - if (isValid) { Toast.show({ - type: "success", + type: fellBack ? "info" : "success", position: "top", - text1: "✅ Connected", - text2: `Connected to ${selected.name}.`, + text1: fellBack ? "Switched server" : "✅ Connected", + text2: fellBack + ? `"${selectedName}" failed the connection test. Connected to ${candidate.name}.` + : `Connected to ${candidate.name}.`, }); setLoading(false); router.push("/(tabs)"); - } else { - setLoading(false); + return; } - }; + + setLoading(false); + }, [router]); const handleLogin = async () => { if (!server || !apiKey || !customName) { @@ -76,21 +102,36 @@ export function useLogin() { }); return; } - - let isValid = false + + if (isInsecureHttpUrl(server) && !httpRisk.canProceed) { + Toast.show({ + type: "error", + position: "top", + text1: "HTTP confirmation required", + text2: "Confirm you understand the risk before sending your API token over HTTP.", + }); + return; + } + + const normalizedKey = normalizeApiKey(apiKey); + + let authResult: Awaited>; // temp for apple login demo, will do nothing - if (server === "https://appledemo.login.ieouiudhmpac.com" && apiKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7"){ - isValid = true + if ( + server === "https://appledemo.login.ieouiudhmpac.com" && + normalizedKey === "WlEB2D3t4fdash89LQW65KDsaD9oq0d2npso78uJolmOod2jp7" + ) { + authResult = { ok: true }; } else { - isValid = await testAPIKey(server, apiKey); + authResult = await testAPIKeyDetailed(server, normalizedKey); } - if (!isValid) { + if (!authResult.ok) { Toast.show({ type: "error", position: "top", - text1: "⚠️ Invalid API Key", - text2: "Check your API key and try again.", + text1: "⚠️ Connection Failed", + text2: authResult.message || "Check your API key and try again.", }); return; } @@ -104,7 +145,7 @@ export function useLogin() { const newEntry = { name: customName.trim(), server: server.trim(), - apiKey: apiKey.trim(), + apiKey: normalizedKey, addedOn: new Date().toISOString(), version: parseVersion(headscaleVersion) }; @@ -120,7 +161,7 @@ export function useLogin() { } const updated = [ - ...parsed.filter((item) => item.name !== newEntry.name), + ...parsed.filter((item: { name: string }) => item.name !== newEntry.name), newEntry, ]; @@ -143,5 +184,6 @@ export function useLogin() { loading, checkForPreviousKey, handleLogin, + httpRisk, }; } \ No newline at end of file diff --git a/app/funcs/preauthkeys.ts b/app/funcs/preauthkeys.ts index c158827..93bfc69 100644 --- a/app/funcs/preauthkeys.ts +++ b/app/funcs/preauthkeys.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getPreAuthKeys, createPreAuthKey, expirePreAuthKey } from "../api/preauthkeys"; import { getUsers } from "../api/users"; -import { getApiEndpoints } from "../utils/apiUtils"; +import { getApiEndpoints, isApiSuccess } from "../utils/apiUtils"; import { isV026OrHigher, isV028OrHigher } from "../utils/headscaleVersion"; import Toast from "react-native-toast-message"; import { calculateExpirationDate } from "../utils/time"; @@ -32,7 +32,7 @@ export const usePreAuthManager = () => { return user.name; }; - const fetchData = async () => { + const fetchData = useCallback(async () => { try { const config = await getApiEndpoints(); const detectedVersion = config?.serverConf?.version || '0.23.x'; @@ -81,11 +81,14 @@ export const usePreAuthManager = () => { } finally { setLoading(false); } - }; + }, []); useEffect(() => { - fetchData(); - }, []); + const handle = setTimeout(() => { + void fetchData(); + }, 0); + return () => clearTimeout(handle); + }, [fetchData]); const handleExpireKey = async (keyId: string, userName: string) => { // Find the user object to get the correct identifier @@ -105,7 +108,7 @@ export const usePreAuthManager = () => { const result = await expirePreAuthKey(userIdentifier, keyId, apiVersion); - if (result) { + if (isApiSuccess(result)) { Toast.show({ type: "success", position: "top", @@ -168,7 +171,7 @@ export const usePreAuthManager = () => { const result = await createPreAuthKey(userIdentifier, expirationDate, reusable); - if (result) { + if (isApiSuccess(result)) { Toast.show({ type: "success", position: "top", diff --git a/app/funcs/routes.ts b/app/funcs/routes.ts index 8a81b2e..4bc58f0 100644 --- a/app/funcs/routes.ts +++ b/app/funcs/routes.ts @@ -1,5 +1,5 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { getServerRoutes, disableRoute, enableRoute } from "../api/routes"; import Toast from "react-native-toast-message"; @@ -9,7 +9,7 @@ export function useRoutes() { const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(true); - const fetchRoutes = async () => { + const fetchRoutes = useCallback(async () => { try { const data = await getServerRoutes(); if (data?.routes) { @@ -22,7 +22,7 @@ export function useRoutes() { } finally { setLoading(false); } - }; + }, []); const handleDisableRoute = async (routeid: string) => { diff --git a/app/funcs/users.ts b/app/funcs/users.ts index b79c64b..6ea798b 100644 --- a/app/funcs/users.ts +++ b/app/funcs/users.ts @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Alert } from "react-native"; import Toast from "react-native-toast-message"; import { getUsers, addUser, deleteUser, renameUser } from "../api/users"; import { getDevices } from "../api/devices"; -import { getApiEndpoints } from "../utils/apiUtils"; +import { getApiEndpoints, isApiSuccess } from "../utils/apiUtils"; import { isV026OrHigher } from "../utils/headscaleVersion"; export function useUsers() { @@ -11,7 +11,7 @@ export function useUsers() { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); - const fetchUsers = async () => { + const fetchUsers = useCallback(async () => { setLoading(true); try { @@ -41,7 +41,7 @@ export function useUsers() { } finally { setLoading(false); } - }; + }, []); // Function to count devices for a specific user const getUserDeviceCount = (userId: string | number): number => { @@ -98,7 +98,7 @@ export function useUsers() { try { const response = await addUser(trimmed); - if (response) { + if (isApiSuccess(response)) { await fetchUsers(); // refresh list Toast.show({ type: "success", @@ -177,7 +177,7 @@ export function useUsers() { // Use user ID for v0.26+ or name for older versions const response = await renameUser(useNumericIds ? id : name, newName.trim()); - if (response) { + if (isApiSuccess(response)) { // Update local state immediately for better UX setUsers((prev) => prev.map((user) => @@ -235,7 +235,7 @@ export function useUsers() { // Use user ID for v0.26+ or name for older versions const response = await deleteUser(useNumericIds ? userId : userName); - if (response) { + if (isApiSuccess(response)) { // Remove from local state immediately setUsers((prev) => prev.filter((user) => user.id !== userId)); @@ -253,7 +253,11 @@ export function useUsers() { type: "error", position: "top", text1: "⚠️ Delete Failed", - text2: `Failed to delete user "${userName}".`, + text2: `Failed to delete user "${userName}".${ + response && typeof response === "object" && "message" in response + ? ` ${(response as { message?: string }).message}` + : "" + }`, }); } } catch (error) { @@ -268,8 +272,11 @@ export function useUsers() { }; useEffect(() => { - fetchUsers(); - }, []); + const handle = setTimeout(() => { + void fetchUsers(); + }, 0); + return () => clearTimeout(handle); + }, [fetchUsers]); return { users, diff --git a/app/index.tsx b/app/index.tsx index 9a691de..1ae7bb1 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -6,16 +6,121 @@ import { TouchableOpacity, KeyboardAvoidingView, Platform, - ActivityIndicator, Image, ScrollView } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialIcons } from "@expo/vector-icons"; import { useLogin, HeadscaleVersion } from "@/app/funcs/index"; +import { HttpInsecureWarning } from "@/app/components/HttpInsecureWarning"; +import { ScreenLoading } from "@/app/components/ScreenLoading"; +import { useTheme } from "@/theme"; -const VERSION_OPTIONS: HeadscaleVersion[] = ["0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"]; +const VERSION_OPTIONS: HeadscaleVersion[] = ["0.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"]; + +function InfoButton({ + field, + showInfo, + onPress, +}: { + field: string; + showInfo: string | null; + onPress: () => void; +}) { + const { theme } = useTheme(); + return ( + + + + ); +} + +function InfoText({ + field, + showInfo, + children, +}: { + field: string; + showInfo: string | null; + children: React.ReactNode; +}) { + const { theme } = useTheme(); + if (showInfo !== field) return null; + return ( + + {children} + + ); +} + +function VersionSelector({ + headscaleVersion, + setHeadscaleVersion, + showInfo, + toggleInfo, +}: { + headscaleVersion: HeadscaleVersion; + setHeadscaleVersion: (version: HeadscaleVersion) => void; + showInfo: string | null; + toggleInfo: (field: string) => void; +}) { + const { theme } = useTheme(); + const { colors } = theme; + return ( + + + Headscale Version + toggleInfo("version")} /> + + + + {VERSION_OPTIONS.map((version) => { + const selected = headscaleVersion === version; + return ( + setHeadscaleVersion(version)} + activeOpacity={0.8} + className="mr-3 px-4 py-2 rounded-lg border-2" + style={{ + backgroundColor: selected ? colors.primary : colors.surfaceMuted, + borderColor: selected ? colors.primaryMuted : colors.border, + }} + > + + {version} + + + ); + })} + + + + Select your Headscale server version to ensure compatibility with the correct API endpoints. Different versions may use different API paths and request formats. If unsure, check your server version with: {"\n"} + headscale version + + + ); +} export default function LoginScreen() { + const { theme } = useTheme(); + const { colors } = theme; const { customName, setCustomName, @@ -30,180 +135,134 @@ export default function LoginScreen() { loading, checkForPreviousKey, handleLogin, + httpRisk, } = useLogin(); useEffect(() => { checkForPreviousKey(); - }, []); + }, [checkForPreviousKey]); - const InfoButton = ({ field, onPress }: { field: string; onPress: () => void }) => ( - - - - ); - - const InfoText = ({ field, children }: { field: string; children: React.ReactNode }) => ( - showInfo === field ? ( - - - {children} - - - ) : null - ); - - const VersionSelector = () => ( - - - Headscale Version - toggleInfo("version")} /> - - - + - {VERSION_OPTIONS.map((version) => ( - setHeadscaleVersion(version)} - className={`mr-3 px-4 py-2 rounded-lg border-2 ${ - headscaleVersion === version - ? 'bg-blue-600 border-blue-500' - : 'bg-zinc-700 border-zinc-600' - }`} + {loading ? ( + + ) : ( + - - {version} - - - ))} - - - - Select your Headscale server version to ensure compatibility with the correct API endpoints. Different versions may use different API paths and request formats. If unsure, check your server version with: {"\n"} - headscale version - - - ); - - return ( - - {loading ? ( - - - Checking For Saved Login... - - ) : ( - - - {/* Logo */} - - - - Scale Manager - - - Connect to your Headscale server - - + + + + + Scale Manager + + + Connect to your Headscale server + + - {/* Custom Name Input */} - - - Custom Server Name - toggleInfo("name")} /> + + + Custom Server Name + toggleInfo("name")} /> + + + + Give your Headscale server a nickname to easily identify it later. This helps when managing multiple servers. + - - - Give your Headscale server a nickname to easily identify it later. This helps when managing multiple servers. - - - {/* Server Input */} - - - Server Domain / IP - toggleInfo("server")} /> + + + Server Domain / IP + toggleInfo("server")} /> + + + + Enter your Headscale server's full URL including http:// or https://. The server must be accessible from your device over the internet or local network. + + - - - Enter your Headscale server's full URL including http:// or https://. The server must be accessible from your device over the internet or local network. - - - {/* Version Selector */} - + - {/* API Key Input */} - - - API Key - toggleInfo("key")} /> + + + API Key + toggleInfo("key")} /> + + + + Generate a management API key (not a pre-auth key):{"\n"} + headscale apikeys create --expiration 90d + {"\n\n"} + Headscale v0.28+ keys look like{" "} + hskey-api-… + . Pre-auth keys (hskey-auth-…) cannot log in. + - - - Generate an API key using Headscale's CLI command: {"\n"} - headscale apikey create --expiration 90d - {"\n\n"}The API key provides secure access to your Headscale server's management functions. - - - {/* Login Button */} - - - Connect to Headscale - - - - - )} - + + + Connect to Headscale + + + + + )} + + ); -} \ No newline at end of file +} diff --git a/app/utils/apiKeyUtils.ts b/app/utils/apiKeyUtils.ts new file mode 100644 index 0000000..ce09f89 --- /dev/null +++ b/app/utils/apiKeyUtils.ts @@ -0,0 +1,75 @@ +/** + * Helpers for Headscale *API keys* (management auth). + * + * Do not confuse with pre-auth keys used by Tailscale clients: + * - API key (v0.28+): hskey-api-{prefix}-{secret} + * - API key (legacy): {prefix}.{secret} + * - Pre-auth key: hskey-auth-{prefix}-{secret} + * - Registration key: hskey-reg-{random} + * + * @see https://github.com/juanfont/headscale/releases/tag/v0.28.0 + */ + +export type ApiKeyKind = + | "api-v028" + | "api-legacy" + | "preauth" + | "registration" + | "unknown"; + +/** Strip whitespace and an accidental "Bearer " prefix from a pasted key. */ +export function normalizeApiKey(raw: string): string { + let key = (raw ?? "").trim(); + if (/^bearer\s+/i.test(key)) { + key = key.replace(/^bearer\s+/i, "").trim(); + } + // Remove any remaining whitespace/newlines from paste + return key.replace(/\s+/g, ""); +} + +/** Classify a pasted token so we can reject pre-auth keys used as API keys. */ +export function getApiKeyKind(key: string): ApiKeyKind { + const normalized = normalizeApiKey(key); + if (normalized.startsWith("hskey-api-")) return "api-v028"; + if (normalized.startsWith("hskey-auth-")) return "preauth"; + if (normalized.startsWith("hskey-reg-")) return "registration"; + // Legacy Headscale API keys: 7-char prefix + "." + secret + if (/^[A-Za-z0-9_-]{7}\.[A-Za-z0-9_-]+$/.test(normalized)) return "api-legacy"; + return "unknown"; +} + +export function isUsableApiKey(key: string): boolean { + const kind = getApiKeyKind(key); + return kind === "api-v028" || kind === "api-legacy" || kind === "unknown"; +} + +/** + * Headscale v0.28 lists API key prefixes masked, e.g. `hskey-api-AbCdEfGhIjKl-***`. + * Match a full key against that listed prefix (asterisks stripped). + */ +export function apiKeyMatchesListedPrefix( + fullKey: string, + listedPrefix: string | undefined | null, +): boolean { + if (!listedPrefix) return false; + const key = normalizeApiKey(fullKey); + const unmasked = listedPrefix.replace(/\*/g, ""); + return key.startsWith(unmasked); +} + +/** Prefer numeric id for expire/delete on v0.28+; fall back to listed prefix. */ +export function buildExpireApiKeyBody(key: { + id?: number | string; + prefix?: string; +}): { id: number } | { prefix: string } { + if (key.id !== undefined && key.id !== null && key.id !== "") { + const id = typeof key.id === "string" ? Number(key.id) : key.id; + if (Number.isFinite(id) && id > 0) { + return { id }; + } + } + if (!key.prefix) { + throw new Error("API key expire requires id or prefix"); + } + return { prefix: key.prefix }; +} diff --git a/app/utils/apiUtils.ts b/app/utils/apiUtils.ts index ef8831a..91dfde3 100644 --- a/app/utils/apiUtils.ts +++ b/app/utils/apiUtils.ts @@ -1,5 +1,6 @@ import { getServerConfig } from "../utils/getServer"; import { API_VERSION_MAP, ApiEndpoints } from "../config/apiVersions"; +import { normalizeApiKey } from "./apiKeyUtils"; import { getVersionKey } from "./headscaleVersion"; // Headscale's REST API uses singular resource names (e.g. /api/v1/node), but @@ -57,14 +58,18 @@ export async function fetchWithFallback( const candidates = buildEndpointCandidates(path); let lastResponse: Response | null = null; + const token = normalizeApiKey(apiKey); + for (const candidate of candidates) { const response = await fetch(`${server}${candidate}`, { ...options, headers: { Accept: "application/json", - Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...options.headers, + // Always win over caller headers so we never drop the Bearer scheme + // required by Headscale (missing "Bearer " is logged as an auth error). + Authorization: `Bearer ${token}`, }, }); @@ -98,13 +103,23 @@ export async function getApiEndpoints(): Promise<{ endpoints: ApiEndpoints; serv const endpoints = API_VERSION_MAP[versionKey]; if (!endpoints) { - console.warn(`No API endpoints found for version ${versionKey}, using default v0.26`); - return { endpoints: API_VERSION_MAP['v0.26'], serverConf }; + console.warn(`No API endpoints found for version ${versionKey}, using default v0.29`); + return { endpoints: API_VERSION_MAP['v0.29'], serverConf }; } return { endpoints, serverConf }; } +/** True when makeApiRequest / fetchWithFallback returned a successful result. */ +export function isApiSuccess(result: unknown): boolean { + if (result === null || result === undefined) return false; + if (typeof result !== "object") return true; + const r = result as Record; + if (r.error === true) return false; + if (typeof r.code === "number" && r.code >= 400) return false; + return true; +} + // Helper function to make API requests export async function makeApiRequest(url: string, options: RequestInit = {}) { const config = await getApiEndpoints(); @@ -134,8 +149,10 @@ export async function makeApiRequest(url: string, options: RequestInit = {}) { } } - const data = await response.json(); - return data; + const text = await response.text(); + // Some endpoints (e.g. DELETE user) return 200 with an empty body. + if (!text) return {}; + return JSON.parse(text); } catch (error) { console.error("Fetch error:", error); return null; diff --git a/app/utils/frontendLog.ts b/app/utils/frontendLog.ts new file mode 100644 index 0000000..95a97d3 --- /dev/null +++ b/app/utils/frontendLog.ts @@ -0,0 +1,138 @@ +import { File, Paths } from "expo-file-system"; +import * as Sharing from "expo-sharing"; +import { Platform, Share } from "react-native"; +import Constants from "expo-constants"; +import { getServerConfig } from "./getServer"; + +type LogLevel = "log" | "info" | "warn" | "error"; + +type LogEntry = { + time: string; + level: LogLevel; + message: string; +}; + +const MAX_ENTRIES = 800; +const entries: LogEntry[] = []; +let installed = false; + +function redact(text: string): string { + return text + .replace(/Bearer\s+\S+/gi, "Bearer [redacted]") + .replace(/hskey-[a-z]+-[^\s"'\\]+/gi, "hskey-[redacted]") + .replace(/((?:api[_-]?key|token|authorization)["']?\s*[:=]\s*["']?)[^\s"',}]+/gi, "$1[redacted]"); +} + +function formatArg(value: unknown): string { + if (value instanceof Error) { + return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ""}`; + } + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function push(level: LogLevel, args: unknown[]) { + const message = redact(args.map(formatArg).join(" ")); + entries.push({ time: new Date().toISOString(), level, message }); + if (entries.length > MAX_ENTRIES) { + entries.splice(0, entries.length - MAX_ENTRIES); + } +} + +export function installFrontendLogger() { + if (installed) return; + installed = true; + + (["log", "info", "warn", "error"] as const).forEach((level) => { + const original = console[level].bind(console); + console[level] = (...args: unknown[]) => { + try { + push(level, args); + } catch { + // Keep logging even if the buffer fails. + } + original(...args); + }; + }); + + const errorUtils = (globalThis as { ErrorUtils?: { + getGlobalHandler?: () => (error: unknown, isFatal?: boolean) => void; + setGlobalHandler?: (handler: (error: unknown, isFatal?: boolean) => void) => void; + } }).ErrorUtils; + + if (errorUtils?.getGlobalHandler && errorUtils.setGlobalHandler) { + const previous = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error, isFatal) => { + push("error", [`${isFatal ? "Fatal error" : "Unhandled error"}:`, error]); + previous?.(error, isFatal); + }); + } +} + +export function getFrontendLogCount() { + return entries.length; +} + +async function buildLogFile(): Promise { + const server = await getServerConfig(); + const lines = [ + "Scale Manager frontend log", + `Exported: ${new Date().toISOString()}`, + `App version: ${Constants.expoConfig?.version ?? "unknown"}`, + `Platform: ${Platform.OS} ${String(Platform.Version)}`, + server + ? `Selected server: ${server.name} (${server.server})` + : "Selected server: none", + `Entries: ${entries.length}`, + "", + ...entries.map((entry) => `[${entry.time}] ${entry.level.toUpperCase()} ${entry.message}`), + "", + ]; + return redact(lines.join("\n")); +} + +function logFileName() { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `scalemanager-logs-${stamp}.txt`; +} + +function downloadOnWeb(filename: string, contents: string) { + const blob = new Blob([contents], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +export async function exportFrontendLogs() { + const contents = await buildLogFile(); + const filename = logFileName(); + + if (Platform.OS === "web") { + downloadOnWeb(filename, contents); + return filename; + } + + const file = new File(Paths.cache, filename); + file.create({ overwrite: true }); + file.write(contents); + + const canShare = await Sharing.isAvailableAsync(); + if (canShare) { + await Sharing.shareAsync(file.uri, { + mimeType: "text/plain", + dialogTitle: "Export Scale Manager logs", + UTI: "public.plain-text", + }); + return filename; + } + + await Share.share({ title: filename, message: contents }); + return filename; +} diff --git a/app/utils/getServer.ts b/app/utils/getServer.ts index eb3cc6c..7cbe7e1 100644 --- a/app/utils/getServer.ts +++ b/app/utils/getServer.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { normalizeApiKey } from "./apiKeyUtils"; export async function getServerConfig() { const selectedName = await AsyncStorage.getItem("selectedServer"); @@ -8,8 +9,12 @@ export async function getServerConfig() { try { const servers = JSON.parse(serversJson); - const config = servers.find(s => s.name === selectedName); - return config || null; + const config = servers.find((s: { name: string }) => s.name === selectedName); + if (!config) return null; + return { + ...config, + apiKey: normalizeApiKey(config.apiKey ?? ""), + }; } catch (err) { console.error("Error parsing server config:", err); return null; diff --git a/app/utils/headscaleVersion.ts b/app/utils/headscaleVersion.ts index 808cef7..fffb6eb 100644 --- a/app/utils/headscaleVersion.ts +++ b/app/utils/headscaleVersion.ts @@ -16,3 +16,10 @@ export function isV028OrHigher(version?: string): boolean { if (!Number.isFinite(major) || !Number.isFinite(minor)) return false; return major > 0 || minor >= 28; } + +export function isV029OrHigher(version?: string): boolean { + const key = getVersionKey(version).replace(/^v/, ""); + const [major, minor] = key.split(".").map((part) => Number(part)); + if (!Number.isFinite(major) || !Number.isFinite(minor)) return false; + return major > 0 || minor >= 29; +} diff --git a/app/utils/registrationUtils.ts b/app/utils/registrationUtils.ts new file mode 100644 index 0000000..81dbec8 --- /dev/null +++ b/app/utils/registrationUtils.ts @@ -0,0 +1,70 @@ +/** + * Helpers for parsing device registration / auth inputs across Headscale versions. + * + * v0.29 prefers: headscale auth register --user --auth-id + * Older: headscale nodes register --user --key + */ + +export type ParsedRegistrationInput = + | { kind: "auth-register"; user?: string; authId: string } + | { kind: "node-register"; user?: string; key: string } + | { kind: "auth-approve"; authId: string } + | { kind: "auth-reject"; authId: string } + | { kind: "raw"; value: string }; + +export function parseRegistrationInput(input: string): ParsedRegistrationInput { + const trimmed = input.trim(); + if (!trimmed) return { kind: "raw", value: "" }; + + const authRegister = trimmed.match( + /headscale\s+auth\s+register\s+.*?--user\s+([^\s]+).*?--auth-id\s+([^\s]+)/i, + ) || trimmed.match( + /headscale\s+auth\s+register\s+.*?--auth-id\s+([^\s]+).*?--user\s+([^\s]+)/i, + ); + if (authRegister) { + // Groups depend on which pattern matched + if (/--user\s+[^\s]+\s+.*?--auth-id/i.test(trimmed)) { + return { kind: "auth-register", user: authRegister[1], authId: authRegister[2] }; + } + return { kind: "auth-register", authId: authRegister[1], user: authRegister[2] }; + } + + const authApprove = trimmed.match( + /headscale\s+auth\s+approve\s+.*?--auth-id\s+([^\s]+)/i, + ); + if (authApprove) { + return { kind: "auth-approve", authId: authApprove[1] }; + } + + const authReject = trimmed.match( + /headscale\s+auth\s+reject\s+.*?--auth-id\s+([^\s]+)/i, + ); + if (authReject) { + return { kind: "auth-reject", authId: authReject[1] }; + } + + const nodeRegister = trimmed.match( + /headscale\s+nodes?\s+register\s+.*?--user\s+([^\s]+).*?--key\s+([A-Za-z0-9:_-]+)/i, + ) || trimmed.match( + /headscale\s+nodes?\s+register\s+.*?--key\s+([A-Za-z0-9:_-]+).*?--user\s+([^\s]+)/i, + ); + if (nodeRegister) { + if (/--user\s+[^\s]+\s+.*?--key/i.test(trimmed)) { + return { kind: "node-register", user: nodeRegister[1], key: nodeRegister[2] }; + } + return { kind: "node-register", key: nodeRegister[1], user: nodeRegister[2] }; + } + + // Bare auth-id / key paste + return { kind: "raw", value: trimmed }; +} + +/** Zero-value timestamps Headscale used historically for "no expiry". */ +export function isNullExpiry(expiry?: string | null): boolean { + if (!expiry) return true; + return ( + expiry === "0001-01-01T00:00:00Z" || + expiry.startsWith("0001-01-01") || + expiry === "null" + ); +} diff --git a/components/SetupGuideModal.tsx b/components/SetupGuideModal.tsx index 428d6d7..b0d28da 100644 --- a/components/SetupGuideModal.tsx +++ b/components/SetupGuideModal.tsx @@ -9,6 +9,7 @@ import { Dimensions, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '@/theme'; interface SetupGuideModalProps { visible: boolean; @@ -18,6 +19,8 @@ interface SetupGuideModalProps { const { width } = Dimensions.get('window'); export default function SetupGuideModal({ visible, onClose }: SetupGuideModalProps) { + const { theme } = useTheme(); + const colors = theme.colors; const [currentStep, setCurrentStep] = useState(0); const fadeAnim = useMemo(() => new Animated.Value(0), []); const slideAnim = useMemo(() => new Animated.Value(50), []); @@ -28,23 +31,23 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro id: 'welcome', title: 'Welcome to ACL Setup', icon: 'rocket-launch', - color: '#3b82f6', + color: colors.primaryMuted, content: ( - - + + - + Let's Set Up Your ACL - + This guide will walk you through configuring Headscale to manage ACL policies from the database - - What you'll need: + + What you'll need: {[ { icon: 'description', text: 'Access to your Headscale config file' }, @@ -53,21 +56,21 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro { icon: 'restart-alt', text: 'Permission to restart Headscale' }, ].map((item, idx) => ( - - + + - {item.text} + {item.text} ))} - + - + - Estimated Time - + Estimated Time + This process takes about 5 minutes to complete @@ -80,27 +83,27 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro id: 'config', title: 'Update Configuration', icon: 'settings', - color: '#8b5cf6', + color: colors.primaryMuted, content: ( - + First, we need to configure Headscale to use database mode for ACL policies. - + - - + + - Step 1: Locate Config File + Step 1: Locate Config File - + Find your Headscale configuration file. Common locations: - - + + /etc/headscale/config.yaml{'\n'} ~/.config/headscale/config.yaml{'\n'} ./config.yaml @@ -108,30 +111,30 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - + - - + + - Step 2: Edit Policy Section + Step 2: Edit Policy Section - - Find the policy section and update it: + + Find the policy section and update it: - - ❌ Before: - + + ❌ Before: + policy:{'\n'} {' '}mode: file{'\n'} {' '}path: /path/to/ACL.json - - ✅ After: - + + ✅ After: + policy:{'\n'} {' '}mode: database{'\n'} {' '}# path: /path/to/ACL.json @@ -141,7 +144,7 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - + Critical @@ -157,60 +160,60 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro id: 'restart', title: 'Restart Headscale', icon: 'restart-alt', - color: '#f59e0b', + color: colors.warning, content: ( - + After updating your configuration, restart Headscale to apply the changes. - - Choose your setup method: + + Choose your setup method: - + - - Systemd Service + + Systemd Service - + sudo systemctl restart headscale - + - - Docker Container + + Docker Container - + docker restart headscale - + - - Docker Compose + + Docker Compose - + docker-compose restart headscale - + - - Manual Process + + Manual Process - + # Stop the current process{'\n'} # Then start: headscale serve @@ -219,12 +222,12 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - + - + - Wait a moment - + Wait a moment + Give Headscale 5-10 seconds to fully restart before continuing to the next step @@ -237,27 +240,27 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro id: 'set-policy', title: 'Set ACL Policy', icon: 'code', - color: '#10b981', + color: colors.success, content: ( - + Now tell Headscale which ACL policy file to use in the database. - + - - + + - Run This Command + Run This Command - + Execute this command to set your ACL policy: - - + + headscale policy set \{'\n'} {' '}--file /path/to/your/ACL.json @@ -271,15 +274,15 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - - Example ACL Structure - + + Example ACL Structure + Your ACL.json file should look something like this: - - + + {`{ "hosts": { "server-1": "100.64.0.1", @@ -311,15 +314,15 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - + - + - Common Error - + Common Error + If you see: "acl policy not found" - + This means you need to run the command above to set your ACL file path in the database. @@ -332,26 +335,26 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro id: 'verify', title: 'Verify & Test', icon: 'check-circle', - color: '#10b981', + color: colors.success, content: ( - + Let's verify everything is working correctly! - - Test Your Setup + + Test Your Setup - - 1 + + 1 - Check current policy + Check current policy - - + + headscale policy get @@ -359,13 +362,13 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - - 2 + + 2 - List your users + List your users - - + + headscale users list @@ -373,38 +376,38 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro - - 3 + + 3 - Test this app + Test this app - + Try accessing the ACL section in this app to verify connectivity - + - + - + You're All Set! 🎉 - + If you can see your ACL policy and users, your setup is complete! You can now manage your ACL policies directly from this app. - + - + - Need More Help? - + Need More Help? + Check the Headscale documentation or the app's error messages for specific guidance if you encounter issues. @@ -413,11 +416,16 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro ), }, - ], [width]); + ], [colors]); + + const [wasVisible, setWasVisible] = useState(visible); + if (visible !== wasVisible) { + setWasVisible(visible); + if (visible) setCurrentStep(0); + } useEffect(() => { if (visible) { - setCurrentStep(0); Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, @@ -477,20 +485,22 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro transparent={true} onRequestClose={onClose} > - + {/* Header with gradient background */} - @@ -498,26 +508,26 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro className="rounded-2xl p-3 mr-3 shadow-lg" style={{ backgroundColor: currentStepData.color }} > - + - {currentStepData.title} - + {currentStepData.title} + Step {currentStep + 1} of {steps.length} - + {/* Animated Progress Bar */} - + {/* Footer */} - + - + Back @@ -576,7 +583,7 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro ? currentStepData.color : index < currentStep ? `${currentStepData.color}60` - : '#3f3f46', + : colors.border, }} /> ))} @@ -588,13 +595,13 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro style={{ backgroundColor: currentStepData.color }} activeOpacity={0.8} > - + {currentStep === steps.length - 1 ? 'Finish' : 'Next'} diff --git a/hooks/useACL.ts b/hooks/useACL.ts index 80fd5a8..ef4dc8c 100644 --- a/hooks/useACL.ts +++ b/hooks/useACL.ts @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from "react"; import { Alert } from "react-native"; -import { getACLPolicy, updateACLPolicy } from "@/app/api/acl"; +import { checkACLPolicy, getACLPolicy, updateACLPolicy } from "@/app/api/acl"; +import { getApiEndpoints } from "@/app/utils/apiUtils"; +import { isV029OrHigher } from "@/app/utils/headscaleVersion"; interface PolicyVersion { id: string; @@ -21,6 +23,7 @@ interface ACLHookReturn { showSetupGuide: boolean; showErrorModal: boolean; currentError: any; + serverVersion: string; // Actions fetchPolicy: () => Promise; @@ -40,7 +43,6 @@ interface ACLHookReturn { } export const useACL = (): ACLHookReturn => { - // State management const [policy, setPolicy] = useState(""); const [originalPolicy, setOriginalPolicy] = useState(""); const [loading, setLoading] = useState(false); @@ -52,22 +54,26 @@ export const useACL = (): ACLHookReturn => { const [showSetupGuide, setShowSetupGuide] = useState(false); const [showErrorModal, setShowErrorModal] = useState(false); const [currentError, setCurrentError] = useState(null); + const [serverVersion, setServerVersion] = useState(""); - // Fetch policy function const fetchPolicy = useCallback(async () => { try { setLoading(true); + const config = await getApiEndpoints(); + if (config?.serverConf?.version) { + setServerVersion(config.serverConf.version); + } + const response = await getACLPolicy(); console.log('Fetch response:', response); if (response && "policy" in response) { let formattedPolicy = response.policy; - // Parse and format JSON properly try { const parsedPolicy = JSON.parse(formattedPolicy); formattedPolicy = JSON.stringify(parsedPolicy, null, 2); - } catch (e) { + } catch { formattedPolicy = formattedPolicy.replace(/\\n/g, '\n'); } @@ -79,7 +85,6 @@ export const useACL = (): ACLHookReturn => { } catch (error: any) { console.error("Error fetching policy:", error); - // Check for specific errors and show error modal const errorMessage = error?.message || error?.toString() || ''; if (errorMessage.includes('acl policy not found') || @@ -102,7 +107,6 @@ export const useACL = (): ACLHookReturn => { return; } - // Generic error setCurrentError({ message: errorMessage }); Alert.alert("Error", "Failed to fetch ACL policy. Check your connection."); } finally { @@ -110,13 +114,11 @@ export const useACL = (): ACLHookReturn => { } }, []); - // Save policy function const savePolicy = useCallback(async () => { try { setSaving(true); console.log("Saving policy:", editText); - // Validate JSON let parsedPolicy; try { parsedPolicy = JSON.parse(editText); @@ -126,24 +128,36 @@ export const useACL = (): ACLHookReturn => { return; } - // Save current version to history + if (isV029OrHigher(serverVersion)) { + const checkResult = await checkACLPolicy(editText); + if ( + checkResult && + !checkResult.skipped && + (checkResult.error || (checkResult.code !== undefined && checkResult.code >= 400)) + ) { + Alert.alert( + "Policy Check Failed", + checkResult.message || + "Headscale rejected this policy (ACL/grants/tests validation). Fix the errors and try again.", + ); + return; + } + } + if (policy) { const newVersion: PolicyVersion = { id: Date.now().toString(), policy: policy, timestamp: new Date(), }; - setPolicyVersions(prev => [newVersion, ...prev].slice(0, 20)); // Keep last 20 versions + setPolicyVersions(prev => [newVersion, ...prev].slice(0, 20)); } - // Update the policy const response = await updateACLPolicy(parsedPolicy); - // Check if response shows an error if (response && response.code) { let errorMessage = ''; - // Check if it's a server error response if (response.code !== undefined && response.message) { errorMessage = `Server Error (Code ${response.code}): ${response.message}`; } else if (response.error) { @@ -159,7 +173,6 @@ export const useACL = (): ACLHookReturn => { } } - // Check if response is successful if (response && !response.error && !response.code) { setPolicy(editText); setOriginalPolicy(editText); @@ -188,15 +201,13 @@ export const useACL = (): ACLHookReturn => { } finally { setSaving(false); } - }, [editText, policy]); + }, [editText, policy, serverVersion]); - // Start editing function const startEditing = useCallback(() => { setEditText(policy); setEditing(true); }, [policy]); - // Cancel editing function const cancelEditing = useCallback(() => { if (editText !== policy) { Alert.alert( @@ -220,7 +231,6 @@ export const useACL = (): ACLHookReturn => { } }, [editText, policy]); - // Restore version function const restoreVersion = useCallback((version: PolicyVersion) => { Alert.alert( "Restore Policy Version?", @@ -239,7 +249,6 @@ export const useACL = (): ACLHookReturn => { ); }, []); - // Delete version function const deleteVersion = useCallback((versionId: string) => { Alert.alert( "Delete Version", @@ -257,18 +266,18 @@ export const useACL = (): ACLHookReturn => { ); }, []); - // Refresh function const onRefresh = useCallback(() => { fetchPolicy(); }, [fetchPolicy]); - // Initialize on mount useEffect(() => { - fetchPolicy(); + const handle = setTimeout(() => { + void fetchPolicy(); + }, 0); + return () => clearTimeout(handle); }, [fetchPolicy]); return { - // State policy, originalPolicy, loading, @@ -280,8 +289,7 @@ export const useACL = (): ACLHookReturn => { showSetupGuide, showErrorModal, currentError, - - // Actions + serverVersion, fetchPolicy, savePolicy, startEditing, @@ -289,8 +297,6 @@ export const useACL = (): ACLHookReturn => { restoreVersion, deleteVersion, onRefresh, - - // Modal controls setShowVersions, setShowSetupGuide, setShowErrorModal, @@ -298,4 +304,3 @@ export const useACL = (): ACLHookReturn => { setEditText, }; }; - diff --git a/package.json b/package.json index 7ed42cb..047471b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "scalemanager", "main": "expo-router/entry", - "version": "1.2.2", + "version": "1.2.7", "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", @@ -17,49 +17,50 @@ "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", - "@react-navigation/bottom-tabs": "^7.18.7", - "@react-navigation/native": "^7.3.7", - "expo": "^54.0.35", - "expo-blur": "~15.0.8", - "expo-constants": "~18.0.13", - "expo-font": "~14.0.12", - "expo-haptics": "~15.0.8", - "expo-linking": "~8.0.12", - "expo-router": "~6.0.24", - "expo-splash-screen": "~31.0.13", - "expo-status-bar": "~3.0.9", - "expo-symbols": "~1.0.8", - "expo-system-ui": "~6.0.9", - "expo-web-browser": "~15.0.11", - "nativewind": "^4.2.6", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-native": "0.81.5", - "react-native-gesture-handler": "~2.28.0", - "react-native-reanimated": "~4.1.7", - "react-native-safe-area-context": "~5.6.2", - "react-native-screens": "~4.16.0", + "@react-navigation/bottom-tabs": "^7.19.2", + "@react-navigation/native": "^7.4.1", + "expo": "^57.0.25", + "expo-blur": "~57.0.3", + "expo-constants": "~57.0.19", + "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-linking": "~57.0.11", + "expo-router": "~57.0.23", + "expo-sharing": "~57.0.22", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", + "nativewind": "^4.2.7", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.2", "react-native-syntax-highlighter": "^2.1.0", - "react-native-toast-message": "^2.3.3", + "react-native-toast-message": "^2.5.2", "react-native-web": "^0.21.2", - "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1", "react-syntax-highlighter": "^15.6.6", "tailwindcss": "^3.4.19" }, "devDependencies": { "@babel/core": "^7.29.7", "@types/jest": "^29.5.14", - "@types/react": "~19.1.17", - "@types/react-test-renderer": "^19.1.0", + "@types/react": "~19.2.18", + "@types/react-test-renderer": "^19.3.0", "ajv": "^8.20.0", "ajv-keywords": "^5.1.0", "eslint": "^8.57.1", - "eslint-config-expo": "~10.0.0", + "eslint-config-expo": "~57.0.2", "jest": "^29.7.0", - "jest-expo": "~54.0.17", - "react-test-renderer": "^19.2.7", - "typescript": "^5.9.3" + "jest-expo": "~57.0.5", + "react-test-renderer": "^19.3.0", + "typescript": "~6.0.3" }, "private": true } diff --git a/theme/ThemeProvider.tsx b/theme/ThemeProvider.tsx new file mode 100644 index 0000000..2d5847d --- /dev/null +++ b/theme/ThemeProvider.tsx @@ -0,0 +1,107 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { useColorScheme } from "react-native"; +import { darkColors, lightColors, ThemeColors } from "./colors"; +import { radii } from "./radii"; +import { shadows } from "./shadows"; +import { spacing } from "./spacing"; +import { typography } from "./typography"; + +const STORAGE_KEY = "themePreference"; + +export type ThemePreference = "system" | "light" | "dark"; +export type ColorScheme = "light" | "dark"; + +export type Theme = { + scheme: ColorScheme; + preference: ThemePreference; + colors: ThemeColors; + spacing: typeof spacing; + radii: typeof radii; + shadows: typeof shadows; + typography: typeof typography; +}; + +type ThemeContextValue = { + theme: Theme; + toggleTheme: () => void; + setPreference: (preference: ThemePreference) => void; +}; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useColorScheme(); + const [preference, setPreferenceState] = useState("system"); + const [ready, setReady] = useState(false); + + useEffect(() => { + let active = true; + AsyncStorage.getItem(STORAGE_KEY) + .then((stored) => { + if (!active) return; + if (stored === "light" || stored === "dark" || stored === "system") { + setPreferenceState(stored); + } + }) + .finally(() => { + if (active) setReady(true); + }); + return () => { + active = false; + }; + }, []); + + const setPreference = useCallback((next: ThemePreference) => { + setPreferenceState(next); + AsyncStorage.setItem(STORAGE_KEY, next).catch(() => {}); + }, []); + + const scheme: ColorScheme = + preference === "system" + ? systemScheme === "light" + ? "light" + : "dark" + : preference; + + const toggleTheme = useCallback(() => { + setPreference(scheme === "dark" ? "light" : "dark"); + }, [scheme, setPreference]); + + const theme = useMemo( + () => ({ + scheme, + preference, + colors: scheme === "light" ? lightColors : darkColors, + spacing, + radii, + shadows, + typography, + }), + [scheme, preference] + ); + + const value = useMemo( + () => ({ theme, toggleTheme, setPreference }), + [theme, toggleTheme, setPreference] + ); + + if (!ready) return null; + + return {children}; +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + return context; +} diff --git a/theme/colors.ts b/theme/colors.ts new file mode 100644 index 0000000..6bd2127 --- /dev/null +++ b/theme/colors.ts @@ -0,0 +1,114 @@ +export type ThemeColors = { + primary: string; + primaryPressed: string; + primaryMuted: string; + primarySoft: string; + onPrimary: string; + secondary: string; + secondaryPressed: string; + onSecondary: string; + background: string; + surface: string; + surfaceMuted: string; + text: string; + textSecondary: string; + textMuted: string; + border: string; + error: string; + errorSoft: string; + success: string; + successSoft: string; + warning: string; + muted: string; + overlay: string; + skeleton: string; + tabBar: string; + tabBarBorder: string; + tabBarInactive: string; +}; + +const blue = { + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", +} as const; + +const zinc = { + 50: "#fafafa", + 100: "#f4f4f5", + 200: "#e4e4e7", + 300: "#d4d4d8", + 400: "#a1a1aa", + 600: "#52525b", + 700: "#3f3f46", + 800: "#27272a", + 900: "#18181b", +} as const; + +const slate = { + 200: "#e2e8f0", + 300: "#cbd5e1", + 400: "#94a3b8", + 500: "#64748b", + 600: "#475569", +} as const; + +export const darkColors: ThemeColors = { + primary: blue[600], + primaryPressed: blue[700], + primaryMuted: blue[400], + primarySoft: "rgba(37, 99, 235, 0.16)", + onPrimary: "#ffffff", + secondary: zinc[700], + secondaryPressed: zinc[600], + onSecondary: slate[200], + background: zinc[900], + surface: zinc[800], + surfaceMuted: zinc[700], + text: "#ffffff", + textSecondary: slate[300], + textMuted: slate[400], + border: zinc[700], + error: "#f87171", + errorSoft: "rgba(248, 113, 113, 0.16)", + success: "#10b981", + successSoft: "rgba(16, 185, 129, 0.16)", + warning: "#fbbf24", + muted: slate[500], + overlay: "rgba(0, 0, 0, 0.5)", + skeleton: zinc[700], + tabBar: zinc[800], + tabBarBorder: zinc[700], + tabBarInactive: zinc[400], +}; + +export const lightColors: ThemeColors = { + primary: blue[600], + primaryPressed: blue[700], + primaryMuted: blue[500], + primarySoft: "rgba(37, 99, 235, 0.1)", + onPrimary: "#ffffff", + secondary: zinc[100], + secondaryPressed: zinc[200], + onSecondary: zinc[900], + background: zinc[100], + surface: "#ffffff", + surfaceMuted: zinc[100], + text: zinc[900], + textSecondary: slate[600], + textMuted: slate[500], + border: zinc[200], + error: "#dc2626", + errorSoft: "rgba(220, 38, 38, 0.1)", + success: "#059669", + successSoft: "rgba(5, 150, 105, 0.1)", + warning: "#d97706", + muted: slate[400], + overlay: "rgba(24, 24, 27, 0.4)", + skeleton: zinc[200], + tabBar: "#ffffff", + tabBarBorder: zinc[200], + tabBarInactive: zinc[400], +}; diff --git a/theme/index.ts b/theme/index.ts new file mode 100644 index 0000000..65866bb --- /dev/null +++ b/theme/index.ts @@ -0,0 +1,8 @@ +export { darkColors, lightColors } from "./colors"; +export type { ThemeColors } from "./colors"; +export { radii } from "./radii"; +export { shadows } from "./shadows"; +export { spacing } from "./spacing"; +export { ThemeProvider, useTheme } from "./ThemeProvider"; +export type { ColorScheme, Theme, ThemePreference } from "./ThemeProvider"; +export { typography } from "./typography"; diff --git a/theme/radii.ts b/theme/radii.ts new file mode 100644 index 0000000..48b2d6a --- /dev/null +++ b/theme/radii.ts @@ -0,0 +1,7 @@ +export const radii = { + sm: 6, + md: 8, + lg: 12, + xl: 16, + full: 999, +} as const; diff --git a/theme/shadows.ts b/theme/shadows.ts new file mode 100644 index 0000000..ebe376c --- /dev/null +++ b/theme/shadows.ts @@ -0,0 +1,28 @@ +import { Platform, ViewStyle } from "react-native"; + +type ShadowToken = ViewStyle; + +function elevation(height: number, opacity: number, radius: number, android: number): ShadowToken { + return Platform.select({ + ios: { + shadowColor: "#18181b", + shadowOffset: { width: 0, height }, + shadowOpacity: opacity, + shadowRadius: radius, + }, + android: { elevation: android }, + default: { + shadowColor: "#18181b", + shadowOffset: { width: 0, height }, + shadowOpacity: opacity, + shadowRadius: radius, + }, + }) as ShadowToken; +} + +export const shadows = { + none: {} as ShadowToken, + sm: elevation(1, 0.06, 3, 1), + md: elevation(2, 0.08, 8, 3), + lg: elevation(6, 0.12, 16, 6), +}; diff --git a/theme/spacing.ts b/theme/spacing.ts new file mode 100644 index 0000000..fdf169b --- /dev/null +++ b/theme/spacing.ts @@ -0,0 +1,9 @@ +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, + xxxl: 48, +} as const; diff --git a/theme/typography.ts b/theme/typography.ts new file mode 100644 index 0000000..4e58b74 --- /dev/null +++ b/theme/typography.ts @@ -0,0 +1,48 @@ +import { TextStyle } from "react-native"; + +export const fontWeight = { + regular: "400" as const, + medium: "500" as const, + semibold: "600" as const, + bold: "700" as const, +}; + +export type TypeToken = Pick; + +export const typography = { + display: { + fontSize: 32, + lineHeight: 40, + fontWeight: fontWeight.bold, + }, + heading: { + fontSize: 24, + lineHeight: 32, + fontWeight: fontWeight.bold, + }, + subheading: { + fontSize: 18, + lineHeight: 24, + fontWeight: fontWeight.semibold, + }, + body: { + fontSize: 16, + lineHeight: 24, + fontWeight: fontWeight.regular, + }, + bodyMedium: { + fontSize: 16, + lineHeight: 24, + fontWeight: fontWeight.medium, + }, + caption: { + fontSize: 13, + lineHeight: 18, + fontWeight: fontWeight.regular, + }, + label: { + fontSize: 12, + lineHeight: 16, + fontWeight: fontWeight.medium, + }, +} as const satisfies Record;