This commit is contained in:
2026-01-04 00:38:04 -05:00
Unverified
parent ec2425f2b7
commit d3dbd1e33a
20 changed files with 1622 additions and 1869 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joshua Higgins
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

112
README.md
View File

@@ -17,24 +17,23 @@ A React Native Expo app that connects to an UpSnap server and provides mobile ac
- Node.js installed
- Expo CLI installed (`npm install -g expo-cli`)
- An UpSnap server instance running (e.g., https://wol.f6knight.duckdns.org/)
- An UpSnap server instance running
### Installation
1. Install dependencies:
```bash
npm install
npm i
```
2. Start the development server:
```bash
npm start
npx expo run
```
3. Run on your preferred platform:
- iOS: Press `i` in the terminal or run `npm run ios`
- Android: Press `a` in the terminal or run `npm run android`
- Web: Press `w` in the terminal or run `npm run web`
- iOS: Press `i` in the terminal or run `npx expo run:ios`
- Android: Press `a` in the terminal or run `npx expo run:android`
## Usage
@@ -42,25 +41,15 @@ npm start
1. Open the app
2. Enter your UpSnap server credentials:
- Server Address
- Username or Email
- Password
- Check "Login as Admin" if you're an admin user
3. Tap "Login"
### Device Management
#### Adding a Device
1. Tap the "+ Add" button in the Devices screen
2. Fill in the required fields:
- Device Name (e.g., "My PC")
- MAC Address (format: XX:XX:XX:XX:XX:XX)
- IP Address
- Optional: Netmask, Broadcast Address, SecureOn Password, Port
3. Tap "Add Device"
#### Scanning for Devices
1. Tap the "Scan" button in the Devices screen
2. Wait for the network scan to complete
3. Tap on a discovered device to add it to your list
@@ -68,7 +57,6 @@ npm start
#### Managing Devices
- **View Devices**: Scroll through the list on the main screen
- **Device Details**: Tap on a device card to view and edit details
- **Wake Device**: Tap the green "Wake" button on a device card
- **Sleep Device**: Tap the orange "Sleep" button
- **Reboot Device**: Tap the blue "Reboot" button
@@ -80,93 +68,8 @@ npm start
- 🔴 Red dot: Device is offline
- 🟠 Orange dot: Status unknown
## Screens
### Login Screen
- Username/email and password fields
- Admin login option
- Persistent authentication using AsyncStorage
### Devices Screen
- List of all devices with status indicators
- Quick action buttons for each device
- Pull-to-refresh for updating status
- Add device and scan network buttons
### Device Details Screen
- View all device information
- Edit device details
- Delete device option
### Add Device Screen
- Manual device entry form
- All required and optional fields
- Input validation
### Scan Devices Screen
- Network scanning functionality
- List of discovered devices
- Quick add to device list
### Settings Screen
- User information display
- Server configuration
- Logout option
- Clear data option
## API Integration
The app connects to an UpSnap server via its REST API:
- **Authentication**: `/api/collections/users/auth-with-password` or `/api/collections/_superusers/auth-with-password`
- **Devices**: `/api/collections/devices/records`
- **Wake**: `/api/upsnap/wake/:id`
- **Sleep**: `/api/collections/upsnap/sleep/:id`
- **Reboot**: `/api/upsnap/reboot/:id`
- **Shutdown**: `/api/upsnap/shutdown/:id`
- **Scan**: `/api/upsnap/scan`
## Project Structure
```
src/
├── components/ # Reusable components
├── context/ # React context providers (AuthContext)
├── navigation/ # Navigation setup
├── screens/ # All app screens
│ ├── LoginScreen.tsx
│ ├── DeviceListScreen.tsx
│ ├── DeviceDetailsScreen.tsx
│ ├── AddDeviceScreen.tsx
│ ├── ScanDevicesScreen.tsx
│ └── SettingsScreen.tsx
├── services/ # API service
│ └── api.ts
└── types/ # TypeScript type definitions
└── index.ts
```
## Tech Stack
- **React Native**: Cross-platform mobile framework
- **Expo**: Development platform
- **TypeScript**: Type safety
- **React Navigation**: Navigation library
- **AsyncStorage**: Local data persistence
## Server Configuration
The app is pre-configured to connect to:
- Server URL: `https://wol.f6knight.duckdns.org`
- API Base: `/api`
To change the server URL, modify the `API_BASE_URL` constant in `src/services/api.ts`.
## Security Notes
- Authentication tokens are stored securely in AsyncStorage
- All API calls require authentication
- Destructive actions (sleep, reboot, shutdown) require confirmation
- Never expose your UpSnap server to the open web without proper security measures
## Troubleshooting
@@ -174,7 +77,6 @@ To change the server URL, modify the `API_BASE_URL` constant in `src/services/ap
### Login Issues
- Verify your username/email and password are correct
- Check if you need to log in as admin
- Ensure your UpSnap server is accessible
### Network Scan Issues
@@ -192,7 +94,7 @@ To change the server URL, modify the `API_BASE_URL` constant in `src/services/ap
## License
This project is a mobile companion to UpSnap, which is licensed under the MIT License.
This project is licensed under the MIT License.
## Credits

View File

@@ -1,6 +1,6 @@
{
"expo": {
"name": "remote-wol",
"name": "Remote WoL",
"slug": "remote-wol",
"version": "1.0.0",
"orientation": "portrait",
@@ -10,7 +10,8 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.remote-wol"
"icon": "./assets/remotewol-ios.icon",
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap"
},
"android": {
"adaptiveIcon": {

View File

@@ -1,11 +1,14 @@
import React from "react";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { View, TouchableOpacity, StyleSheet } from "react-native";
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
import React from "react";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useColorScheme } from "../../hooks/use-color-scheme";
export function DevicesHeader() {
const router = useRouter();
const isDark = useColorScheme() === "dark";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
return (
<View style={styles.headerRight}>
@@ -13,7 +16,7 @@ export function DevicesHeader() {
onPress={() => router.push("/scan-devices")}
style={styles.headerButton}
>
<Ionicons name="add-circle-outline" size={24} color="#007AFF" />
<Ionicons name="add-circle-outline" size={24} color={activityColor} />
</TouchableOpacity>
</View>
);
@@ -23,11 +26,11 @@ export default function TabsLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf="desktopcomputer"/>
<Icon sf="desktopcomputer" />
<Label>Devices</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf="gear"/>
<Icon sf="gear" />
<Label>Settings</Label>
</NativeTabs.Trigger>
</NativeTabs>

View File

@@ -1,66 +1,111 @@
import React, { useState, useEffect } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
Alert,
ActivityIndicator,
} from "react-native";
import { ContextMenu, Button as SwiftUIButton, Host } from "@expo/ui/swift-ui";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import { ContextMenu, Host, Button as SwiftUIButton } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { SymbolView } from "expo-symbols";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
AppState,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useColorScheme } from "../../hooks/use-color-scheme";
import api from "../../src/services/api";
import { Device } from "../../src/types";
function DevicesHeader() {
const router = useRouter();
return (
<View>
<TouchableOpacity onPress={() => router.push("/scan-devices")}>
<Ionicons name="add-circle-outline" size={24} color="#007AFF" />
</TouchableOpacity>
</View>
);
}
import * as Burnt from "burnt";
export default function DeviceListScreen() {
const router = useRouter();
const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const cardBg = isDark ? "#1c1c1e" : "#fff";
const textColor = isDark ? "#ffffff" : "#333333";
const subTextColor = isDark ? "#c6c6c8" : "#666666";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
const [devices, setDevices] = useState<Device[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
const fetchDevices = useCallback(async (showLoading = false) => {
try {
setIsLoading(true);
if (showLoading) setIsLoading(true);
const data = await api.getDevices();
setDevices(data);
} catch (error: any) {
// For background/periodic refreshes, avoid interruptive alerts
if (showLoading) {
Alert.alert("Error", error.message || "Failed to load devices");
}
} finally {
setIsLoading(false);
if (showLoading) setIsLoading(false);
}
}, []);
useEffect(() => {
fetchDevices(true);
}, [fetchDevices]);
useEffect(() => {
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
fetchDevices(false);
}, 10000) as unknown as number;
};
const stopPolling = () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
// Start polling while app is active; pause when backgrounded
startPolling();
const onAppStateChange = (nextAppState: string) => {
if (nextAppState === "active") {
startPolling();
} else {
stopPolling();
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => {
stopPolling();
subscription.remove();
};
}, [fetchDevices]);
const onRefresh = async () => {
setRefreshing(true);
await loadDevices();
await fetchDevices(false);
setRefreshing(false);
};
const handleWake = async (device: Device) => {
try {
await api.wakeDevice(device.id);
Alert.alert("Success", `Wake signal sent to ${device.name}`);
Burnt.toast({
title: "Success",
preset: "done",
message: `Waking ${device.name} up.`,
});
} catch (error: any) {
Alert.alert("Error", error.message || "Failed to wake device");
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to wake up ${device.name}.`,
});
}
};
@@ -73,9 +118,17 @@ export default function DeviceListScreen() {
onPress: async () => {
try {
await api.sleepDevice(device.id);
Alert.alert("Success", `Sleep signal sent to ${device.name}`);
Burnt.toast({
title: "Success",
preset: "done",
message: `Sending ${device.name} to sleep.`,
});
} catch (error: any) {
Alert.alert("Error", error.message || "Failed to sleep device");
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to send ${device.name} to sleep.`,
});
}
},
},
@@ -91,9 +144,17 @@ export default function DeviceListScreen() {
onPress: async () => {
try {
await api.rebootDevice(device.id);
Alert.alert("Success", `Reboot signal sent to ${device.name}`);
Burnt.toast({
title: "Success",
preset: "done",
message: `Rebooting ${device.name}.`,
});
} catch (error: any) {
Alert.alert("Error", error.message || "Failed to reboot device");
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to reboot ${device.name}`,
});
}
},
},
@@ -112,12 +173,17 @@ export default function DeviceListScreen() {
onPress: async () => {
try {
await api.shutdownDevice(device.id);
Alert.alert("Success", `Shutdown signal sent to ${device.name}`);
Burnt.toast({
title: "Success",
preset: "done",
message: `Shutting down ${device.name}.`,
});
} catch (error: any) {
Alert.alert(
"Error",
error.message || "Failed to shutdown device"
);
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to shut down ${device.name}.`,
});
}
},
},
@@ -127,16 +193,31 @@ export default function DeviceListScreen() {
const handleDelete = (device: Device) => {
Alert.alert("Delete Device", `Delete "${device.name}"?`, [
{
text: "Cancel",
style: "cancel",
onPress: () => {
// Close alert
},
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await api.deleteDevice(device.id);
Alert.alert("Success", "Device deleted successfully");
loadDevices();
Burnt.toast({
title: "Success",
preset: "done",
message: `Deleted ${device.name} successfully.`,
});
fetchDevices(false);
} catch (error: any) {
Alert.alert("Error", error.message || "Failed to delete device");
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || `Failed to delete ${device.name}.`,
});
}
},
},
@@ -154,6 +235,42 @@ export default function DeviceListScreen() {
}
};
const ActionIcon = ({
name,
symbolName,
color,
onPress,
fallbackName,
}: {
name: string;
symbolName: string;
color: string;
onPress: () => void;
fallbackName?: string;
}) => (
<TouchableOpacity
style={styles.actionIconContainer}
onPress={onPress}
accessibilityLabel={name}
activeOpacity={0.75}
>
<SymbolView
name={symbolName as any}
size={20}
tintColor={color}
type="monochrome"
fallback={
<Ionicons
name={(fallbackName || "ellipse") as any}
size={20}
color={color}
/>
}
style={styles.actionIcon}
/>
</TouchableOpacity>
);
const renderDevice = ({ item }: { item: Device }) => (
<Host>
<ContextMenu activationMethod="longPress">
@@ -167,51 +284,83 @@ export default function DeviceListScreen() {
</SwiftUIButton>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View style={styles.deviceCard}>
<TouchableOpacity
onPress={() => router.push(`/devices/${item.id}`)}
activeOpacity={1}
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? "rgba(0,0,0,0.6)" : "#000",
},
]}
>
<View>
<View style={styles.deviceHeader}>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name}</Text>
<Text style={styles.deviceIP}>{item.ip}</Text>
<Text style={[styles.deviceName, { color: textColor }]}>
{item.name}
</Text>
<Text style={[styles.deviceIP, { color: subTextColor }]}>
{item.mac}
</Text>
</View>
<SymbolView
name="circle.fill"
size={18}
tintColor={getStatusColor(item.status)}
animationSpec={{
effect: { type: "pulse" },
repeating: true,
speed: 1,
}}
fallback={
<View
style={[
styles.statusDot,
{ backgroundColor: getStatusColor(item.status) },
]}
/>
}
style={[
styles.statusSymbol,
{
shadowColor: getStatusColor(item.status),
shadowOpacity: isDark ? 0.9 : 0.6,
},
]}
/>
</View>
<View style={styles.deviceActions}>
<TouchableOpacity
style={[styles.actionButton, styles.wakeButton]}
<ActionIcon
name="Wake"
symbolName="bolt.fill"
fallbackName="flash"
color="#4CAF50"
onPress={() => handleWake(item)}
>
<Text style={styles.actionButtonText}>Wake</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.sleepButton]}
/>
<ActionIcon
name="Sleep"
symbolName="moon.fill"
fallbackName="moon"
color="#FF9800"
onPress={() => handleSleep(item)}
>
<Text style={styles.actionButtonText}>Sleep</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.rebootButton]}
/>
<ActionIcon
name="Reboot"
symbolName="arrow.clockwise.circle.fill"
fallbackName="refresh"
color="#2196F3"
onPress={() => handleReboot(item)}
>
<Text style={styles.actionButtonText}>Reboot</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.shutdownButton]}
/>
<ActionIcon
name="Shutdown"
symbolName="power.circle.fill"
fallbackName="power"
color="#f44336"
onPress={() => handleShutdown(item)}
>
<Text style={styles.actionButtonText}>Shutdown</Text>
</TouchableOpacity>
/>
</View>
</View>
</TouchableOpacity>
</View>
</ContextMenu.Trigger>
</ContextMenu>
@@ -221,19 +370,13 @@ export default function DeviceListScreen() {
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<ActivityIndicator size="large" color={activityColor} />
</View>
);
}
return (
<View style={styles.container}>
<Stack.Screen
options={{
title: "Devices",
headerRight: () => <DevicesHeader />,
}}
/>
<View style={[styles.container, { backgroundColor: bgColor }]}>
<FlatList
data={devices}
renderItem={renderDevice}
@@ -244,7 +387,9 @@ export default function DeviceListScreen() {
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No devices found</Text>
<Text style={[styles.emptyText, { color: subTextColor }]}>
No devices found
</Text>
</View>
}
/>
@@ -271,12 +416,13 @@ const styles = StyleSheet.create({
},
list: {
padding: 15,
gap: 15,
},
deviceCard: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 15,
marginBottom: 15,
paddingRight: 25,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
@@ -309,14 +455,27 @@ const styles = StyleSheet.create({
},
deviceActions: {
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
},
actionButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 8,
alignItems: "center",
justifyContent: "flex-start",
marginTop: 8,
paddingBottom: 6,
},
actionIconContainer: {
padding: 6,
marginRight: 12,
justifyContent: "center",
alignItems: "center",
minWidth: 36,
minHeight: 36,
},
actionIcon: {
width: 22,
height: 22,
},
actionButtonText: {
color: "#fff",
fontWeight: "600",
fontSize: 12,
},
wakeButton: {
backgroundColor: "#4CAF50",
@@ -330,10 +489,14 @@ const styles = StyleSheet.create({
shutdownButton: {
backgroundColor: "#f44336",
},
actionButtonText: {
color: "#fff",
fontWeight: "600",
fontSize: 12,
statusSymbol: {
width: 18,
height: 18,
borderRadius: 9,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 8,
elevation: 6,
},
emptyContainer: {
alignItems: "center",

View File

@@ -1,76 +1,69 @@
import React from 'react';
import AsyncStorage from "@react-native-async-storage/async-storage";
import React from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
} from 'react-native';
import { useAuth } from '../../src/context/AuthContext';
import AsyncStorage from '@react-native-async-storage/async-storage';
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useColorScheme } from "../../hooks/use-color-scheme";
import { useAuth } from "../../src/context/AuthContext";
import { useRouter } from "expo-router";
export default function SettingsScreen() {
const { user, logout } = useAuth();
const { user, serverAddress, logout } = useAuth();
const router = useRouter();
const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const sectionBg = isDark ? "#1c1c1e" : "#fff";
const sectionTitleBg = isDark ? "#111111" : "#f9f9f9";
const textColor = isDark ? "#fff" : "#333";
const subTextColor = isDark ? "#c6c6c8" : "#666";
const primary = isDark ? "#0A84FF" : "#007AFF";
const destructiveColor = "#f44336";
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
Alert.alert("Logout", "Are you sure you want to logout?", [
{ text: "Cancel", style: "cancel" },
{
text: 'Logout',
style: 'destructive',
text: "Logout",
style: "destructive",
onPress: async () => {
router.replace("/login");
await logout();
},
},
]
);
};
const handleClearData = () => {
Alert.alert(
'Clear Data',
'This will clear all stored data. Are you sure?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.clear();
Alert.alert('Success', 'All data cleared');
} catch (error) {
Alert.alert('Error', 'Failed to clear data');
}
},
},
]
);
]);
};
const SettingItem = ({
label,
value,
onPress
onPress,
}: {
label: string;
value?: string;
onPress?: () => void;
}) => (
<TouchableOpacity
style={styles.settingItem}
style={[
styles.settingItem,
{ borderBottomColor: isDark ? "rgba(255,255,255,0.03)" : "#f0f0f0" },
]}
onPress={onPress}
disabled={!onPress}
activeOpacity={onPress ? 0.7 : 1}
>
<Text style={styles.settingLabel}>{label}</Text>
<Text style={[styles.settingLabel, { color: textColor }]}>{label}</Text>
<View style={styles.settingValueContainer}>
<Text style={styles.settingValue} numberOfLines={1}>
{value || 'N/A'}
<Text
style={[styles.settingValue, { color: subTextColor }]}
numberOfLines={1}
>
{value}
</Text>
</View>
</TouchableOpacity>
@@ -86,62 +79,87 @@ export default function SettingsScreen() {
destructive?: boolean;
}) => (
<TouchableOpacity
style={[styles.actionButton, destructive && styles.actionButtonDestructive]}
style={[
styles.actionButton,
{ backgroundColor: destructive ? destructiveColor : primary },
]}
onPress={onPress}
>
<Text
style={[
styles.actionButtonText,
destructive && styles.actionButtonTextDestructive,
]}
>
{title}
</Text>
<Text style={styles.actionButtonText}>{title}</Text>
</TouchableOpacity>
);
return (
<ScrollView style={styles.container}>
<ScrollView style={[styles.container, { backgroundColor: bgColor }]}>
<View style={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>User Information</Text>
<SettingItem label="Username" value={user?.username || 'N/A'} />
<SettingItem label="Email" value={user?.email || 'N/A'} />
<SettingItem
label="User ID"
value={user?.id || 'N/A'}
/>
<View
style={[
styles.section,
{
backgroundColor: sectionBg,
shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
},
]}
>
<Text
style={[
styles.sectionTitle,
{ color: subTextColor, backgroundColor: sectionTitleBg },
]}
>
User Information
</Text>
<SettingItem label="Username" value={user?.username || "Admin"} />
<SettingItem label="Email" value={user?.email || "N/A"} />
<SettingItem label="Server URL" value={serverAddress || "N/A"} />
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Server</Text>
<SettingItem
label="Server URL"
value="https://wol.f6knight.duckdns.org"
/>
<SettingItem
label="API Base"
value="/api"
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>App Info</Text>
<View
style={[
styles.section,
{
backgroundColor: sectionBg,
shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
},
]}
>
<Text
style={[
styles.sectionTitle,
{ color: subTextColor, backgroundColor: sectionTitleBg },
]}
>
App Info
</Text>
<SettingItem label="Version" value="1.0.0" />
<SettingItem label="Build" value="Expo" />
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Actions</Text>
<View
style={[
styles.section,
{
backgroundColor: sectionBg,
shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
},
]}
>
<Text
style={[
styles.sectionTitle,
{ color: subTextColor, backgroundColor: sectionTitleBg },
]}
>
Actions
</Text>
<ActionButton title="Logout" onPress={handleLogout} destructive />
<ActionButton title="Clear All Data" onPress={handleClearData} destructive />
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
<Text style={[styles.footerText, { color: subTextColor }]}>
UpSnap Mobile App
</Text>
<Text style={styles.footerText}>
<Text style={[styles.footerText, { color: subTextColor }]}>
Connect to your UpSnap server
</Text>
</View>
@@ -153,17 +171,17 @@ export default function SettingsScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
},
content: {
padding: 20,
},
section: {
backgroundColor: '#fff',
backgroundColor: "#fff",
borderRadius: 12,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#000',
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
@@ -171,61 +189,63 @@ const styles = StyleSheet.create({
},
sectionTitle: {
fontSize: 14,
fontWeight: 'bold',
color: '#666',
fontWeight: "bold",
color: "#666",
paddingHorizontal: 15,
paddingTop: 15,
paddingBottom: 10,
backgroundColor: '#f9f9f9',
backgroundColor: "#f9f9f9",
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
settingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 15,
paddingVertical: 15,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: "#f0f0f0",
},
settingLabel: {
fontSize: 16,
color: '#333',
color: "#333",
flex: 1,
},
settingValueContainer: {
flex: 1,
alignItems: 'flex-end',
alignItems: "flex-end",
},
settingValue: {
fontSize: 14,
color: '#666',
color: "#666",
maxWidth: 200,
},
actionButton: {
backgroundColor: '#007AFF',
backgroundColor: "#007AFF",
margin: 15,
padding: 15,
borderRadius: 8,
alignItems: 'center',
alignItems: "center",
},
actionButtonDestructive: {
backgroundColor: '#f44336',
backgroundColor: "#f44336",
},
actionButtonText: {
color: '#fff',
color: "#fff",
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
actionButtonTextDestructive: {
color: '#fff',
color: "#fff",
},
footer: {
alignItems: 'center',
alignItems: "center",
paddingVertical: 30,
},
footerText: {
fontSize: 14,
color: '#999',
color: "#999",
marginBottom: 5,
},
});

View File

@@ -1,41 +1,77 @@
import { StatusBar } from "expo-status-bar";
import { AuthProvider, useAuth } from "../src/context/AuthContext";
import { useEffect } from "react";
import { Stack, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter, useSegments } from "expo-router";
import { TouchableOpacity, Text } from "react-native";
import { useColorScheme } from "../hooks/use-color-scheme";
import { AuthProvider } from "../src/context/AuthContext";
function RootStack() {
function DevicesHeader() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace('/(tabs)');
} else {
router.replace('/login');
}
}
}, [isAuthenticated, isLoading, router]);
const isDark = useColorScheme() === "dark";
const activityColor = isDark ? "#0A84FF" : "#007AFF";
return (
<Stack>
{isAuthenticated ? (
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
) : (
<Stack.Screen name="login" options={{ headerShown: false }} />
)}
<Stack.Screen name="add-device" options={{ headerShown: false }} />
<Stack.Screen name="scan-devices" options={{ headerShown: false }} />
<Stack.Screen name="devices/[id]" />
</Stack>
<TouchableOpacity
onPress={() => router.push("/scan-devices")}
style={{ paddingHorizontal: 8 }}
>
<Ionicons name="add-circle-outline" size={24} color={activityColor} />
</TouchableOpacity>
);
}
function TabsTitle(props: { title: string }) {
const segments = useSegments();
const last = segments[segments.length - 1];
const isDark = useColorScheme() === "dark";
const titleColor = isDark ? "#fff" : "#000";
const title = last === "settings" ? "Settings" : props.title;
return (
<Text style={{ color: titleColor, fontSize: 17, fontWeight: "600" }}>
{title}
</Text>
);
}
export default function RootLayout() {
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#0b0b0d" : "#fff";
const titleColor = isDark ? "#fff" : "#000";
const segments = useSegments();
const last = segments[segments.length - 1];
const isIndex = last === "(tabs)" || last === undefined;
return (
<AuthProvider>
<RootStack />
<StatusBar style="auto" />
<Stack>
{/* Root index that performs auth redirect (app/index.tsx) */}
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* Tabs parent - render tabs with dynamic header */}
<Stack.Screen
name="(tabs)"
options={
{
headerTitle: () => <TabsTitle title="Devices" />,
headerRight: isIndex ? () => <DevicesHeader /> : undefined,
headerRightContainerStyle: isIndex
? undefined
: { width: 0, paddingRight: 0 },
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
} as any
}
/>{" "}
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen
name="scan-devices"
options={{
headerShown: true,
headerTitle: () => <TabsTitle title="Scan Devices" />,
headerStyle: { backgroundColor: bgColor },
headerBackButtonDisplayMode: "minimal",
}}
/>
</Stack>
</AuthProvider>
);
}

