feat(services): server storage connection state and access token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent b88a1c81e5
commit 872d8edc68
5 files changed
+164

No files matched your search

+2
View File
@@ -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?.();
}
}