diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 6b73236e3..25fca28bd 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/server-access-token.spec.ts b/libs/services/src/lib/server-access-token.spec.ts new file mode 100644 index 000000000..9c78efc31 --- /dev/null +++ b/libs/services/src/lib/server-access-token.spec.ts @@ -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(); + }); +}); diff --git a/libs/services/src/lib/server-access-token.ts b/libs/services/src/lib/server-access-token.ts new file mode 100644 index 000000000..21f536443 --- /dev/null +++ b/libs/services/src/lib/server-access-token.ts @@ -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 { + const token = readServerAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/libs/services/src/lib/server-storage-status.service.spec.ts b/libs/services/src/lib/server-storage-status.service.spec.ts new file mode 100644 index 000000000..a3aafa95c --- /dev/null +++ b/libs/services/src/lib/server-storage-status.service.spec.ts @@ -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'); + }); +}); diff --git a/libs/services/src/lib/server-storage-status.service.ts b/libs/services/src/lib/server-storage-status.service.ts new file mode 100644 index 000000000..bac7982c5 --- /dev/null +++ b/libs/services/src/lib/server-storage-status.service.ts @@ -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('inactive'); + private readonly instanceSignal = signal(null); + private readonly lastSuccessSignal = signal(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?.(); + } +}