View File

@@ -1,236 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import api from '../src/services/api';
export default function AddDeviceScreen() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
mac: '',
ip: '',
netmask: '255.255.255.0',
broadcast: '',
secureOnPassword: '',
port: '9',
});
const handleSave = async () => {
if (!formData.name || !formData.mac || !formData.ip) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
setIsLoading(true);
try {
await api.createDevice({
name: formData.name,
mac: formData.mac,
ip: formData.ip,
netmask: formData.netmask,
broadcast: formData.broadcast,
secureOnPassword: formData.secureOnPassword,
port: parseInt(formData.port) || 9,
groups: [],
status: 'offline',
});
Alert.alert('Success', 'Device added successfully');
router.back();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to add device');
} finally {
setIsLoading(false);
}
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
<Text style={styles.header}>Add New Device</Text>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="My PC"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
<Text style={styles.hint}>
Format: XX:XX:XX:XX:XX:XX
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
<Text style={styles.hint}>
Optional: Auto-calculated if left blank
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>SecureOn Password</Text>
<TextInput
style={styles.input}
value={formData.secureOnPassword}
onChangeText={(text) => setFormData({ ...formData, secureOnPassword: text })}
placeholder="Optional password"
secureTextEntry
/>
<Text style={styles.hint}>
Optional: For SecureOn enabled NICs
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
<Text style={styles.hint}>
Default: 9 (Standard Wake-on-LAN port)
</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={() => router.back()}
disabled={isLoading}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Adding...' : 'Add Device'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
padding: 20,
},
header: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
form: {
gap: 20,
},
inputGroup: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
hint: {
fontSize: 12,
color: '#666',
},
buttonGroup: {
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
button: {
flex: 1,
paddingVertical: 15,
borderRadius: 8,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#999',
},
saveButton: {
backgroundColor: '#4CAF50',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
});

