Compare commits

...
1 Commits
3 changed files with 528 additions and 125 deletions

No files matched your search

+236 -28
View File
@@ -1,6 +1,8 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import api from '../services/api'; import api, { isAuthCollection, isRevokedAuthError } from '../services/api';
import { getJwtExpiryMs } from '../services/jwtExpiry';
import { AuthResponse, User } from '../types'; import { AuthResponse, User } from '../types';
interface AuthContextType { interface AuthContextType {
@@ -16,6 +18,22 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AUTH_COLLECTION_KEY = 'auth_collection';
const REFRESH_BEFORE_EXPIRY_MS = 60_000;
const MAX_REFRESH_DELAY_MS = 2_147_483_647;
const REFRESH_RETRY_MS = 30_000;
const REFRESH_BACKOFF_MS = 15_000;
function msUntilRefresh(token: string): number | null {
const expiresAt = getJwtExpiryMs(token);
if (expiresAt == null) return null;
return expiresAt - Date.now() - REFRESH_BEFORE_EXPIRY_MS;
}
function collectionFromUser(user: User | null): 'users' | '_superusers' | null {
return isAuthCollection(user?.collectionName) ? user.collectionName : null;
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [serverAddress, setServerAddress] = useState<string | null>(null); const [serverAddress, setServerAddress] = useState<string | null>(null);
@@ -23,44 +41,192 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [canCreate, setCanCreate] = useState<boolean | null>(null); const [canCreate, setCanCreate] = useState<boolean | null>(null);
const clearSession = useCallback(async () => { const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']); const refreshingRef = useRef(false);
const sessionWriteRef = useRef<Promise<void>>(Promise.resolve());
const refreshSessionRef = useRef<() => Promise<void>>(async () => {});
const armRefreshTimerRef = useRef<(token: string, overdueDelay?: number) => void>(() => {});
const enqueueSessionWrite = useCallback((task: () => Promise<void>) => {
const run = sessionWriteRef.current.then(task, task);
sessionWriteRef.current = run.then(
() => undefined,
() => undefined,
);
return run;
}, []);
const clearRefreshTimer = useCallback(() => {
if (refreshTimerRef.current != null) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
}, []);
const clearSession = useCallback(async () => {
clearRefreshTimer();
api.clearToken(); api.clearToken();
api.clearCanCreate(); api.clearCanCreate();
api.clearAuthCollection();
setToken(null); setToken(null);
setUser(null); setUser(null);
setCanCreate(null); setCanCreate(null);
await enqueueSessionWrite(async () => {
await AsyncStorage.multiRemove([
'auth_token',
'auth_user',
'auth_can_create',
AUTH_COLLECTION_KEY,
]);
});
}, [clearRefreshTimer, enqueueSessionWrite]);
const armRefreshTimer = useCallback((nextToken: string, overdueDelay = 0) => {
clearRefreshTimer();
const untilRefresh = msUntilRefresh(nextToken);
if (untilRefresh == null) return;
const delay = untilRefresh <= 0 ? overdueDelay : Math.min(untilRefresh, MAX_REFRESH_DELAY_MS);
const rescheduleOnly = untilRefresh > MAX_REFRESH_DELAY_MS;
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
const latest = api.getToken();
if (!latest) return;
if (rescheduleOnly) {
armRefreshTimerRef.current(latest);
return;
}
void refreshSessionRef.current();
}, delay);
}, [clearRefreshTimer]);
const refreshSession = useCallback(async () => {
if (refreshingRef.current || !api.getToken()) return;
refreshingRef.current = true;
try {
await api.refreshAuth();
} catch {
// The failure listener either keeps the session and retries, or signs out.
} finally {
refreshingRef.current = false;
}
}, []);
const handleRefreshFailure = useCallback(async (error: unknown) => {
if (isRevokedAuthError(error)) {
await clearSession();
return;
}
if (!api.getToken()) return;
clearRefreshTimer();
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
void refreshSessionRef.current();
}, REFRESH_RETRY_MS);
}, [clearRefreshTimer, clearSession]);
const persistRefreshedAuth = useCallback(async (auth: AuthResponse) => {
await enqueueSessionWrite(async () => {
if (api.getToken() !== auth.token) return;
const collection = api.getAuthCollection();
const pairs: [string, string][] = [
['auth_token', auth.token],
['auth_user', JSON.stringify(auth.record)],
];
if (collection) {
pairs.push([AUTH_COLLECTION_KEY, collection]);
}
if (collection === '_superusers') {
pairs.push(['auth_can_create', 'true']);
}
await AsyncStorage.multiSet(pairs);
if (api.getToken() !== auth.token) return;
setToken(auth.token);
setUser(auth.record);
if (collection === '_superusers') {
setCanCreate(true);
}
armRefreshTimerRef.current(auth.token, REFRESH_BACKOFF_MS);
});
}, [enqueueSessionWrite]);
const watchToken = useCallback((nextToken: string) => {
const untilRefresh = msUntilRefresh(nextToken);
if (untilRefresh != null && untilRefresh <= 0) {
void refreshSessionRef.current();
return;
}
armRefreshTimerRef.current(nextToken);
}, []); }, []);
useEffect(() => {
refreshSessionRef.current = refreshSession;
armRefreshTimerRef.current = armRefreshTimer;
}, [armRefreshTimer, refreshSession]);
useEffect(() => { useEffect(() => {
api.setUnauthorizedHandler(clearSession); api.setUnauthorizedHandler(clearSession);
api.setAuthUpdateListener(persistRefreshedAuth);
api.setAuthFailureListener(handleRefreshFailure);
return () => { return () => {
api.setUnauthorizedHandler(null); api.setUnauthorizedHandler(null);
api.setAuthUpdateListener(null);
api.setAuthFailureListener(null);
clearRefreshTimer();
}; };
}, [clearSession]); }, [clearRefreshTimer, clearSession, handleRefreshFailure, persistRefreshedAuth]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void AsyncStorage.multiGet([ void (async () => {
try {
const [
[, storedToken],
[, storedUser],
[, storedServerAddress],
[, storedCanCreate],
[, storedCollection],
] = await AsyncStorage.multiGet([
'auth_token', 'auth_token',
'auth_user', 'auth_user',
'auth_server_address', 'auth_server_address',
'auth_can_create', 'auth_can_create',
]).then( AUTH_COLLECTION_KEY,
([[, storedToken], [, storedUser], [, storedServerAddress], [, storedCanCreate]]) => { ]);
if (cancelled) return; if (cancelled) return;
if (storedCanCreate) { let restoredUser: User | null = null;
setCanCreate(storedCanCreate === 'true'); if (storedToken && storedUser) {
restoredUser = JSON.parse(storedUser) as User;
api.setToken(storedToken);
setToken(storedToken);
setUser(restoredUser);
} }
if (storedToken && storedUser) { const collection = isAuthCollection(storedCollection)
setToken(storedToken); ? storedCollection
api.setToken(storedToken); : collectionFromUser(restoredUser);
setUser(JSON.parse(storedUser)); if (collection) {
api.setAuthCollection(collection);
}
if (collection === '_superusers') {
api.setCanCreate(true);
setCanCreate(true);
} else if (storedCanCreate) {
const restoredCanCreate = storedCanCreate === 'true';
api.setCanCreate(restoredCanCreate);
setCanCreate(restoredCanCreate);
} }
if (storedServerAddress) { if (storedServerAddress) {
@@ -68,36 +234,78 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
api.setAddress(storedServerAddress + '/api'); api.setAddress(storedServerAddress + '/api');
} }
const untilRefresh = storedToken ? msUntilRefresh(storedToken) : null;
const refreshIsDue = !!storedToken && !!restoredUser && untilRefresh != null && untilRefresh <= 0;
if (refreshIsDue) {
try {
// Hold the loading gate so an expired token does not flash the device list.
await api.refreshAuth();
} catch {
// Revoked tokens sign out; transient failures keep the session and retry.
}
} else if (storedToken && restoredUser) {
watchToken(storedToken);
}
if (!cancelled) {
setIsLoading(false); setIsLoading(false);
}, }
error => { } catch (error) {
if (cancelled) return; if (cancelled) return;
console.error('Failed to load auth', error); console.error('Failed to load auth', error);
setIsLoading(false); setIsLoading(false);
} }
); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [watchToken]);
useEffect(() => {
const subscription = AppState.addEventListener('change', nextState => {
if (nextState !== 'active') return;
const currentToken = api.getToken();
if (!currentToken) return;
const untilRefresh = msUntilRefresh(currentToken);
if (untilRefresh == null) return;
if (untilRefresh <= 0) {
void refreshSessionRef.current();
return;
}
armRefreshTimerRef.current(currentToken);
});
return () => {
subscription.remove();
};
}, []); }, []);
const login = async (serverAddress: string, identity: string, password: string) => { const login = async (nextServerAddress: string, identity: string, password: string) => {
try { const response: AuthResponse = await api.authenticate(nextServerAddress, identity, password);
const response: AuthResponse = await api.authenticate(serverAddress, identity, password); const collection = api.getAuthCollection();
await AsyncStorage.setItem('auth_token', response.token); await enqueueSessionWrite(async () => {
await AsyncStorage.setItem('auth_user', JSON.stringify(response.record)); const pairs: [string, string][] = [
await AsyncStorage.setItem('auth_server_address', serverAddress); ['auth_token', response.token],
await AsyncStorage.setItem('auth_can_create', api.getCanCreate() ? 'true' : 'false'); ['auth_user', JSON.stringify(response.record)],
['auth_server_address', nextServerAddress],
['auth_can_create', api.getCanCreate() ? 'true' : 'false'],
];
if (collection) {
pairs.push([AUTH_COLLECTION_KEY, collection]);
}
await AsyncStorage.multiSet(pairs);
});
setToken(response.token); setToken(response.token);
setUser(response.record); setUser(response.record);
setServerAddress(serverAddress); setServerAddress(nextServerAddress);
setCanCreate(api.getCanCreate() || false); setCanCreate(api.getCanCreate() || false);
} catch (error) { watchToken(response.token);
throw error;
}
}; };
const logout = async () => { const logout = async () => {
+251 -97
View File
@@ -5,12 +5,47 @@ import {
PermissionResponse, PermissionResponse,
} from '../types'; } from '../types';
export type AuthCollection = 'users' | '_superusers';
export type ApiError = Error & {
status?: number;
isAuthError?: boolean;
};
export type AuthUpdateListener = (auth: AuthResponse) => void | Promise<void>;
export type AuthFailureListener = (error: unknown) => void | Promise<void>;
const AUTH_ERROR_MESSAGE = /authorization token|invalid token|unauthorized|not authenticated/i;
export function isAuthCollection(value: unknown): value is AuthCollection {
return value === 'users' || value === '_superusers';
}
export function isRevokedAuthError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const status = (error as { status?: unknown }).status;
return status === 401 || status === 403 || status === 404;
}
function isSessionChanged(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
(error as { sessionChanged?: boolean }).sessionChanged === true
);
}
class UpSnapAPI { class UpSnapAPI {
private token: string | null = null; private token: string | null = null;
private address: string | null = null; private address: string | null = null;
private canCreate: boolean | null = null; private canCreate: boolean | null = null;
private authCollection: AuthCollection | null = null;
private authEpoch = 0;
private unauthorizedHandler: (() => Promise<void>) | null = null; private unauthorizedHandler: (() => Promise<void>) | null = null;
private authUpdateListener: AuthUpdateListener | null = null;
private authFailureListener: AuthFailureListener | null = null;
private isHandlingUnauthorized = false; private isHandlingUnauthorized = false;
private refreshInFlight: Promise<AuthResponse> | null = null;
setToken(token: string) { setToken(token: string) {
this.token = token; this.token = token;
@@ -22,6 +57,7 @@ class UpSnapAPI {
clearToken() { clearToken() {
this.token = null; this.token = null;
this.authEpoch += 1;
} }
setAddress(address: string) { setAddress(address: string) {
@@ -48,10 +84,30 @@ class UpSnapAPI {
this.canCreate = null; this.canCreate = null;
} }
setAuthCollection(collection: AuthCollection | null) {
this.authCollection = collection;
}
getAuthCollection(): AuthCollection | null {
return this.authCollection;
}
clearAuthCollection() {
this.authCollection = null;
}
setUnauthorizedHandler(handler: (() => Promise<void>) | null) { setUnauthorizedHandler(handler: (() => Promise<void>) | null) {
this.unauthorizedHandler = handler; this.unauthorizedHandler = handler;
} }
setAuthUpdateListener(listener: AuthUpdateListener | null) {
this.authUpdateListener = listener;
}
setAuthFailureListener(listener: AuthFailureListener | null) {
this.authFailureListener = listener;
}
private async handleUnauthorized() { private async handleUnauthorized() {
if (!this.unauthorizedHandler || this.isHandlingUnauthorized) { if (!this.unauthorizedHandler || this.isHandlingUnauthorized) {
return; return;
@@ -66,7 +122,7 @@ class UpSnapAPI {
} }
} }
private async throwApiError(response: Response, fallbackMessage: string): Promise<never> { private async errorFromResponse(response: Response, fallbackMessage: string): Promise<ApiError> {
let message = fallbackMessage; let message = fallbackMessage;
try { try {
@@ -76,22 +132,25 @@ class UpSnapAPI {
// Ignore JSON parsing errors and use the fallback message. // Ignore JSON parsing errors and use the fallback message.
} }
const isAuthError = const apiError = new Error(message) as ApiError;
apiError.status = response.status;
apiError.isAuthError =
response.status === 401 || response.status === 401 ||
response.status === 403 || response.status === 403 ||
/authorization token|invalid token|unauthorized|not authenticated/i.test(message); AUTH_ERROR_MESSAGE.test(message);
return apiError;
if (isAuthError) {
await this.handleUnauthorized();
} }
const error = new Error(message) as Error & { private async throwApiError(
status?: number; response: Response,
isAuthError?: boolean; fallbackMessage: string,
}; skipUnauthorizedHandler = false,
): Promise<never> {
const error = await this.errorFromResponse(response, fallbackMessage);
error.status = response.status; if (error.isAuthError && !skipUnauthorizedHandler) {
error.isAuthError = isAuthError; await this.handleUnauthorized();
}
throw error; throw error;
} }
@@ -108,11 +167,166 @@ class UpSnapAPI {
return headers; return headers;
} }
private sessionChanged(): ApiError {
const error = new Error('Auth session changed') as ApiError & { sessionChanged: true };
error.sessionChanged = true;
error.isAuthError = true;
return error;
}
/**
* One refresh is shared by the expiry timer and every request that receives a 401.
* PocketBase returns a new auth token and record for the same collection.
*/
async refreshAuth(): Promise<AuthResponse> {
if (!this.refreshInFlight) {
this.refreshInFlight = this.performRefresh().finally(() => {
this.refreshInFlight = null;
});
}
return this.refreshInFlight;
}
private async performRefresh(): Promise<AuthResponse> {
const epoch = this.authEpoch;
const tokenAtStart = this.token;
try {
if (!tokenAtStart || !this.address) {
throw Object.assign(new Error('Not authenticated'), {
status: 401,
isAuthError: true,
}) as ApiError;
}
const collections: AuthCollection[] = this.authCollection
? [this.authCollection]
: ['users', '_superusers'];
let lastError: ApiError | null = null;
for (let index = 0; index < collections.length; index += 1) {
const collection = collections[index];
const response = await fetch(
`${this.address}/collections/${collection}/auth-refresh`,
{
method: 'POST',
headers: this.getHeaders(),
},
);
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
throw this.sessionChanged();
}
if (response.ok) {
const data = (await response.json()) as AuthResponse;
if (this.authEpoch !== epoch || this.token !== tokenAtStart) {
throw this.sessionChanged();
}
if (!data?.token) {
throw Object.assign(new Error('Auth refresh failed'), {
status: 401,
isAuthError: true,
}) as ApiError;
}
this.authCollection = isAuthCollection(data.record?.collectionName)
? data.record.collectionName
: collection;
this.token = data.token;
if (this.authCollection === '_superusers') {
this.canCreate = true;
}
try {
await this.authUpdateListener?.(data);
} catch (error) {
console.error('Failed to persist refreshed auth', error);
}
return data;
}
lastError = await this.errorFromResponse(response, 'Auth refresh failed');
const tryNextCollection =
!this.authCollection && index < collections.length - 1 && response.status === 403;
if (!tryNextCollection) {
throw lastError;
}
}
throw (
lastError ??
(Object.assign(new Error('Auth refresh failed'), {
status: 401,
isAuthError: true,
}) as ApiError)
);
} catch (error) {
if (!isSessionChanged(error)) {
try {
await this.authFailureListener?.(error);
} catch (listenerError) {
console.error('Failed to handle auth refresh failure', listenerError);
}
}
throw error;
}
}
/**
* On 401, refresh once and retry the original request.
* A second 401, or a refresh the server rejects, ends the session.
*/
private async request(url: string, fallbackMessage: string, init?: RequestInit): Promise<Response> {
const send = () =>
fetch(url, {
...init,
headers: this.getHeaders(),
});
const response = await send();
if (response.ok) {
return response;
}
if (response.status === 401 && this.token) {
try {
await this.refreshAuth();
} catch (error) {
if (isSessionChanged(error)) {
throw error;
}
if (!isRevokedAuthError(error)) {
throw error;
}
await this.handleUnauthorized();
return this.throwApiError(response, fallbackMessage, true);
}
const retried = await send();
if (!retried.ok) {
if (retried.status === 401) {
await this.handleUnauthorized();
return this.throwApiError(retried, fallbackMessage, true);
}
return this.throwApiError(retried, fallbackMessage);
}
return retried;
}
return this.throwApiError(response, fallbackMessage);
}
async authenticate( async authenticate(
serverAddress: string, serverAddress: string,
identity: string, identity: string,
password: string password: string
): Promise<AuthResponse> { ): Promise<AuthResponse> {
this.authEpoch += 1;
this.token = null;
this.authCollection = null;
this.address = serverAddress + '/api'; this.address = serverAddress + '/api';
const response = await fetch( const response = await fetch(
@@ -125,7 +339,7 @@ class UpSnapAPI {
); );
if (!response.ok) { if (!response.ok) {
const response = await fetch( const superuserResponse = await fetch(
`${this.address}/collections/_superusers/auth-with-password`, `${this.address}/collections/_superusers/auth-with-password`,
{ {
method: 'POST', method: 'POST',
@@ -134,18 +348,20 @@ class UpSnapAPI {
} }
); );
if (!response.ok) { if (!superuserResponse.ok) {
await this.throwApiError(response, 'Authentication failed'); await this.throwApiError(superuserResponse, 'Authentication failed');
} }
const data: AuthResponse = await response.json(); const data: AuthResponse = await superuserResponse.json();
this.token = data.token; this.token = data.token;
this.authCollection = '_superusers';
this.canCreate = true; this.canCreate = true;
return data; return data;
} }
const data: AuthResponse = await response.json(); const data: AuthResponse = await response.json();
this.token = data.token; this.token = data.token;
this.authCollection = 'users';
const userID = data.record.id; const userID = data.record.id;
const userPermissionResponse = await fetch( const userPermissionResponse = await fetch(
@@ -165,142 +381,80 @@ class UpSnapAPI {
} }
async getDevices(page = 1, perPage = 100): Promise<Device[]> { async getDevices(page = 1, perPage = 100): Promise<Device[]> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`, `${this.address}/collections/devices/records?page=${page}&perPage=${perPage}`,
{ 'Failed to fetch devices',
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to fetch devices');
}
const data = await response.json(); const data = await response.json();
return data.items; return data.items;
} }
async getDevice(id: string): Promise<Device> { async getDevice(id: string): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
{ 'Failed to fetch device',
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to fetch device');
}
return response.json(); return response.json();
} }
async createDevice(device: Partial<Device>): Promise<Device> { async createDevice(device: Partial<Device>): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records`, `${this.address}/collections/devices/records`,
'Failed to create device',
{ {
method: 'POST', method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(device), body: JSON.stringify(device),
} },
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to create device');
}
return response.json(); return response.json();
} }
async updateDevice(id: string, device: Partial<Device>): Promise<Device> { async updateDevice(id: string, device: Partial<Device>): Promise<Device> {
const response = await fetch( const response = await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
'Failed to update device',
{ {
method: 'PATCH', method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(device), body: JSON.stringify(device),
} },
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to update device');
}
return response.json(); return response.json();
} }
async deleteDevice(id: string): Promise<void> { async deleteDevice(id: string): Promise<void> {
const response = await fetch( await this.request(
`${this.address}/collections/devices/records/${id}`, `${this.address}/collections/devices/records/${id}`,
{ 'Failed to delete device',
method: 'DELETE', { method: 'DELETE' },
headers: this.getHeaders(),
}
); );
if (!response.ok) {
await this.throwApiError(response, 'Failed to delete device');
}
} }
async wakeDevice(id: string): Promise<void> { async wakeDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/wake/${id}`, { await this.request(`${this.address}/upsnap/wake/${id}`, 'Failed to wake device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to wake device');
}
} }
async wakeGroup(id: string): Promise<void> { async wakeGroup(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/wakegroup/${id}`, { await this.request(`${this.address}/upsnap/wakegroup/${id}`, 'Failed to wake group');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to wake group');
}
} }
async sleepDevice(id: string): Promise<void> { async sleepDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/sleep/${id}`, { await this.request(`${this.address}/upsnap/sleep/${id}`, 'Failed to sleep device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to sleep device');
}
} }
async rebootDevice(id: string): Promise<void> { async rebootDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/reboot/${id}`, { await this.request(`${this.address}/upsnap/reboot/${id}`, 'Failed to reboot device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to reboot device');
}
} }
async shutdownDevice(id: string): Promise<void> { async shutdownDevice(id: string): Promise<void> {
const response = await fetch(`${this.address}/upsnap/shutdown/${id}`, { await this.request(`${this.address}/upsnap/shutdown/${id}`, 'Failed to shutdown device');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to shutdown device');
}
} }
async scanNetwork(): Promise<NetworkScanResult[]> { async scanNetwork(): Promise<NetworkScanResult[]> {
const response = await fetch(`${this.address}/upsnap/scan`, { const response = await this.request(`${this.address}/upsnap/scan`, 'Failed to scan network');
headers: this.getHeaders(),
});
if (!response.ok) {
await this.throwApiError(response, 'Failed to scan network');
}
const data = await response.json(); const data = await response.json();
if (data.devices && Array.isArray(data.devices)) { if (data.devices && Array.isArray(data.devices)) {
+41
View File
@@ -0,0 +1,41 @@
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeBase64Url(segment: string): string {
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const bytes: number[] = [];
for (let index = 0; index < padded.length; index += 4) {
const a = BASE64_ALPHABET.indexOf(padded[index]);
const b = BASE64_ALPHABET.indexOf(padded[index + 1]);
const c = padded[index + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 2]);
const d = padded[index + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(padded[index + 3]);
if (a < 0 || b < 0 || c < 0 || d < 0) {
throw new Error('Invalid base64url');
}
const chunk = (a << 18) | (b << 12) | (c << 6) | d;
bytes.push((chunk >> 16) & 255);
if (padded[index + 2] !== '=') bytes.push((chunk >> 8) & 255);
if (padded[index + 3] !== '=') bytes.push(chunk & 255);
}
return String.fromCharCode(...bytes);
}
/**
* Reads the JWT `exp` claim in milliseconds.
* Scheduling only; the signature is not verified.
*/
export function getJwtExpiryMs(token: string): number | null {
const segment = token.split('.')[1];
if (!segment) return null;
try {
const payload = JSON.parse(decodeBase64Url(segment)) as { exp?: unknown };
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null;
return payload.exp * 1000;
} catch {
return null;
}
}