mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
feat(services): server storage connection state and access token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b88a1c81e5
commit
872d8edc68
5 files changed
+164
No files matched your search
@@ -33,3 +33,5 @@ export * from './lib/source-health-evidence.service';
|
||||
export * from './lib/source-activity.service';
|
||||
export * from './lib/translation-tick';
|
||||
export * from './lib/user-data-storage';
|
||||
export * from './lib/server-storage-status.service';
|
||||
export * from './lib/server-access-token';
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
SERVER_ACCESS_TOKEN_STORAGE_KEY,
|
||||
serverAuthorizationHeaders,
|
||||
writeServerAccessToken,
|
||||
} from './server-access-token';
|
||||
|
||||
describe('server access token', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('has no header without a token', () => {
|
||||
expect(readServerAccessToken()).toBeNull();
|
||||
expect(serverAuthorizationHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('stores a trimmed token and builds the bearer header', () => {
|
||||
writeServerAccessToken(' s3cret ');
|
||||
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
|
||||
's3cret'
|
||||
);
|
||||
expect(serverAuthorizationHeaders()).toEqual({
|
||||
Authorization: 'Bearer s3cret',
|
||||
});
|
||||
|
||||
clearServerAccessToken();
|
||||
expect(readServerAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token';
|
||||
|
||||
export function readServerAccessToken(): string | null {
|
||||
try {
|
||||
const token = localStorage
|
||||
.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
?.trim();
|
||||
return token ? token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerAccessToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, token.trim());
|
||||
} catch {
|
||||
// Storage unavailable (private mode): the token cannot persist.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearServerAccessToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(SERVER_ACCESS_TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
// Nothing stored.
|
||||
}
|
||||
}
|
||||
|
||||
/** `Authorization` header for calls to the self-hosted backend. */
|
||||
export function serverAuthorizationHeaders(): Record<string, string> {
|
||||
const token = readServerAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import { ServerStorageStatusService } from './server-storage-status.service';
|
||||
|
||||
const instance: ServerInstanceInfo = {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
};
|
||||
|
||||
describe('ServerStorageStatusService', () => {
|
||||
let status: ServerStorageStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
status = TestBed.inject(ServerStorageStatusService);
|
||||
});
|
||||
|
||||
it('stays inactive and ignores reports until activated', () => {
|
||||
status.reportOffline();
|
||||
status.reportUnauthorized();
|
||||
|
||||
expect(status.state()).toBe('inactive');
|
||||
expect(status.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks online, offline and unauthorized after activation', () => {
|
||||
const retry = jest.fn();
|
||||
status.activate(instance, retry);
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.instance()).toEqual(instance);
|
||||
|
||||
status.reportOffline();
|
||||
expect(status.state()).toBe('offline');
|
||||
status.retry();
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
status.reportSuccess();
|
||||
expect(status.state()).toBe('online');
|
||||
expect(status.lastSuccessAt()).toBeInstanceOf(Date);
|
||||
|
||||
status.reportUnauthorized();
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type ServerConnectionState =
|
||||
'inactive' | 'online' | 'offline' | 'unauthorized';
|
||||
|
||||
/**
|
||||
* Connection state of the self-hosted server storage. `inactive` in Electron
|
||||
* and in a PWA without server storage; the server session activates it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerStorageStatusService {
|
||||
private readonly stateSignal = signal<ServerConnectionState>('inactive');
|
||||
private readonly instanceSignal = signal<ServerInstanceInfo | null>(null);
|
||||
private readonly lastSuccessSignal = signal<Date | null>(null);
|
||||
private retryHandler: (() => void) | null = null;
|
||||
|
||||
readonly state = this.stateSignal.asReadonly();
|
||||
readonly instance = this.instanceSignal.asReadonly();
|
||||
readonly lastSuccessAt = this.lastSuccessSignal.asReadonly();
|
||||
readonly isActive = computed(() => this.stateSignal() !== 'inactive');
|
||||
|
||||
activate(instance: ServerInstanceInfo, retry: () => void): void {
|
||||
this.instanceSignal.set(instance);
|
||||
this.retryHandler = retry;
|
||||
this.stateSignal.set('online');
|
||||
}
|
||||
|
||||
reportSuccess(): void {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
this.stateSignal.set('online');
|
||||
this.lastSuccessSignal.set(new Date());
|
||||
}
|
||||
|
||||
reportOffline(): void {
|
||||
if (this.isActive()) {
|
||||
this.stateSignal.set('offline');
|
||||
}
|
||||
}
|
||||
|
||||
reportUnauthorized(): void {
|
||||
if (this.isActive()) {
|
||||
this.stateSignal.set('unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.retryHandler?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user