View File

@@ -1,350 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import api from '../../src/services/api';
import { Device } from '../../src/types';
export default function DeviceDetailsScreen() {
const router = useRouter();
const { id: deviceId } = useLocalSearchParams<{ id: string }>();
const [device, setDevice] = useState<Device | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
mac: '',
ip: '',
netmask: '',
broadcast: '',
secureOnPassword: '',
port: '',
});
useEffect(() => {
loadDevice();
}, [deviceId]);
const loadDevice = async () => {
try {
setIsLoading(true);
const data = await api.getDevice(deviceId);
setDevice(data);
setFormData({
name: data.name,
mac: data.mac,
ip: data.ip,
netmask: data.netmask || '',
broadcast: data.broadcast || '',
secureOnPassword: data.secureOnPassword || '',
port: String(data.port),
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to load device');
router.back();
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!formData.name || !formData.mac || !formData.ip) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
setIsSaving(true);
try {
await api.updateDevice(deviceId, {
name: formData.name,
mac: formData.mac,
ip: formData.ip,
netmask: formData.netmask,
broadcast: formData.broadcast,
secureOnPassword: formData.secureOnPassword,
port: parseInt(formData.port) || 9,
});
setIsEditing(false);
await loadDevice();
Alert.alert('Success', 'Device updated successfully');
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to update device');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
Alert.alert(
'Delete Device',
'Are you sure you want to delete this device?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await api.deleteDevice(deviceId);
Alert.alert('Success', 'Device deleted successfully');
router.back();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to delete device');
}
},
},
]
);
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{isEditing ? (
<View style={styles.form}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="Device name"
/>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
<Text style={styles.label}>SecureOn Password</Text>
<TextInput
style={styles.input}
value={formData.secureOnPassword}
onChangeText={(text) => setFormData({ ...formData, secureOnPassword: text })}
placeholder="Optional password"
/>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={isSaving}
>
<Text style={styles.buttonText}>
{isSaving ? 'Saving...' : 'Save'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={() => {
setIsEditing(false);
setFormData({
name: device!.name,
mac: device!.mac,
ip: device!.ip,
netmask: device!.netmask || '',
broadcast: device!.broadcast || '',
secureOnPassword: device!.secureOnPassword || '',
port: String(device!.port),
});
}}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.details}>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Name</Text>
<Text style={styles.detailValue}>{device?.name}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>MAC Address</Text>
<Text style={styles.detailValue}>{device?.mac}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>IP Address</Text>
<Text style={styles.detailValue}>{device?.ip}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Netmask</Text>
<Text style={styles.detailValue}>{device?.netmask || 'N/A'}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Broadcast</Text>
<Text style={styles.detailValue}>{device?.broadcast || 'N/A'}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Port</Text>
<Text style={styles.detailValue}>{device?.port}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Status</Text>
<Text style={styles.detailValue}>{device?.status}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Groups</Text>
<Text style={styles.detailValue}>
{device?.groups?.join(', ') || 'None'}
</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.editButton]}
onPress={() => setIsEditing(true)}
>
<Text style={styles.buttonText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.deleteButton]}
onPress={handleDelete}
>
<Text style={styles.buttonText}>Delete</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
},
form: {
gap: 15,
},
details: {
gap: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginBottom: 5,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
detailRow: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 15,
borderWidth: 1,
borderColor: '#e0e0e0',
},
detailLabel: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
detailValue: {
fontSize: 16,
color: '#333',
fontWeight: '500',
},
buttonGroup: {
flexDirection: 'row',
gap: 10,
marginTop: 20,
},
button: {
flex: 1,
paddingVertical: 15,
borderRadius: 8,
alignItems: 'center',
},
saveButton: {
backgroundColor: '#4CAF50',
},
cancelButton: {
backgroundColor: '#999',
},
editButton: {
backgroundColor: '#007AFF',
},
deleteButton: {
backgroundColor: '#f44336',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
});

