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;