From 597f023af5805e9ed50f93ea6d8edc89726b8473 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Tue, 25 Aug 2026 18:55:58 +0000
Subject: [PATCH 1/9] fix: HS v0.28 token changes; minor toast fixes
---
app/(tabs)/apikeys.tsx | 2 +-
app/_layout.tsx | 51 +++++++++++++++-
app/accounts.tsx | 6 +-
app/api/acl.ts | 3 +-
app/api/apikeys.ts | 14 +++--
app/api/login.ts | 116 +++++++++++++++++++++++++++++-------
app/config/apiVersions.ts | 8 ++-
app/funcs/accounts.ts | 27 +++++----
app/funcs/apikeys.ts | 120 ++++++++++++++++++++------------------
app/funcs/index.ts | 32 +++++-----
app/index.tsx | 9 ++-
app/utils/apiKeyUtils.ts | 75 ++++++++++++++++++++++++
app/utils/apiUtils.ts | 7 ++-
app/utils/getServer.ts | 9 ++-
14 files changed, 355 insertions(+), 124 deletions(-)
create mode 100644 app/utils/apiKeyUtils.ts
diff --git a/app/(tabs)/apikeys.tsx b/app/(tabs)/apikeys.tsx
index 27c402b..9cb3270 100644
--- a/app/(tabs)/apikeys.tsx
+++ b/app/(tabs)/apikeys.tsx
@@ -259,7 +259,7 @@ export default function ApiKeysScreen() {
{
text: "Expire Key",
style: "destructive",
- onPress: () => handleExpireKey(key.prefix),
+ onPress: () => handleExpireKey({ id: key.id, prefix: key.prefix }),
},
]
);
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 91d496b..56246d0 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,18 +1,65 @@
import { Slot } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
+import Toast, {
+ BaseToast,
+ ErrorToast,
+ InfoToast,
+ type ToastConfig,
+} from "react-native-toast-message";
import "../global.css";
-import Toast from "react-native-toast-message";
+
+// Clear the library's fixed 60px height so long text2 messages can wrap fully.
+const toastBaseStyle = {
+ height: null as unknown as number,
+ minHeight: 60,
+ paddingVertical: 12,
+ width: "90%" as const,
+};
+
+const toastConfig: ToastConfig = {
+ success: (props) => (
+
+ ),
+ error: (props) => (
+
+ ),
+ info: (props) => (
+
+ ),
+};
export default function RootLayout() {
return (
- )
+ );
}
diff --git a/app/accounts.tsx b/app/accounts.tsx
index 965043d..b2e377b 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -288,8 +288,10 @@ export default function Accounts() {
autoCorrect={false}
/>
- 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-…
diff --git a/app/api/acl.ts b/app/api/acl.ts
index 7fe9025..8e67e25 100644
--- a/app/api/acl.ts
+++ b/app/api/acl.ts
@@ -22,7 +22,7 @@ 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);
@@ -36,7 +36,6 @@ export async function updateACLPolicy(policy: any) {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
- 'Authorization': `Bearer ${serverConf.apiKey}`,
},
body: requestBody,
});
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/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/config/apiVersions.ts b/app/config/apiVersions.ts
index 3031864..1fac324 100644
--- a/app/config/apiVersions.ts
+++ b/app/config/apiVersions.ts
@@ -519,6 +519,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 +528,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) }
}),
},
diff --git a/app/funcs/accounts.ts b/app/funcs/accounts.ts
index 04efa82..1d56efb 100644
--- a/app/funcs/accounts.ts
+++ b/app/funcs/accounts.ts
@@ -2,7 +2,8 @@ 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";
export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
@@ -143,23 +144,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 +173,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/index.ts b/app/funcs/index.ts
index 8e04dc6..73aa234 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -2,7 +2,8 @@ import { 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";
export type HeadscaleVersion = "0.23.x" | "0.24.x" | "0.25.x" | "0.26.x" | "0.27.x" | "0.28.x";
@@ -33,16 +34,16 @@ export function useLogin() {
}
const servers = JSON.parse(serversJson);
- const selected = servers.find(s => s.name === selectedName);
+ const selected = servers.find((s: { name: string }) => s.name === selectedName);
if (!selected) {
setLoading(false);
return;
}
- const isValid = await testAPIKey(selected.server, selected.apiKey);
+ const result = await testAPIKeyDetailed(selected.server, selected.apiKey);
- if (isValid) {
+ if (result.ok) {
Toast.show({
type: "success",
position: "top",
@@ -77,20 +78,25 @@ export function useLogin() {
return;
}
- 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);
}
- 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 +110,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 +126,7 @@ export function useLogin() {
}
const updated = [
- ...parsed.filter((item) => item.name !== newEntry.name),
+ ...parsed.filter((item: { name: string }) => item.name !== newEntry.name),
newEntry,
];
diff --git a/app/index.tsx b/app/index.tsx
index 9a691de..fc93cb4 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -185,9 +185,12 @@ export default function LoginScreen() {
autoCorrect={false}
/>
- 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.
+ 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.
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..7a6eb24 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}`,
},
});
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;
From 356bdd677322e6a46d37c20374a523d002a51f88 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:09:14 +0000
Subject: [PATCH 2/9] feat: support for HS v0.29
---
app/(tabs)/acl.tsx | 6 +
app/(tabs)/devices.tsx | 6 +
app/accounts.tsx | 4 +-
app/api/acl.ts | 37 +++-
app/api/auth.ts | 35 ++++
app/api/devices.ts | 24 ++-
app/components/RegisterDeviceModal.tsx | 74 ++++++--
app/config/apiVersions.ts | 128 ++++++++++++++
app/customScreens/[id].tsx | 23 ++-
app/funcs/accounts.ts | 2 +-
app/funcs/deviceDetail.ts | 8 +
app/funcs/devices.ts | 227 +++++++++++++++++++------
app/funcs/index.ts | 4 +-
app/index.tsx | 2 +-
app/utils/apiUtils.ts | 4 +-
app/utils/headscaleVersion.ts | 7 +
app/utils/registrationUtils.ts | 70 ++++++++
hooks/useACL.ts | 56 +++---
18 files changed, 599 insertions(+), 118 deletions(-)
create mode 100644 app/api/auth.ts
create mode 100644 app/utils/registrationUtils.ts
diff --git a/app/(tabs)/acl.tsx b/app/(tabs)/acl.tsx
index 160b085..ccc06d1 100644
--- a/app/(tabs)/acl.tsx
+++ b/app/(tabs)/acl.tsx
@@ -39,6 +39,7 @@ export default function ACLScreen() {
setShowVersions,
setShowSetupGuide,
setEditText,
+ serverVersion,
} = useACL();
return (
@@ -54,6 +55,11 @@ export default function ACLScreen() {
ACL Policy
Access Control List Management
+ {serverVersion?.startsWith("0.29") && (
+
+ v0.29: supports grants, nodeAttrs, tests & sshTests. Policy is checked before save.
+
+ )}
diff --git a/app/(tabs)/devices.tsx b/app/(tabs)/devices.tsx
index 7f47f19..9d70fd1 100644
--- a/app/(tabs)/devices.tsx
+++ b/app/(tabs)/devices.tsx
@@ -33,6 +33,9 @@ export default function DevicesScreen() {
setDeviceKey,
handleModalClose,
handleModalRegister,
+ handleModalApprove,
+ handleModalReject,
+ serverVersion,
} = useDevices();
const [searchQuery, setSearchQuery] = useState("");
@@ -288,6 +291,9 @@ export default function DevicesScreen() {
deviceKey={deviceKey}
onKeyChange={setDeviceKey}
onRegister={handleModalRegister}
+ serverVersion={serverVersion}
+ onApprove={handleModalApprove}
+ onReject={handleModalReject}
/>
);
diff --git a/app/accounts.tsx b/app/accounts.tsx
index b2e377b..26b1e05 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -15,7 +15,7 @@ import { MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useAccountsManager, HeadscaleVersion } from "@/app/funcs/accounts";
-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"];
export default function Accounts() {
const {
@@ -31,7 +31,7 @@ export default function Accounts() {
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);
diff --git a/app/api/acl.ts b/app/api/acl.ts
index 8e67e25..72978c5 100644
--- a/app/api/acl.ts
+++ b/app/api/acl.ts
@@ -23,15 +23,15 @@ export async function updateACLPolicy(policy: any) {
if (!config) return null;
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: {
@@ -45,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/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..e025968 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,27 @@ 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 with { user, authId }
+ * - older: POST /api/v1/node/register with { user, key }
+ */
+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);
-
+ const { endpoints, serverConf } = config;
+ const apiCall = endpoints.devices.registerDevice(user as number, keyOrAuthId);
+
+ // Ensure JSON body is always sent (required for v0.29 auth/register).
+ const body = apiCall.body
+ ? apiCall.body
+ : isV029OrHigher(serverConf.version)
+ ? { user: String(user), authId: keyOrAuthId }
+ : { user, key: keyOrAuthId };
+
return await makeApiRequest(apiCall.url, {
method: apiCall.method,
- body: apiCall.body ? JSON.stringify(apiCall.body) : undefined,
+ body: JSON.stringify(body),
});
}
diff --git a/app/components/RegisterDeviceModal.tsx b/app/components/RegisterDeviceModal.tsx
index 2f20e60..882a954 100644
--- a/app/components/RegisterDeviceModal.tsx
+++ b/app/components/RegisterDeviceModal.tsx
@@ -12,6 +12,7 @@ import {
Keyboard,
} from "react-native";
import Toast from "react-native-toast-message";
+import { isV029OrHigher } from "../utils/headscaleVersion";
interface RegisterDeviceModalProps {
visible: boolean;
@@ -22,6 +23,9 @@ interface RegisterDeviceModalProps {
deviceKey: string;
onKeyChange: (key: string) => void;
onRegister: () => void;
+ serverVersion?: string;
+ onApprove?: () => void;
+ onReject?: () => void;
}
export const RegisterDeviceModal: React.FC = ({
@@ -33,9 +37,14 @@ export const RegisterDeviceModal: React.FC = ({
deviceKey,
onKeyChange,
onRegister,
+ serverVersion,
+ onApprove,
+ onReject,
}) => {
+ 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 +57,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;
}
@@ -70,9 +81,16 @@ export const RegisterDeviceModal: React.FC = ({
className="w-full max-w-md"
>
-
- 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:
@@ -120,11 +138,17 @@ export const RegisterDeviceModal: React.FC = ({
)}
- {/* Key Input */}
- Device Key:
+ {/* Key / Auth ID Input */}
+
+ {isV029 ? "Auth ID or command:" : "Device Key:"}
+
= ({
autoCapitalize="none"
autoCorrect={false}
/>
+ {isV029 && (
+
+ Examples:{"\n"}
+ headscale auth register --user alice --auth-id …{"\n"}
+ headscale auth approve --auth-id …{"\n"}
+ headscale auth reject --auth-id …
+
+ )}
{/* Buttons */}
-
+
= ({
+
+ {isV029 && onApprove && onReject && (
+
+
+
+ Approve
+
+
+
+
+ Reject
+
+
+
+ )}
@@ -160,4 +213,3 @@ export const RegisterDeviceModal: React.FC = ({
);
};
-
diff --git a/app/config/apiVersions.ts b/app/config/apiVersions.ts
index 1fac324..c6eb222 100644
--- a/app/config/apiVersions.ts
+++ b/app/config/apiVersions.ts
@@ -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 };
};
}
@@ -619,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..0a8cdac 100644
--- a/app/customScreens/[id].tsx
+++ b/app/customScreens/[id].tsx
@@ -8,6 +8,7 @@ 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";
@@ -41,6 +42,7 @@ export default function DeviceDetailScreen() {
handleApproveRoutes,
handleRemoveRoute,
handleDelete,
+ canChangeUser,
} = useDeviceDetail(deviceData);
const appliedTags = device?.tags || device?.validTags || [];
@@ -201,12 +203,17 @@ export default function DeviceDetailScreen() {
)}
+ {device.name && device.givenName && device.name !== device.givenName && (
+
+ )}
-
- {device.expiry && device.expiry !== "0001-01-01T00:00:00Z" && (
-
+
+ {!isNullExpiry(device.expiry) ? (
+
+ ) : (
+
)}
@@ -214,9 +221,13 @@ export default function DeviceDetailScreen() {
User Assignment
- setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded">
- Change
-
+ {canChangeUser ? (
+ setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded">
+ Change
+
+ ) : (
+ Fixed at registration (v0.28+)
+ )}
diff --git a/app/funcs/accounts.ts b/app/funcs/accounts.ts
index 1d56efb..5185921 100644
--- a/app/funcs/accounts.ts
+++ b/app/funcs/accounts.ts
@@ -6,7 +6,7 @@ import { testAPIKeyDetailed } from "../api/login";
import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
-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;
diff --git a/app/funcs/deviceDetail.ts b/app/funcs/deviceDetail.ts
index 2bca66a..633638b 100644
--- a/app/funcs/deviceDetail.ts
+++ b/app/funcs/deviceDetail.ts
@@ -18,6 +18,7 @@ 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) {
@@ -25,6 +26,12 @@ export function useDeviceDetail(deviceData: string | undefined) {
const parsedDevice: Device = JSON.parse(deviceData);
setDevice(parsedDevice);
loadUsers();
+ (async () => {
+ const versionInfo = await getVersionInfo();
+ if (versionInfo?.versionKey) {
+ setCanChangeUser(!isV028OrHigher(versionInfo.versionKey.replace(/^v/, "")));
+ }
+ })();
} catch (error) {
console.error("Failed to parse device data:", error);
Toast.show({
@@ -386,5 +393,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..a4d139e 100644
--- a/app/funcs/devices.ts
+++ b/app/funcs/devices.ts
@@ -1,10 +1,12 @@
import { 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 { isV026OrHigher, 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 () => {
setLoading(true);
try {
+ const config = await getApiEndpoints();
+ if (config?.serverConf?.version) {
+ setServerVersion(config.serverConf.version);
+ }
+
const [devicesData, usersData] = await Promise.all([
getDevices(),
getUsers()
@@ -53,7 +61,6 @@ export function useDevices() {
fetchDevices();
}, []);
- // Get appropriate icon based on device name/type
const getDeviceTypeIcon = (deviceName: string = ""): any => {
const name = deviceName.toLowerCase();
@@ -69,7 +76,6 @@ export function useDevices() {
return 'devices-other';
};
- // Format last seen time
const getLastSeenText = (lastSeen: string): string => {
try {
const lastSeenDate = new Date(lastSeen);
@@ -91,39 +97,39 @@ 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: any) =>
+ !result || result.error || (result.code !== undefined && result.code >= 400);
+
+ 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({
@@ -137,20 +143,17 @@ export function useDevices() {
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);
+ const result = await registerDevice(userParam, 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 73aa234..0afd435 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -6,7 +6,7 @@ import { testAPIKeyDetailed } from "../api/login";
import { normalizeApiKey } from "../utils/apiKeyUtils";
import { parseVersion } from "../utils/getServer";
-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();
@@ -14,7 +14,7 @@ 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);
diff --git a/app/index.tsx b/app/index.tsx
index fc93cb4..9354419 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -13,7 +13,7 @@ import {
import { MaterialIcons } from "@expo/vector-icons";
import { useLogin, HeadscaleVersion } from "@/app/funcs/index";
-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"];
export default function LoginScreen() {
const {
diff --git a/app/utils/apiUtils.ts b/app/utils/apiUtils.ts
index 7a6eb24..3e84a5f 100644
--- a/app/utils/apiUtils.ts
+++ b/app/utils/apiUtils.ts
@@ -103,8 +103,8 @@ 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 };
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/hooks/useACL.ts b/hooks/useACL.ts
index 80fd5a8..a0447c5 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,18 +54,22 @@ 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);
@@ -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,15 @@ export const useACL = (): ACLHookReturn => {
);
}, []);
- // Refresh function
const onRefresh = useCallback(() => {
fetchPolicy();
}, [fetchPolicy]);
- // Initialize on mount
useEffect(() => {
fetchPolicy();
}, [fetchPolicy]);
return {
- // State
policy,
originalPolicy,
loading,
@@ -280,8 +286,7 @@ export const useACL = (): ACLHookReturn => {
showSetupGuide,
showErrorModal,
currentError,
-
- // Actions
+ serverVersion,
fetchPolicy,
savePolicy,
startEditing,
@@ -289,8 +294,6 @@ export const useACL = (): ACLHookReturn => {
restoreVersion,
deleteVersion,
onRefresh,
-
- // Modal controls
setShowVersions,
setShowSetupGuide,
setShowErrorModal,
@@ -298,4 +301,3 @@ export const useACL = (): ACLHookReturn => {
setEditText,
};
};
-
From 3f6ed95709130a61678ede94e3c67e3298adaa2e Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Wed, 16 Sep 2026 23:50:18 +0000
Subject: [PATCH 3/9] device registrations fix; user delete fixes
---
app/api/devices.ts | 46 +++++++++++++++++++--------------------
app/api/preauthkeys.ts | 18 ++++++++++++---
app/config/apiVersions.ts | 2 +-
app/funcs/devices.ts | 12 +++++-----
app/funcs/preauthkeys.ts | 6 ++---
app/funcs/users.ts | 14 +++++++-----
app/utils/apiUtils.ts | 16 ++++++++++++--
7 files changed, 70 insertions(+), 44 deletions(-)
diff --git a/app/api/devices.ts b/app/api/devices.ts
index e025968..b95463c 100644
--- a/app/api/devices.ts
+++ b/app/api/devices.ts
@@ -11,25 +11,35 @@ export async function getDevices() {
/**
* Register a device.
- * - v0.29+: POST /api/v1/auth/register with { user, authId }
- * - older: POST /api/v1/node/register with { user, key }
+ * - 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, serverConf } = config;
- const apiCall = endpoints.devices.registerDevice(user as number, keyOrAuthId);
- // Ensure JSON body is always sent (required for v0.29 auth/register).
- const body = apiCall.body
- ? apiCall.body
- : isV029OrHigher(serverConf.version)
- ? { user: String(user), authId: keyOrAuthId }
- : { user, key: keyOrAuthId };
+ // Always pass username — Headscale RegisterNode uses GetUserByName().
+ const userName = String(user);
- return await makeApiRequest(apiCall.url, {
- method: apiCall.method,
- body: JSON.stringify(body),
+ 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',
});
}
@@ -39,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);
@@ -57,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);
@@ -75,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);
@@ -92,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;
}
@@ -103,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);
@@ -115,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/preauthkeys.ts b/app/api/preauthkeys.ts
index a6d83f8..43378ff 100644
--- a/app/api/preauthkeys.ts
+++ b/app/api/preauthkeys.ts
@@ -1,4 +1,4 @@
-import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils";
+import { getApiEndpoints, makeApiRequest, isApiSuccess } from "../utils/apiUtils";
import { isV028OrHigher } from "../utils/headscaleVersion";
export async function getPreAuthKeys(userIdentifier?: string) {
@@ -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/config/apiVersions.ts b/app/config/apiVersions.ts
index c6eb222..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 };
diff --git a/app/funcs/devices.ts b/app/funcs/devices.ts
index a4d139e..e0a137d 100644
--- a/app/funcs/devices.ts
+++ b/app/funcs/devices.ts
@@ -4,8 +4,8 @@ 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, isV029OrHigher } from "../utils/headscaleVersion";
+import { getApiEndpoints, isApiSuccess } from "../utils/apiUtils";
+import { isV029OrHigher } from "../utils/headscaleVersion";
import { parseRegistrationInput } from "../utils/registrationUtils";
import { Device } from "../types";
@@ -125,8 +125,7 @@ export function useDevices() {
});
};
- const registrationFailed = (result: any) =>
- !result || result.error || (result.code !== undefined && result.code >= 400);
+ const registrationFailed = (result: unknown) => !isApiSuccess(result);
const confirmAndRegister = async (user: any, keyOrAuthId: string) => {
try {
@@ -142,9 +141,8 @@ export function useDevices() {
}
const { serverConf } = config;
- const useNumericIds = isV026OrHigher(serverConf.version);
- const userParam = useNumericIds ? user.id : user.name;
- const result = await registerDevice(userParam, keyOrAuthId);
+ // Headscale RegisterNode resolves user by name, not numeric ID.
+ const result = await registerDevice(user.name, keyOrAuthId);
if (!registrationFailed(result)) {
Toast.show({
diff --git a/app/funcs/preauthkeys.ts b/app/funcs/preauthkeys.ts
index c158827..1ae0510 100644
--- a/app/funcs/preauthkeys.ts
+++ b/app/funcs/preauthkeys.ts
@@ -1,7 +1,7 @@
import { 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";
@@ -105,7 +105,7 @@ export const usePreAuthManager = () => {
const result = await expirePreAuthKey(userIdentifier, keyId, apiVersion);
- if (result) {
+ if (isApiSuccess(result)) {
Toast.show({
type: "success",
position: "top",
@@ -168,7 +168,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/users.ts b/app/funcs/users.ts
index b79c64b..6225329 100644
--- a/app/funcs/users.ts
+++ b/app/funcs/users.ts
@@ -3,7 +3,7 @@ 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() {
@@ -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) {
diff --git a/app/utils/apiUtils.ts b/app/utils/apiUtils.ts
index 3e84a5f..91dfde3 100644
--- a/app/utils/apiUtils.ts
+++ b/app/utils/apiUtils.ts
@@ -110,6 +110,16 @@ export async function getApiEndpoints(): Promise<{ endpoints: ApiEndpoints; serv
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();
@@ -139,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;
From 576f128995b4d580d207a8ee1ab6644e8ddd33f5 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:11:57 +0000
Subject: [PATCH 4/9] upgrade to expo v57; invalid session bug fix
---
app.json | 3 ++-
app/funcs/index.ts | 45 +++++++++++++++++++++++++--------
package.json | 62 +++++++++++++++++++++++-----------------------
3 files changed, 67 insertions(+), 43 deletions(-)
diff --git a/app.json b/app.json
index 449c754..1814d00 100644
--- a/app.json
+++ b/app.json
@@ -41,7 +41,8 @@
}
],
"expo-font",
- "expo-web-browser"
+ "expo-web-browser",
+ "expo-status-bar"
],
"experiments": {
"typedRoutes": true
diff --git a/app/funcs/index.ts b/app/funcs/index.ts
index 0afd435..f6c6f2c 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -28,33 +28,56 @@ export function useLogin() {
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: { name: string }) => 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 result = await testAPIKeyDetailed(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 (result.ok) {
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);
};
const handleLogin = async () => {
diff --git a/package.json b/package.json
index 7ed42cb..a032fd2 100644
--- a/package.json
+++ b/package.json
@@ -17,49 +17,49 @@
"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-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
}
From 41c67ca8706225014afb8aa0b905ef44db12d8a5 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:23:29 +0000
Subject: [PATCH 5/9] http risk warning feat
---
app/accounts.tsx | 14 +++-
app/components/HttpInsecureWarning.tsx | 98 ++++++++++++++++++++++++++
app/funcs/accounts.ts | 14 ++++
app/funcs/index.ts | 15 +++-
app/index.tsx | 13 +++-
5 files changed, 151 insertions(+), 3 deletions(-)
create mode 100644 app/components/HttpInsecureWarning.tsx
diff --git a/app/accounts.tsx b/app/accounts.tsx
index 26b1e05..dc9517a 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -14,6 +14,7 @@ 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";
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"];
@@ -36,6 +37,7 @@ export default function Accounts() {
const [editingVersion, setEditingVersion] = useState(null);
const router = useRouter();
+ const httpRisk = useHttpRiskAck(server);
const toggleInfo = (field: string) => {
setShowInfo(showInfo === field ? null : field);
@@ -47,6 +49,7 @@ export default function Accounts() {
server,
apiKey,
version: selectedVersion,
+ httpRiskAcknowledged: httpRisk.canProceed,
onSuccess: () => {
setModalVisible(false);
setCustomName("");
@@ -253,6 +256,12 @@ export default function Accounts() {
Enter the full URL including http:// or https://
+
{/* Version Selector */}
@@ -312,8 +321,11 @@ export default function Accounts() {
{loading ? (
diff --git a/app/components/HttpInsecureWarning.tsx b/app/components/HttpInsecureWarning.tsx
new file mode 100644
index 0000000..57a4f2d
--- /dev/null
+++ b/app/components/HttpInsecureWarning.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useState } from "react";
+import { Text, TouchableOpacity, View } from "react-native";
+import { MaterialIcons } from "@expo/vector-icons";
+
+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(WAIT_SECONDS);
+
+ useEffect(() => {
+ setAcknowledged(false);
+ if (!insecure) {
+ setSecondsLeft(0);
+ return;
+ }
+
+ setSecondsLeft(WAIT_SECONDS);
+ 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;
+}) {
+ 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/funcs/accounts.ts b/app/funcs/accounts.ts
index 5185921..14eda45 100644
--- a/app/funcs/accounts.ts
+++ b/app/funcs/accounts.ts
@@ -5,6 +5,7 @@ import Toast from "react-native-toast-message";
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" | "0.29.x";
@@ -21,6 +22,7 @@ interface AddAccountParams {
server: string;
apiKey: string;
version: HeadscaleVersion;
+ httpRiskAcknowledged?: boolean;
onSuccess?: () => void;
onFail?: () => void;
}
@@ -87,6 +89,7 @@ export function useAccountsManager() {
server,
apiKey,
version,
+ httpRiskAcknowledged = false,
onSuccess,
onFail,
}: AddAccountParams) => {
@@ -112,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) {
diff --git a/app/funcs/index.ts b/app/funcs/index.ts
index f6c6f2c..975f111 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -5,6 +5,7 @@ import Toast from "react-native-toast-message";
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" | "0.29.x";
@@ -17,6 +18,7 @@ export function useLogin() {
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) => {
@@ -100,7 +102,17 @@ export function useLogin() {
});
return;
}
-
+
+ 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>;
@@ -172,5 +184,6 @@ export function useLogin() {
loading,
checkForPreviousKey,
handleLogin,
+ httpRisk,
};
}
\ No newline at end of file
diff --git a/app/index.tsx b/app/index.tsx
index 9354419..2949802 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -12,6 +12,7 @@ import {
} from "react-native";
import { MaterialIcons } from "@expo/vector-icons";
import { useLogin, HeadscaleVersion } from "@/app/funcs/index";
+import { HttpInsecureWarning } from "@/app/components/HttpInsecureWarning";
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"];
@@ -30,6 +31,7 @@ export default function LoginScreen() {
loading,
checkForPreviousKey,
handleLogin,
+ httpRisk,
} = useLogin();
useEffect(() => {
@@ -163,6 +165,12 @@ export default function LoginScreen() {
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 */}
@@ -196,8 +204,11 @@ export default function LoginScreen() {
{/* Login Button */}
From 5169aa5dca5e78d576255fbd636120585577f9b1 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:37:36 +0000
Subject: [PATCH 6/9] lint fixes
---
app/(tabs)/apikeys.tsx | 123 ++++++++++++---------
app/(tabs)/index.tsx | 8 +-
app/(tabs)/preauthkeys.tsx | 2 +-
app/(tabs)/routes.tsx | 2 +-
app/accounts.tsx | 143 +++++++++++++-----------
app/api/preauthkeys.ts | 2 +-
app/api/users.ts | 8 +-
app/components/HttpInsecureWarning.tsx | 16 +--
app/customScreens/[id].tsx | 4 +-
app/funcs/deviceDetail.ts | 72 +++++++-----
app/funcs/devices.ts | 14 ++-
app/funcs/index.ts | 6 +-
app/funcs/preauthkeys.ts | 13 ++-
app/funcs/routes.ts | 6 +-
app/funcs/users.ts | 13 ++-
app/index.tsx | 146 +++++++++++++++----------
components/SetupGuideModal.tsx | 9 +-
hooks/useACL.ts | 7 +-
18 files changed, 350 insertions(+), 244 deletions(-)
diff --git a/app/(tabs)/apikeys.tsx b/app/(tabs)/apikeys.tsx
index 9cb3270..bb29aad 100644
--- a/app/(tabs)/apikeys.tsx
+++ b/app/(tabs)/apikeys.tsx
@@ -9,6 +9,70 @@ import { MaterialIcons } from "@expo/vector-icons";
import Toast from "react-native-toast-message";
import { useApiKeys } from "@/app/funcs/apikeys";
+function KeyDisplayModal({
+ visible,
+ apiKey,
+ onClose,
+ onCopy,
+}: {
+ visible: boolean;
+ apiKey: string | null;
+ onClose: () => void;
+ onCopy: (value: string) => void;
+}) {
+ return (
+
+
+
+
+
+
+ New API Key Created
+
+
+ Save this key securely - it won't be shown again
+
+
+
+
+
+ {apiKey}
+
+
+
+
+ apiKey && onCopy(apiKey)}
+ 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
+
+
+
+
+ );
+}
+
export default function ApiKeysScreen() {
const {
apiKeys,
@@ -63,58 +127,6 @@ 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 ? (
@@ -287,7 +299,12 @@ export default function ApiKeysScreen() {
)}
-
+
);
}
\ No newline at end of file
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 193c69f..2672e72 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -55,7 +55,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);
@@ -86,11 +87,6 @@ export default function IndexScreen() {
}
};
- useEffect(() => {
- measurePing();
- fetchData();
- }, []);
-
return (
{
fetchData();
- }, []);
+ }, [fetchData]);
const copyToClipboard = async (text: string, label: string) => {
try {
diff --git a/app/(tabs)/routes.tsx b/app/(tabs)/routes.tsx
index 3b2b2a8..5c29213 100644
--- a/app/(tabs)/routes.tsx
+++ b/app/(tabs)/routes.tsx
@@ -31,7 +31,7 @@ export default function RoutesScreen() {
useEffect(() => {
fetchRoutes();
- }, []);
+ }, [fetchRoutes]);
if (loading) {
return (
diff --git a/app/accounts.tsx b/app/accounts.tsx
index dc9517a..434716f 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -18,6 +18,80 @@ import { HttpInsecureWarning, useHttpRiskAck } from "@/app/components/HttpInsecu
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;
+}) {
+ return (
+
+
+
+ );
+}
+
+function InfoText({
+ field,
+ showInfo,
+ children,
+}: {
+ field: string;
+ showInfo: string | null;
+ children: React.ReactNode;
+}) {
+ if (showInfo !== field) return null;
+ return (
+
+ {children}
+
+ );
+}
+
+function VersionSelector({
+ currentVersion,
+ onVersionChange,
+}: {
+ currentVersion: HeadscaleVersion;
+ onVersionChange: (version: HeadscaleVersion) => void;
+}) {
+ return (
+
+ {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}
+
+
+ ))}
+
+ );
+}
+
export default function Accounts() {
const {
accounts,
@@ -64,59 +138,6 @@ 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 */}
@@ -224,7 +245,7 @@ export default function Accounts() {
Server Name
- toggleInfo("name")} />
+ toggleInfo("name")} />
-
+
Give this server a memorable name to identify it easily.
@@ -242,7 +263,7 @@ export default function Accounts() {
Server URL
- toggleInfo("server")} />
+ toggleInfo("server")} />
-
+
Enter the full URL including http:// or https://
Headscale Version
- toggleInfo("version")} />
+ toggleInfo("version")} />
-
+
Select your server's Headscale version for API compatibility. Check with: {'\n'}
headscale version
@@ -284,7 +305,7 @@ export default function Accounts() {
API Key
- toggleInfo("key")} />
+ toggleInfo("key")} />
-
+
Generate a management API key (not a pre-auth key):{"\n"}
headscale apikeys create --expiration 90d
{"\n"}
diff --git a/app/api/preauthkeys.ts b/app/api/preauthkeys.ts
index 43378ff..be72531 100644
--- a/app/api/preauthkeys.ts
+++ b/app/api/preauthkeys.ts
@@ -1,4 +1,4 @@
-import { getApiEndpoints, makeApiRequest, isApiSuccess } from "../utils/apiUtils";
+import { getApiEndpoints, makeApiRequest } from "../utils/apiUtils";
import { isV028OrHigher } from "../utils/headscaleVersion";
export async function getPreAuthKeys(userIdentifier?: string) {
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
index 57a4f2d..e109f43 100644
--- a/app/components/HttpInsecureWarning.tsx
+++ b/app/components/HttpInsecureWarning.tsx
@@ -11,16 +11,18 @@ export function isInsecureHttpUrl(server: string): boolean {
export function useHttpRiskAck(server: string) {
const insecure = isInsecureHttpUrl(server);
const [acknowledged, setAcknowledged] = useState(false);
- const [secondsLeft, setSecondsLeft] = useState(WAIT_SECONDS);
+ const [secondsLeft, setSecondsLeft] = useState(0);
+ const [trackedInsecure, setTrackedInsecure] = useState(insecure);
- useEffect(() => {
+ if (insecure !== trackedInsecure) {
+ setTrackedInsecure(insecure);
setAcknowledged(false);
- if (!insecure) {
- setSecondsLeft(0);
- return;
- }
+ setSecondsLeft(insecure ? WAIT_SECONDS : 0);
+ }
+
+ useEffect(() => {
+ if (!insecure) return;
- setSecondsLeft(WAIT_SECONDS);
const started = Date.now();
const timer = setInterval(() => {
const remaining = Math.max(
diff --git a/app/customScreens/[id].tsx b/app/customScreens/[id].tsx
index 0a8cdac..38dbef8 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
@@ -17,7 +17,7 @@ import { RoutesModal } from "../components/RoutesModal";
export default function DeviceDetailScreen() {
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,
diff --git a/app/funcs/deviceDetail.ts b/app/funcs/deviceDetail.ts
index 633638b..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("");
@@ -20,31 +32,12 @@ export function useDeviceDetail(deviceData: string | undefined) {
const [newTags, setNewTags] = useState("");
const [canChangeUser, setCanChangeUser] = useState(true);
- useEffect(() => {
- if (deviceData) {
- try {
- const parsedDevice: Device = JSON.parse(deviceData);
- setDevice(parsedDevice);
- loadUsers();
- (async () => {
- const versionInfo = await getVersionInfo();
- if (versionInfo?.versionKey) {
- setCanChangeUser(!isV028OrHigher(versionInfo.versionKey.replace(/^v/, "")));
- }
- })();
- } 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) {
@@ -53,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;
diff --git a/app/funcs/devices.ts b/app/funcs/devices.ts
index e0a137d..27aef7b 100644
--- a/app/funcs/devices.ts
+++ b/app/funcs/devices.ts
@@ -1,4 +1,4 @@
-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";
@@ -19,7 +19,7 @@ export function useDevices() {
const [serverVersion, setServerVersion] = useState("");
const router = useRouter();
- const fetchDevices = async () => {
+ const fetchDevices = useCallback(async () => {
setLoading(true);
try {
const config = await getApiEndpoints();
@@ -55,11 +55,14 @@ export function useDevices() {
} finally {
setLoading(false);
}
- };
+ }, []);
useEffect(() => {
- fetchDevices();
- }, []);
+ const handle = setTimeout(() => {
+ void fetchDevices();
+ }, 0);
+ return () => clearTimeout(handle);
+ }, [fetchDevices]);
const getDeviceTypeIcon = (deviceName: string = ""): any => {
const name = deviceName.toLowerCase();
@@ -140,7 +143,6 @@ export function useDevices() {
return;
}
- const { serverConf } = config;
// Headscale RegisterNode resolves user by name, not numeric ID.
const result = await registerDevice(user.name, keyOrAuthId);
diff --git a/app/funcs/index.ts b/app/funcs/index.ts
index 975f111..e94109f 100644
--- a/app/funcs/index.ts
+++ b/app/funcs/index.ts
@@ -1,4 +1,4 @@
-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";
@@ -25,7 +25,7 @@ export function useLogin() {
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");
@@ -80,7 +80,7 @@ export function useLogin() {
}
setLoading(false);
- };
+ }, [router]);
const handleLogin = async () => {
if (!server || !apiKey || !customName) {
diff --git a/app/funcs/preauthkeys.ts b/app/funcs/preauthkeys.ts
index 1ae0510..93bfc69 100644
--- a/app/funcs/preauthkeys.ts
+++ b/app/funcs/preauthkeys.ts
@@ -1,4 +1,4 @@
-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, isApiSuccess } from "../utils/apiUtils";
@@ -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
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 6225329..6ea798b 100644
--- a/app/funcs/users.ts
+++ b/app/funcs/users.ts
@@ -1,4 +1,4 @@
-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";
@@ -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 => {
@@ -272,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 2949802..6604c6d 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -16,57 +16,63 @@ import { HttpInsecureWarning } from "@/app/components/HttpInsecureWarning";
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"];
-export default function LoginScreen() {
- const {
- customName,
- setCustomName,
- server,
- setServer,
- apiKey,
- setApiKey,
- headscaleVersion,
- setHeadscaleVersion,
- showInfo,
- toggleInfo,
- loading,
- checkForPreviousKey,
- handleLogin,
- httpRisk,
- } = useLogin();
-
- useEffect(() => {
- checkForPreviousKey();
- }, []);
-
- const InfoButton = ({ field, onPress }: { field: string; onPress: () => void }) => (
+function InfoButton({
+ field,
+ showInfo,
+ onPress,
+}: {
+ field: string;
+ showInfo: string | null;
+ onPress: () => void;
+}) {
+ return (
-
);
+}
- const InfoText = ({ field, children }: { field: string; children: React.ReactNode }) => (
- showInfo === field ? (
-
-
- {children}
-
-
- ) : null
+function InfoText({
+ field,
+ showInfo,
+ children,
+}: {
+ field: string;
+ showInfo: string | null;
+ children: React.ReactNode;
+}) {
+ if (showInfo !== field) return null;
+ return (
+
+ {children}
+
);
+}
- const VersionSelector = () => (
+function VersionSelector({
+ headscaleVersion,
+ setHeadscaleVersion,
+ showInfo,
+ toggleInfo,
+}: {
+ headscaleVersion: HeadscaleVersion;
+ setHeadscaleVersion: (version: HeadscaleVersion) => void;
+ showInfo: string | null;
+ toggleInfo: (field: string) => void;
+}) {
+ return (
Headscale Version
- toggleInfo("version")} />
+ toggleInfo("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'
+ headscaleVersion === version
+ ? "bg-blue-600 border-blue-500"
+ : "bg-zinc-700 border-zinc-600"
}`}
>
-
+
{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 {
+ customName,
+ setCustomName,
+ server,
+ setServer,
+ apiKey,
+ setApiKey,
+ headscaleVersion,
+ setHeadscaleVersion,
+ showInfo,
+ toggleInfo,
+ loading,
+ checkForPreviousKey,
+ handleLogin,
+ httpRisk,
+ } = useLogin();
+
+ useEffect(() => {
+ checkForPreviousKey();
+ }, [checkForPreviousKey]);
return (
Custom Server Name
- toggleInfo("name")} />
+ toggleInfo("name")} />
-
+
Give your Headscale server a nickname to easily identify it later. This helps when managing multiple servers.
@@ -151,7 +182,7 @@ export default function LoginScreen() {
Server Domain / IP
- toggleInfo("server")} />
+ 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.
{/* Version Selector */}
-
+
{/* API Key Input */}
API Key
- toggleInfo("key")} />
+ toggleInfo("key")} />
-
+
Generate a management API key (not a pre-auth key):{"\n"}
headscale apikeys create --expiration 90d
{"\n\n"}
diff --git a/components/SetupGuideModal.tsx b/components/SetupGuideModal.tsx
index 428d6d7..2273098 100644
--- a/components/SetupGuideModal.tsx
+++ b/components/SetupGuideModal.tsx
@@ -413,11 +413,16 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro
),
},
- ], [width]);
+ ], []);
+
+ 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,
diff --git a/hooks/useACL.ts b/hooks/useACL.ts
index a0447c5..ef4dc8c 100644
--- a/hooks/useACL.ts
+++ b/hooks/useACL.ts
@@ -73,7 +73,7 @@ export const useACL = (): ACLHookReturn => {
try {
const parsedPolicy = JSON.parse(formattedPolicy);
formattedPolicy = JSON.stringify(parsedPolicy, null, 2);
- } catch (e) {
+ } catch {
formattedPolicy = formattedPolicy.replace(/\\n/g, '\n');
}
@@ -271,7 +271,10 @@ export const useACL = (): ACLHookReturn => {
}, [fetchPolicy]);
useEffect(() => {
- fetchPolicy();
+ const handle = setTimeout(() => {
+ void fetchPolicy();
+ }, 0);
+ return () => clearTimeout(handle);
}, [fetchPolicy]);
return {
From 6c05c020e37f5ce1d09d53c6c7ff565c87f88168 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:42:35 +0000
Subject: [PATCH 7/9] feat: logging & expoting debug logsg
---
app/(tabs)/index.tsx | 41 +++++++++++-
app/_layout.tsx | 3 +
app/utils/frontendLog.ts | 138 +++++++++++++++++++++++++++++++++++++++
package.json | 1 +
4 files changed, 182 insertions(+), 1 deletion(-)
create mode 100644 app/utils/frontendLog.ts
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 2672e72..b5bd960 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import {
Text,
View,
@@ -13,9 +13,12 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { FontAwesome, MaterialIcons, Ionicons } from "@expo/vector-icons";
import { useDashboardData } from "@/app/funcs/tabsHome";
+import { exportFrontendLogs } from "@/app/utils/frontendLog";
+import Toast from "react-native-toast-message";
export default function IndexScreen() {
const router = useRouter();
+ const [exportingLogs, setExportingLogs] = useState(false);
const {
devices,
usersCount,
@@ -72,6 +75,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);
@@ -252,6 +279,18 @@ export default function IndexScreen() {
+
+
+
+ {exportingLogs ? "Preparing log file..." : "Export debug logs"}
+
+
+
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/package.json b/package.json
index a032fd2..15aa168 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"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",
From 73bda971dd17931b9b992b4175acb4c87b8f0131 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:54:17 +0000
Subject: [PATCH 8/9] ui cleanup & update
---
app/(tabs)/_layout.tsx | 44 ++-
app/(tabs)/acl.tsx | 161 +++++-----
app/(tabs)/apikeys.tsx | 125 ++++----
app/(tabs)/devices.tsx | 119 ++++----
app/(tabs)/index.tsx | 387 +++++++++++++++++--------
app/(tabs)/preauthkeys.tsx | 169 ++++++-----
app/(tabs)/routes.tsx | 66 ++---
app/(tabs)/users.tsx | 108 +++----
app/_layout.tsx | 100 ++++---
app/accounts.tsx | 185 ++++++------
app/components/HttpInsecureWarning.tsx | 17 +-
app/components/InfoRow.tsx | 60 ++--
app/components/RegisterDeviceModal.tsx | 104 ++++---
app/components/RoutesModal.tsx | 83 +++---
app/components/ScreenLoading.tsx | 64 ++++
app/components/TagsModal.tsx | 27 +-
app/components/UserSelectionModal.tsx | 24 +-
app/customScreens/[id].tsx | 134 ++++-----
app/index.tsx | 297 ++++++++++---------
components/SetupGuideModal.tsx | 258 +++++++++--------
theme/ThemeProvider.tsx | 107 +++++++
theme/colors.ts | 114 ++++++++
theme/index.ts | 8 +
theme/radii.ts | 7 +
theme/shadows.ts | 28 ++
theme/spacing.ts | 9 +
theme/typography.ts | 48 +++
27 files changed, 1763 insertions(+), 1090 deletions(-)
create mode 100644 app/components/ScreenLoading.tsx
create mode 100644 theme/ThemeProvider.tsx
create mode 100644 theme/colors.ts
create mode 100644 theme/index.ts
create mode 100644 theme/radii.ts
create mode 100644 theme/shadows.ts
create mode 100644 theme/spacing.ts
create mode 100644 theme/typography.ts
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 ccc06d1..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,
@@ -43,7 +46,7 @@ export default function ACLScreen() {
} = useACL();
return (
-
+
}
@@ -53,10 +56,10 @@ 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.
)}
@@ -65,37 +68,36 @@ export default function ACLScreen() {
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}
/>
@@ -103,9 +105,9 @@ export default function ACLScreen() {
{/* Version count badge */}
{policyVersions.length > 0 && (
-
-
-
+
+
+
{policyVersions.length} saved version{policyVersions.length !== 1 ? 's' : ''}
@@ -114,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
@@ -140,18 +142,18 @@ export default function ACLScreen() {
)}
{/* Policy Display */}
-
+
-
-
+
+
- Current Policy
+ Current Policy
{loading && (
-
- Loading...
+
+ Loading...
)}
@@ -160,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
)}
@@ -230,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
@@ -266,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}
@@ -285,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()}
@@ -328,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 bb29aad..3916ee1 100644
--- a/app/(tabs)/apikeys.tsx
+++ b/app/(tabs)/apikeys.tsx
@@ -1,13 +1,15 @@
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,
@@ -20,6 +22,8 @@ function KeyDisplayModal({
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}
@@ -48,23 +52,23 @@ function KeyDisplayModal({
apiKey && onCopy(apiKey)}
- className="flex-1 bg-blue-600 py-3 rounded-lg flex-row items-center justify-center"
+ className="flex-1 py-3 rounded-lg flex-row items-center justify-center" style={{ backgroundColor: colors.primary }}
activeOpacity={0.7}
>
-
- Copy Key
+
+ Copy Key
- Done
+ Done
-
+
This is the only time you'll see the full API key
@@ -85,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);
@@ -128,12 +134,9 @@ export default function ApiKeysScreen() {
};
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
@@ -196,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
@@ -216,27 +219,28 @@ export default function ApiKeysScreen() {
return (
-
+
Key #{index + 1}
-
-
+
+
{expired ? 'EXPIRED' : 'ACTIVE'}
@@ -244,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()}
@@ -277,17 +281,16 @@ export default function ApiKeysScreen() {
);
}}
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"}
diff --git a/app/(tabs)/devices.tsx b/app/(tabs)/devices.tsx
index 9d70fd1..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 {
@@ -37,6 +38,8 @@ export default function DevicesScreen() {
handleModalReject,
serverVersion,
} = useDevices();
+ const { theme } = useTheme();
+ const { colors } = theme;
const [searchQuery, setSearchQuery] = useState("");
const [filterOnline, setFilterOnline] = useState<"all" | "online" | "offline">("all");
@@ -64,37 +67,36 @@ export default function DevicesScreen() {
return (
-
+
{loading ? (
-
-
- Loading Devices...
-
+
) : (
}
+ refreshControl={}
>
{/* Header */}
- Devices
-
+ Devices
+
{getOnlineDevicesCount()} online • {devices.length} total
-
-
+
+
-
- Register
+
+ Register
@@ -102,18 +104,19 @@ export default function DevicesScreen() {
{/* Search and Filter */}
{/* Search Bar */}
-
-
+
+
{searchQuery.length > 0 && (
- setSearchQuery("")}>
-
+ setSearchQuery("")} activeOpacity={0.7}>
+
)}
@@ -131,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})
@@ -150,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}
@@ -161,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"}
@@ -176,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'}
@@ -217,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('_', ' ')}
@@ -238,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`}
@@ -255,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}
@@ -272,8 +275,8 @@ export default function DevicesScreen() {
{/* Tap to View Indicator */}
- Tap for details
-
+ Tap for details
+
)})
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index b5bd960..81a27a9 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -2,22 +2,65 @@ 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,
@@ -32,6 +75,8 @@ export default function IndexScreen() {
handleSignOut
} = useDashboardData();
+ const showSkeleton = loading && devices.length === 0 && usersCount === 0;
+
const onRefresh = async () => {
await measurePing();
await fetchData();
@@ -114,111 +159,216 @@ export default function IndexScreen() {
}
};
+ 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()}
@@ -227,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}
@@ -252,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.
@@ -270,11 +423,12 @@ export default function IndexScreen() {
-
-
+
+
Report Issues & Suggestions
@@ -282,42 +436,49 @@ export default function IndexScreen() {
-
-
+
+
{exportingLogs ? "Preparing log file..." : "Export debug logs"}
-
-
+
+
Star on GitHub
+
-
-
+
+
Join Our Discord
-
-
+
github.com/loqtek/ScaleManager
-
+
discord.gg/fRMxHmwm4z
@@ -325,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 026a7e5..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,
@@ -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 5c29213..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,
@@ -35,36 +38,29 @@ export default function RoutesScreen() {
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 638fb55..43c1756 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,5 +1,7 @@
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,
@@ -11,50 +13,56 @@ import "../global.css";
installFrontendLogger();
-// Clear the library's fixed 60px height so long text2 messages can wrap fully.
-const toastBaseStyle = {
- height: null as unknown as number,
- minHeight: 60,
- paddingVertical: 12,
- width: "90%" as const,
-};
+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) => (
-
- ),
-};
+ const toastConfig: ToastConfig = {
+ success: (props) => (
+
+ ),
+ error: (props) => (
+
+ ),
+ info: (props) => (
+
+ ),
+ };
-export default function RootLayout() {
return (
-
+ <>
+
+ >
+ );
+}
+
+export default function RootLayout() {
+ return (
+
+
+
+
);
}
diff --git a/app/accounts.tsx b/app/accounts.tsx
index 434716f..47758d3 100644
--- a/app/accounts.tsx
+++ b/app/accounts.tsx
@@ -15,6 +15,7 @@ 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.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
@@ -27,12 +28,13 @@ function InfoButton({
showInfo: string | null;
onPress: () => void;
}) {
+ const { theme } = useTheme();
return (
-
+
);
@@ -47,10 +49,14 @@ function InfoText({
showInfo: string | null;
children: React.ReactNode;
}) {
+ const { theme } = useTheme();
if (showInfo !== field) return null;
return (
-
- {children}
+
+ {children}
);
}
@@ -62,6 +68,8 @@ function VersionSelector({
currentVersion: HeadscaleVersion;
onVersionChange: (version: HeadscaleVersion) => void;
}) {
+ const { theme } = useTheme();
+ const { colors } = theme;
return (
- {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"
- }`}
- >
- {
+ 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}
-
-
- ))}
+
+ {version}
+
+
+ );
+ })}
);
}
export default function Accounts() {
+ const { theme } = useTheme();
+ const { colors } = theme;
const {
accounts,
loading,
@@ -101,7 +114,7 @@ export default function Accounts() {
handleAddAccount,
updateAccountVersion,
} = useAccountsManager();
-
+
const [modalVisible, setModalVisible] = useState(false);
const [customName, setCustomName] = useState("");
const [server, setServer] = useState("");
@@ -109,7 +122,7 @@ export default function Accounts() {
const [selectedVersion, setSelectedVersion] = useState("0.29.x");
const [showInfo, setShowInfo] = useState(null);
const [editingVersion, setEditingVersion] = useState(null);
-
+
const router = useRouter();
const httpRisk = useHttpRiskAck(server);
@@ -139,24 +152,22 @@ export default function Accounts() {
};
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.
@@ -164,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);
@@ -214,8 +228,8 @@ export default function Accounts() {
}}
/>
) : (
-
- {acc.version}
+
+ {acc.version}
)}
@@ -224,33 +238,32 @@ export default function Accounts() {
)}
- {/* Add Account Modal */}
-
-
-
+
+
Add New Server
- {/* Custom Name Input */}
- Server Name
+ Server Name
toggleInfo("name")} />
@@ -259,16 +272,16 @@ export default function Accounts() {
- {/* Server URL Input */}
- Server URL
+ Server URL
toggleInfo("server")} />
- {/* Version Selector */}
- Headscale 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
+ API Key
toggleInfo("key")} />
Generate a management API key (not a pre-auth key):{"\n"}
- headscale apikeys create --expiration 90d
+ headscale apikeys create --expiration 90d
{"\n"}
- v0.28+ format: hskey-api-…
+ v0.28+ format: hskey-api-…
- {/* Buttons */}
{
setModalVisible(false);
setShowInfo(null);
@@ -338,20 +351,20 @@ export default function Accounts() {
setSelectedVersion("0.26.x");
}}
>
- Cancel
+ Cancel
-
+
{loading ? (
-
+
) : (
- Add Server
+ Add Server
)}
@@ -361,4 +374,4 @@ export default function Accounts() {
);
-}
\ No newline at end of file
+}
diff --git a/app/components/HttpInsecureWarning.tsx b/app/components/HttpInsecureWarning.tsx
index e109f43..478f760 100644
--- a/app/components/HttpInsecureWarning.tsx
+++ b/app/components/HttpInsecureWarning.tsx
@@ -1,6 +1,7 @@
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;
@@ -63,13 +64,18 @@ export function HttpInsecureWarning({
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.
@@ -83,12 +89,11 @@ export function HttpInsecureWarning({
{locked
? `Read the warning before continuing (${secondsLeft}s)`
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 882a954..d0f246c 100644
--- a/app/components/RegisterDeviceModal.tsx
+++ b/app/components/RegisterDeviceModal.tsx
@@ -13,6 +13,7 @@ import {
} from "react-native";
import Toast from "react-native-toast-message";
import { isV029OrHigher } from "../utils/headscaleVersion";
+import { useTheme } from "@/theme";
interface RegisterDeviceModalProps {
visible: boolean;
@@ -41,6 +42,8 @@ export const RegisterDeviceModal: React.FC = ({
onApprove,
onReject,
}) => {
+ const { theme } = useTheme();
+ const { colors } = theme;
const isV029 = isV029OrHigher(serverVersion);
const handleRegister = () => {
@@ -75,81 +78,69 @@ export const RegisterDeviceModal: React.FC = ({
onRequestClose={onClose}
>
-
+
-
-
+
+
{isV029 ? "Register / Auth" : "Register Device"}
{isV029 && (
-
+
v0.29 uses auth IDs. Paste an auth ID or a full{" "}
- headscale auth …{" "}
+ 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"
- }`}
- >
- {
+ 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 }}
>
- {user.name}
-
-
- ID: {user.id}
-
-
- ))}
+
+ {user.name}
+
+
+ ID: {user.id}
+
+
+ );
+ })}
) : (
-
-
+
+
No users available. Please add a user first.
)}
- {/* Key / Auth ID Input */}
-
+
{isV029 ? "Auth ID or command:" : "Device Key:"}
= ({
autoCorrect={false}
/>
{isV029 && (
-
+
Examples:{"\n"}
headscale auth register --user alice --auth-id …{"\n"}
headscale auth approve --auth-id …{"\n"}
@@ -165,22 +156,25 @@ export const RegisterDeviceModal: React.FC = ({
)}
- {/* Buttons */}
-
+
Cancel
-
+
Register
@@ -190,17 +184,21 @@ export const RegisterDeviceModal: React.FC = ({
-
+
Approve
-
+
Reject
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/customScreens/[id].tsx b/app/customScreens/[id].tsx
index 38dbef8..eeabb89 100644
--- a/app/customScreens/[id].tsx
+++ b/app/customScreens/[id].tsx
@@ -13,8 +13,11 @@ 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] = useState(() => new Animated.Value(0));
@@ -78,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'}
@@ -131,10 +133,10 @@ export default function DeviceDetailScreen() {
}}
className="items-center"
>
-
+
{device.givenName || device.name}
-
+
{device.user?.name} • {device.ipAddresses?.[0]}
@@ -149,10 +151,10 @@ export default function DeviceDetailScreen() {
}}
className="items-center"
>
-
+
{device.givenName || device.name}
-
+
{device.ipAddresses?.[0]}
@@ -168,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 }}>
+
@@ -218,15 +220,15 @@ export default function DeviceDetailScreen() {
{/* User Information */}
-
+
- User Assignment
+ User Assignment
{canChangeUser ? (
- setShowUserModal(true)} className="bg-blue-600 px-3 py-1 rounded">
- Change
+ setShowUserModal(true)} className="px-3 py-1 rounded" style={{ backgroundColor: colors.primary }}>
+ Change
) : (
- Fixed at registration (v0.28+)
+ Fixed at registration (v0.28+)
)}
@@ -241,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
)}
@@ -262,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)}>
-
+
@@ -284,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
@@ -304,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")}>
-
+
))}
@@ -319,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
@@ -343,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
@@ -361,14 +363,14 @@ export default function DeviceDetailScreen() {
)}
{/* Actions */}
-
- Actions
+
+ Actions
-
- Delete Device
+
+ Delete Device
diff --git a/app/index.tsx b/app/index.tsx
index 6604c6d..1ae7bb1 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -6,13 +6,15 @@ 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.29.x", "0.28.x", "0.27.x", "0.26.x", "0.25.x", "0.24.x", "0.23.x"];
@@ -25,12 +27,13 @@ function InfoButton({
showInfo: string | null;
onPress: () => void;
}) {
+ const { theme } = useTheme();
return (
-
+
);
@@ -45,10 +48,14 @@ function InfoText({
showInfo: string | null;
children: React.ReactNode;
}) {
+ const { theme } = useTheme();
if (showInfo !== field) return null;
return (
-
- {children}
+
+ {children}
);
}
@@ -64,10 +71,12 @@ function VersionSelector({
showInfo: string | null;
toggleInfo: (field: string) => void;
}) {
+ const { theme } = useTheme();
+ const { colors } = theme;
return (
- Headscale Version
+ Headscale Version
toggleInfo("version")} />
@@ -77,36 +86,41 @@ function VersionSelector({
className="flex-row"
contentContainerStyle={{ paddingRight: 20 }}
>
- {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"
- }`}
- >
- {
+ 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}
-
-
- ))}
+
+ {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
+ headscale version
);
}
export default function LoginScreen() {
+ const { theme } = useTheme();
+ const { colors } = theme;
const {
customName,
setCustomName,
@@ -129,131 +143,126 @@ export default function LoginScreen() {
}, [checkForPreviousKey]);
return (
-
- {loading ? (
-
-
- Checking For Saved Login...
-
- ) : (
-
-
- {/* Logo */}
-
-
-
- Scale Manager
-
-
- Connect to your Headscale server
-
-
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+ 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 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.
-
-
- {/* Login Button */}
-
-
- Connect to Headscale
-
-
-
-
- )}
-
+
+
+ Connect to Headscale
+
+
+
+
+ )}
+
+
);
-}
\ No newline at end of file
+}
diff --git a/components/SetupGuideModal.tsx b/components/SetupGuideModal.tsx
index 2273098..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,7 +416,7 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro
),
},
- ], []);
+ ], [colors]);
const [wasVisible, setWasVisible] = useState(visible);
if (visible !== wasVisible) {
@@ -482,20 +485,22 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro
transparent={true}
onRequestClose={onClose}
>
-
+
{/* Header with gradient background */}
-
@@ -503,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
@@ -581,7 +583,7 @@ export default function SetupGuideModal({ visible, onClose }: SetupGuideModalPro
? currentStepData.color
: index < currentStep
? `${currentStepData.color}60`
- : '#3f3f46',
+ : colors.border,
}}
/>
))}
@@ -593,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/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;
From 77b30af075ba1f2e5091661fd03c8e0b1c069908 Mon Sep 17 00:00:00 2001
From: gm852 <184552300+gm852@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:18:47 +0000
Subject: [PATCH 9/9] version bump
---
app.json | 2 +-
package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app.json b/app.json
index 1814d00..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",
diff --git a/package.json b/package.json
index 15aa168..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",