21
app/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React, { useEffect } from "react";
import { useAuth } from "../src/context/AuthContext";
export default function IndexRedirect() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
return <StatusBar style="auto" />;
}

View File

@@ -1,38 +1,48 @@
import React, { useState } from 'react';
import { useRouter } from "expo-router";
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useAuth } from '../src/context/AuthContext';
import { useRouter } from 'expo-router';
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useColorScheme } from "../hooks/use-color-scheme";
import { useAuth } from "../src/context/AuthContext";
export default function LoginScreen() {
const router = useRouter();
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [isSuperuser, setIsSuperuser] = useState(false);
const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const textColor = isDark ? "#fff" : "#333";
const subText = isDark ? "#c6c6c8" : "#666";
const inputBg = isDark ? "rgba(255,255,255,0.04)" : "#fff";
const borderColor = isDark ? "rgba(255,255,255,0.06)" : "#ddd";
const primary = isDark ? "#0A84FF" : "#007AFF";
const { serverAddress: storedServerAddress, login } = useAuth();
const [serverAddress, setServerAddress] = useState(storedServerAddress || "");
const [identity, setIdentity] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const handleLogin = async () => {
if (!identity || !password) {
Alert.alert('Error', 'Please enter both identity and password');
if (!serverAddress || !identity || !password) {
Alert.alert("Error", "Please enter server address, identity, and password");
return;
}
setIsLoading(true);
try {
await login(identity, password, isSuperuser);
router.replace('/');
await login(serverAddress, identity, password);
router.replace("/");
} catch (error: any) {
Alert.alert('Login Failed', error.message || 'An error occurred');
Alert.alert("Login Failed", error.message || "An error occurred");
} finally {
setIsLoading(false);
}
@@ -40,52 +50,80 @@ export default function LoginScreen() {
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.container, { backgroundColor: bgColor }]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Text style={styles.title}>UpSnap</Text>
<Text style={styles.subtitle}>Wake on LAN Mobile</Text>
<Text style={[styles.title, { color: textColor }]}>Remote WoL</Text>
<Text style={[styles.subtitle, { color: subText }]}>
Mobile Frontend for UpSnap
</Text>
</View>
<View style={styles.form}>
<TextInput
style={styles.input}
style={[
styles.input,
{ backgroundColor: inputBg, borderColor, color: textColor },
]}
placeholder="Server Address"
placeholderTextColor={subText}
value={serverAddress}
onChangeText={setServerAddress}
autoCapitalize="none"
autoCorrect={false}
selectionColor={primary}
keyboardAppearance={isDark ? "dark" : "light"}
/>
<TextInput
style={[
styles.input,
{ backgroundColor: inputBg, borderColor, color: textColor },
]}
placeholder="Username or Email"
placeholderTextColor={subText}
value={identity}
onChangeText={setIdentity}
autoCapitalize="none"
autoCorrect={false}
selectionColor={primary}
keyboardAppearance={isDark ? "dark" : "light"}
/>
<TextInput
style={styles.input}
style={[
styles.input,
{ backgroundColor: inputBg, borderColor, color: textColor },
]}
placeholder="Password"
placeholderTextColor={subText}
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
selectionColor={primary}
keyboardAppearance={isDark ? "dark" : "light"}
/>
<TouchableOpacity
style={styles.checkboxContainer}
onPress={() => setIsSuperuser(!isSuperuser)}
activeOpacity={0.7}
>
<View style={[styles.checkbox, isSuperuser && styles.checkboxChecked]}>
{isSuperuser && <Text style={styles.checkmark}></Text>}
</View>
<Text style={styles.checkboxLabel}>Login as Admin</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
style={[
styles.button,
isLoading && styles.buttonDisabled,
{
backgroundColor: isLoading
? isDark
? "#333"
: "#ccc"
: primary,
},
]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Logging in...' : 'Login'}
<Text style={[styles.buttonText, { color: "#fff" }]}>
{isLoading ? "Logging in..." : "Login"}
</Text>
</TouchableOpacity>
</View>
@@ -97,78 +135,78 @@ export default function LoginScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
justifyContent: "center",
padding: 20,
},
header: {
alignItems: 'center',
alignItems: "center",
marginBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333',
fontWeight: "bold",
color: "#333",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
color: "#666",
},
form: {
width: '100%',
width: "100%",
},
input: {
backgroundColor: '#fff',
backgroundColor: "#fff",
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
borderRadius: 8,
padding: 15,
fontSize: 16,
marginBottom: 15,
},
checkboxContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
marginBottom: 20,
},
checkbox: {
width: 24,
height: 24,
borderWidth: 2,
borderColor: '#007AFF',
borderColor: "#007AFF",
borderRadius: 4,
marginRight: 10,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
checkboxChecked: {
backgroundColor: '#007AFF',
backgroundColor: "#007AFF",
},
checkmark: {
color: '#fff',
color: "#fff",
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
checkboxLabel: {
fontSize: 16,
color: '#333',
color: "#333",
},
button: {
backgroundColor: '#007AFF',
backgroundColor: "#007AFF",
borderRadius: 8,
padding: 15,
alignItems: 'center',
alignItems: "center",
},
buttonDisabled: {
backgroundColor: '#ccc',
backgroundColor: "#ccc",
},
buttonText: {
color: '#fff',
color: "#fff",
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
});

View File

@@ -1,36 +1,33 @@
import React, { useState } from 'react';
import { useRouter } from "expo-router";
import React, { useState } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
Modal,
TextInput,
ScrollView,
} from 'react-native';
import { useRouter } from 'expo-router';
import api from '../src/services/api';
import { NetworkScanResult } from '../src/types';
Alert,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useColorScheme } from "../hooks/use-color-scheme";
import api from "../src/services/api";
import { NetworkScanResult } from "../src/types";
import { SymbolView } from "expo-symbols";
import * as Burnt from "burnt";
export default function ScanDevicesScreen() {
const router = useRouter();
const colorScheme = useColorScheme() ?? "light";
const isDark = colorScheme === "dark";
const bgColor = isDark ? "#0b0b0d" : "#f5f5f5";
const cardBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(255, 255, 255, 0.8)";
const primary = isDark ? "#0A84FF" : "#007AFF";
const primaryPressed = isDark ? "#004BB5" : "#0051CC";
const textColor = isDark ? "#fff" : "#333";
const subText = isDark ? "#c6c6c8" : "#666";
const [scanning, setScanning] = useState(false);
const [devices, setDevices] = useState<NetworkScanResult[]>([]);
const [selectedDevice, setSelectedDevice] = useState<NetworkScanResult | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [formData, setFormData] = useState({
name: '',
mac: '',
ip: '',
netmask: '255.255.255.0',
broadcast: '',
secureOnPassword: '',
port: '9',
});
const handleScan = async () => {
setScanning(true);
@@ -38,115 +35,140 @@ export default function ScanDevicesScreen() {
const results = await api.scanNetwork();
setDevices(results);
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to scan network');
Alert.alert("Error", error.message || "Failed to scan network");
} finally {
setScanning(false);
}
};
const handleAddFromScan = (device: NetworkScanResult) => {
const deviceName = device.name || device.hostname || `Device ${device.ip || device.ip_address}`;
const deviceIP = device.ip || device.ip_address || '';
const deviceMAC = device.mac || device.mac_address || '';
const deviceVendor = device.mac_vendor || '';
setFormData({
name: deviceName,
mac: deviceMAC,
ip: deviceIP,
netmask: '255.255.255.0',
broadcast: '',
secureOnPassword: '',
port: '9',
});
setSelectedDevice(device);
setShowAddModal(true);
};
const handleSaveDevice = async () => {
if (!formData.name || !formData.mac || !formData.ip) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
const handleAddFromScan = async (device: NetworkScanResult) => {
const deviceName =
device.name ||
device.hostname ||
`Device ${device.ip || device.ip_address}`;
const deviceIP = device.ip || device.ip_address || "";
const deviceMAC = device.mac || device.mac_address || "";
try {
await api.createDevice({
name: formData.name,
mac: formData.mac,
ip: formData.ip,
netmask: formData.netmask,
broadcast: formData.broadcast,
secureOnPassword: formData.secureOnPassword,
port: parseInt(formData.port) || 9,
name: deviceName,
mac: deviceMAC,
ip: deviceIP,
netmask: "255.255.255.0",
broadcast: "",
secureOnPassword: "",
port: 9,
groups: [],
status: 'offline',
status: "offline",
});
Burnt.toast({
title: "Success",
preset: "done",
message: `Added ${deviceName} successfully`,
});
Alert.alert('Success', 'Device added successfully');
setShowAddModal(false);
router.back();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to add device');
Burnt.toast({
title: "Error",
preset: "error",
message: error.message || "Failed to add device",
});
}
};
const renderDevice = ({ item }: { item: NetworkScanResult }) => {
const displayName = item.name || item.hostname || 'Unknown Device';
const displayIP = item.ip || item.ip_address || '';
const displayMAC = item.mac || item.mac_address || '';
const displayVendor = item.mac_vendor || '';
const displayName = item.name || item.hostname || "Unknown Device";
const displayIP = item.ip || item.ip_address || "";
const displayMAC = item.mac || item.mac_address || "";
const displayVendor = item.mac_vendor || "";
return (
<TouchableOpacity
style={styles.deviceCard}
onPress={() => handleAddFromScan(item)}
<View
style={[
styles.deviceCard,
{
backgroundColor: cardBg,
shadowColor: isDark ? "rgba(255,255,255,0.02)" : "#000",
},
]}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName} numberOfLines={1}>{displayName}</Text>
<Text style={styles.deviceDetail}>{displayIP}</Text>
<Text style={styles.deviceDetail}>{displayMAC}</Text>
{displayVendor && displayVendor !== 'Unknown' && (
<Text style={styles.vendorText}>{displayVendor}</Text>
<Text
style={[styles.deviceName, { color: textColor }]}
numberOfLines={1}
>
{displayName}
</Text>
<Text style={[styles.deviceDetail, { color: subText }]}>
{displayIP}
</Text>
<Text style={[styles.deviceDetail, { color: subText }]}>
{displayMAC}
</Text>
{displayVendor && displayVendor !== "Unknown" && (
<Text style={[styles.vendorText, { color: subText }]}>
{displayVendor}
</Text>
)}
</View>
<View style={styles.addButton}>
<Text style={styles.addButtonText}>+</Text>
</View>
<TouchableOpacity onPress={() => handleAddFromScan(item)}>
<SymbolView
name="plus.circle.fill"
size={20}
type="hierarchical"
style={styles.addButton}
/>
</TouchableOpacity>
</View>
);
};
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: bgColor }]}>
<View style={styles.header}>
<Text style={styles.headerText}>Network Scan</Text>
<Text style={styles.headerSubtext}>
<Text style={[styles.headerText, { color: textColor }]}>
Add Devices
</Text>
<Text style={[styles.headerSubtext, { color: subText }]}>
Discover devices on your local network
</Text>
</View>
<TouchableOpacity
style={[styles.scanButton, scanning && styles.scanButtonDisabled]}
style={[
styles.scanButton,
{
backgroundColor: scanning ? primaryPressed : primary,
},
]}
onPress={handleScan}
disabled={scanning}
>
{scanning ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.scanButtonText}>Scan Network</Text>
<Text style={[styles.scanButtonText, { color: "#fff" }]}>
Scan Network
</Text>
)}
</TouchableOpacity>
<Text style={styles.infoText}>
Note: This requires the server to have nmap installed and may take several minutes.
<Text style={[styles.infoText, { color: subText }]}>
Note: This requires the server to have nmap installed and may take
several minutes.
</Text>
{devices.length > 0 && (
<View style={styles.resultsContainer}>
<Text style={styles.resultsHeader}>Discovered Devices ({devices.length})</Text>
<Text style={[styles.resultsHeader, { color: textColor }]}>
Discovered Devices ({devices.length})
</Text>
<FlatList
data={devices}
renderItem={renderDevice}
keyExtractor={(item, index) => `${item.ip || item.ip_address || index}-${index}`}
keyExtractor={(item, index) =>
`${item.ip || item.ip_address || index}-${index}`
}
contentContainerStyle={styles.list}
/>
</View>
@@ -154,114 +176,19 @@ export default function ScanDevicesScreen() {
{devices.length === 0 && !scanning && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Tap "Scan Network" to discover devices</Text>
</View>
)}
<Modal
visible={showAddModal}
animationType="slide"
transparent
onRequestClose={() => setShowAddModal(false)}
>
<View>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Add Device</Text>
<TouchableOpacity onPress={() => setShowAddModal(false)}>
<Text style={styles.closeButton}></Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody}>
<View style={styles.formGroup}>
<Text style={styles.label}>Device Name *</Text>
<TextInput
style={styles.input}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="Device name"
/>
{selectedDevice?.mac_vendor && (
<Text style={styles.hint}>
Vendor: {selectedDevice.mac_vendor}
<Text style={styles.emptyText}>
Tap "Scan Network" to discover devices
</Text>
</View>
)}
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>MAC Address *</Text>
<TextInput
style={styles.input}
value={formData.mac}
onChangeText={(text) => setFormData({ ...formData, mac: text })}
placeholder="00:11:22:33:44:55"
autoCapitalize="characters"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>IP Address *</Text>
<TextInput
style={styles.input}
value={formData.ip}
onChangeText={(text) => setFormData({ ...formData, ip: text })}
placeholder="192.168.1.100"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Netmask</Text>
<TextInput
style={styles.input}
value={formData.netmask}
onChangeText={(text) => setFormData({ ...formData, netmask: text })}
placeholder="255.255.255.0"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Broadcast Address</Text>
<TextInput
style={styles.input}
value={formData.broadcast}
onChangeText={(text) => setFormData({ ...formData, broadcast: text })}
placeholder="192.168.1.255"
keyboardType="numeric"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.input}
value={formData.port}
onChangeText={(text) => setFormData({ ...formData, port: text })}
placeholder="9"
keyboardType="numeric"
/>
</View>
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveDevice}
>
<Text style={styles.saveButtonText}>Add Device</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
padding: 20,
},
header: {
@@ -269,33 +196,30 @@ const styles = StyleSheet.create({
},
headerText: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
fontWeight: "bold",
color: "#333",
marginBottom: 5,
},
headerSubtext: {
fontSize: 14,
color: '#666',
color: "#666",
},
scanButton: {
backgroundColor: '#007AFF',
backgroundColor: "#007AFF",
borderRadius: 12,
padding: 16,
alignItems: 'center',
alignItems: "center",
marginBottom: 15,
},
scanButtonDisabled: {
backgroundColor: '#ccc',
},
scanButtonText: {
color: '#fff',
color: "#fff",
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
infoText: {
fontSize: 12,
color: '#999',
textAlign: 'center',
color: "#999",
textAlign: "center",
marginBottom: 20,
},
resultsContainer: {
@@ -303,21 +227,21 @@ const styles = StyleSheet.create({
},
resultsHeader: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
fontWeight: "bold",
color: "#333",
marginBottom: 15,
},
list: {
gap: 10,
},
deviceCard: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
backgroundColor: "rgba(255, 255, 255, 0.8)",
borderRadius: 16,
padding: 15,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
@@ -328,69 +252,68 @@ const styles = StyleSheet.create({
},
deviceName: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
fontWeight: "bold",
color: "#333",
marginBottom: 4,
},
deviceDetail: {
fontSize: 14,
color: '#666',
color: "#666",
marginBottom: 2,
},
vendorText: {
fontSize: 12,
color: '#999',
fontStyle: 'italic',
color: "#999",
fontStyle: "italic",
},
addButton: {
width: 44,
height: 44,
backgroundColor: 'rgba(76, 175, 80, 0.9)',
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
addButtonText: {
color: '#fff',
color: "#fff",
fontSize: 24,
fontWeight: 'bold',
fontWeight: "bold",
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
emptyText: {
fontSize: 16,
color: '#999',
textAlign: 'center',
color: "#999",
textAlign: "center",
},
modalBlur: {
flex: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
modalContent: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backgroundColor: "rgba(255, 255, 255, 0.95)",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '90%',
maxHeight: "90%",
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0, 0, 0, 0.1)',
borderBottomColor: "rgba(0, 0, 0, 0.1)",
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
fontWeight: "bold",
color: "#333",
},
closeButton: {
fontSize: 24,
color: '#999',
color: "#999",
padding: 8,
},
modalBody: {
@@ -401,33 +324,33 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
input: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
backgroundColor: "rgba(255, 255, 255, 0.8)",
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
borderColor: "rgba(0, 0, 0, 0.1)",
borderRadius: 12,
padding: 14,
fontSize: 16,
},
hint: {
fontSize: 12,
color: '#666',
color: "#666",
marginTop: 4,
},
saveButton: {
backgroundColor: '#4CAF50',
backgroundColor: "#4CAF50",
borderRadius: 12,
padding: 16,
alignItems: 'center',
alignItems: "center",
marginTop: 10,
},
saveButtonText: {
color: '#fff',
color: "#fff",
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 265 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,37 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "gopher.svg",
"name" : "gopher",
"position" : {
"scale" : 0.45,
"translation-in-points" : [
-6.017952794793246,
-0.4600234584925147
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

35
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "~54.0.30",
"expo-constants": "~18.0.12",
"expo-font": "~14.0.10",
@@ -4677,6 +4678,40 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/burnt": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/burnt/-/burnt-0.13.0.tgz",
"integrity": "sha512-LjlQa7CLkGWUdz08YUIaGCJ8BLXib31/ztKqowgwqd7UH283A/kmdCj+1PYAQwDQEMPNmvSUfFHrjXbcwZibFQ==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^1.0.0",
"sonner": "^2.0.1"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/burnt/node_modules/sf-symbols-typescript": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-1.0.0.tgz",
"integrity": "sha512-DkS7q3nN68dEMb4E18HFPDAvyrjDZK9YAQQF2QxeFu9gp2xRDXFMF8qLJ1EmQ/qeEGQmop4lmMM1WtYJTIcCMw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/burnt/node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",

View File

@@ -17,6 +17,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "~54.0.30",
"expo-constants": "~18.0.12",
"expo-font": "~14.0.10",

View File

@@ -5,10 +5,11 @@ import { AuthResponse, User } from '../types';
interface AuthContextType {
user: User | null;
serverAddress: string | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (identity: string, password: string, isSuperuser?: boolean) => Promise<void>;
login: (serverAddress: string, identity: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
@@ -16,6 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [serverAddress, setServerAddress] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -27,12 +29,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
try {
const storedToken = await AsyncStorage.getItem('auth_token');
const storedUser = await AsyncStorage.getItem('auth_user');
const storedServerAddress = await AsyncStorage.getItem('auth_server_address');
if (storedToken && storedUser) {
setToken(storedToken);
api.setToken(storedToken);
setUser(JSON.parse(storedUser));
}
if (storedServerAddress) {
setServerAddress(storedServerAddress);
api.setAddress(storedServerAddress + '/api');
}
} catch (error) {
console.error('Failed to load auth', error);
} finally {
@@ -40,15 +48,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
const login = async (identity: string, password: string, isSuperuser = false) => {
const login = async (serverAddress: string, identity: string, password: string) => {
try {
const response: AuthResponse = await api.authenticate(identity, password, isSuperuser);
const response: AuthResponse = await api.authenticate(serverAddress, identity, password);
await AsyncStorage.setItem('auth_token', response.token);
await AsyncStorage.setItem('auth_user', JSON.stringify(response.record));
await AsyncStorage.setItem('auth_server_address', serverAddress);
setToken(response.token);
setUser(response.record);
setServerAddress(serverAddress);
} catch (error) {
throw error;
}
@@ -60,8 +70,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await AsyncStorage.removeItem('auth_user');
api.clearToken();
api.clearAddress();
setToken(null);
setUser(null);
setServerAddress(null);
} catch (error) {
console.error('Failed to logout', error);
}
@@ -70,6 +82,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return (
<AuthContext.Provider
value={{
serverAddress,
user,
token,
isAuthenticated: !!token && !!user,

View File

@@ -1,9 +1,8 @@
import { Device, AuthResponse, NetworkScanResult } from '../types';
const API_BASE_URL = 'https://wol.f6knight.duckdns.org/api';
import { Device, AuthResponse, NetworkScanResult } from "../types";
class UpSnapAPI {
private token: string | null = null;
private address: string | null = null;
setToken(token: string) {
this.token = token;
@@ -17,32 +16,59 @@ class UpSnapAPI {
this.token = null;
}
setAddress(address: string) {
this.address = address;
}
getAddress(): string | null {
return this.address;
}
clearAddress() {
this.address = null;
}
private getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
"Content-Type": "application/json",
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
headers["Authorization"] = `Bearer ${this.token}`;
}
return headers;
}
async authenticate(identity: string, password: string, isSuperuser = false): Promise<AuthResponse> {
const endpoint = isSuperuser
? `${API_BASE_URL}/collections/_superusers/auth-with-password`
: `${API_BASE_URL}/collections/users/auth-with-password`;
async authenticate(
serverAddress: string,
identity: string,
password: string
): Promise<AuthResponse> {
this.address = serverAddress + "/api";
const response = await fetch(endpoint, {
method: 'POST',
const response = await fetch(
`${this.address}/collections/users/auth-with-password`,
{
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({ identity, password }),
});
}
);
if (!response.ok) {
const response = await fetch(
`${this.address}/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({ identity, password }),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Authentication failed');
throw new Error(error.message || "Authentication failed");
}
const data: AuthResponse = await response.json();
@@ -50,16 +76,21 @@ class UpSnapAPI {
return data;
}
async getDevices(page = 1, perPage = 30): Promise<Device[]> {
const data: AuthResponse = await response.json();
this.token = data.token;
return data;
}
async getDevices(page = 1, perPage = 100): Promise<Device[]> {
const response = await fetch(
`${API_BASE_URL}/collections/devices/records?page=${page}&perPage=${perPage}`,
`${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`,
{
headers: this.getHeaders(),
}
);
if (!response.ok) {
throw new Error('Failed to fetch devices');
throw new Error("Failed to fetch devices");
}
const data = await response.json();
@@ -68,14 +99,14 @@ class UpSnapAPI {
async getDevice(id: string): Promise<Device> {
const response = await fetch(
`${API_BASE_URL}/collections/devices/records/${id}`,
`${this.address}/collections/devices/records/${id}`,
{
headers: this.getHeaders(),
}
);
if (!response.ok) {
throw new Error('Failed to fetch device');
throw new Error("Failed to fetch device");
}
return response.json();
@@ -83,9 +114,9 @@ class UpSnapAPI {
async createDevice(device: Partial<Device>): Promise<Device> {
const response = await fetch(
`${API_BASE_URL}/collections/devices/records`,
`${this.address}/collections/devices/records`,
{
method: 'POST',
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify(device),
}
@@ -93,7 +124,7 @@ class UpSnapAPI {
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create device');
throw new Error(error.message || "Failed to create device");
}
return response.json();
@@ -101,9 +132,9 @@ class UpSnapAPI {
async updateDevice(id: string, device: Partial<Device>): Promise<Device> {
const response = await fetch(
`${API_BASE_URL}/collections/devices/records/${id}`,
`${this.address}/collections/devices/records/${id}`,
{
method: 'PATCH',
method: "PATCH",
headers: this.getHeaders(),
body: JSON.stringify(device),
}
@@ -111,7 +142,7 @@ class UpSnapAPI {
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to update device');
throw new Error(error.message || "Failed to update device");
}
return response.json();
@@ -119,122 +150,102 @@ class UpSnapAPI {
async deleteDevice(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/collections/devices/records/${id}`,
`${this.address}/collections/devices/records/${id}`,
{
method: 'DELETE',
method: "DELETE",
headers: this.getHeaders(),
}
);
if (!response.ok) {
throw new Error('Failed to delete device');
throw new Error("Failed to delete device");
}
}
async wakeDevice(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/upsnap/wake/${id}`,
{
const response = await fetch(`${this.address}/upsnap/wake/${id}`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to wake device');
throw new Error("Failed to wake device");
}
}
async wakeGroup(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/upsnap/wakegroup/${id}`,
{
const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to wake group');
throw new Error("Failed to wake group");
}
}
async sleepDevice(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/upsnap/sleep/${id}`,
{
const response = await fetch(`${this.address}/upsnap/sleep/${id}`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to sleep device');
throw new Error("Failed to sleep device");
}
}
async rebootDevice(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/upsnap/reboot/${id}`,
{
const response = await fetch(`${this.address}/upsnap/reboot/${id}`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to reboot device');
throw new Error("Failed to reboot device");
}
}
async shutdownDevice(id: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/upsnap/shutdown/${id}`,
{
const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to shutdown device');
throw new Error("Failed to shutdown device");
}
}
async scanNetwork(): Promise<NetworkScanResult[]> {
const response = await fetch(
`${API_BASE_URL}/upsnap/scan`,
{
const response = await fetch(`${this.address}/upsnap/scan`, {
headers: this.getHeaders(),
}
);
});
if (!response.ok) {
throw new Error('Failed to scan network');
throw new Error("Failed to scan network");
}
const data = await response.json();
console.log('Raw scan data:', data);
if (data.devices && Array.isArray(data.devices)) {
return data.devices.map((item: any) => ({
name: item.name || item.hostname || 'Unknown',
ip: item.ip || item.ip_address || '',
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}
if (Array.isArray(data)) {
return data.map((item: any) => ({
name: item.name || item.hostname || 'Unknown',
ip: item.ip || item.ip_address || '',
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}
if (data.items && Array.isArray(data.items)) {
return data.items.map((item: any) => ({
name: item.name || item.hostname || 'Unknown',
ip: item.ip || item.ip_address || '',
mac: item.mac || item.mac_address || '',
mac_vendor: item.mac_vendor || 'Unknown',
name: item.name || item.hostname || "Unknown",
ip: item.ip || item.ip_address || "",
mac: item.mac || item.mac_address || "",
mac_vendor: item.mac_vendor || "Unknown",
}));
}