feat(pwa): server sign-in page and migration dialog

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 c5477c5f64
commit cec1ad83a2
8 files changed
+538

No files matched your search

@@ -0,0 +1,69 @@
<h2 mat-dialog-title>
{{
'SERVER_STORAGE.MIGRATION_TITLE'
| translate: { count: playlists.length }
}}
</h2>
<mat-dialog-content class="server-migration">
@if (result(); as done) {
<p data-test-id="server-migration-result">
{{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }}
</p>
} @else {
<p>{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}</p>
<ul class="server-migration__list">
@for (playlist of playlists; track playlist.id) {
<li>
<mat-icon aria-hidden="true">{{
icons[playlist.kind]
}}</mat-icon>
<span>{{ playlist.title }}</span>
</li>
}
</ul>
<p class="server-migration__hint">
{{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }}
</p>
@if (failed()) {
<p class="server-migration__error" role="status">
{{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
</p>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (result()) {
<button
mat-flat-button
type="button"
(click)="reload()"
data-test-id="server-migration-reload"
>
{{ 'SERVER_STORAGE.MIGRATION_RELOAD' | translate }}
</button>
} @else {
<button
mat-button
type="button"
[disabled]="busy()"
(click)="notNow()"
data-test-id="server-migration-not-now"
>
{{ 'SERVER_STORAGE.MIGRATION_NOT_NOW' | translate }}
</button>
<button
mat-flat-button
type="button"
[disabled]="busy()"
(click)="move()"
data-test-id="server-migration-move"
>
@if (busy()) {
<mat-progress-spinner diameter="16" mode="indeterminate" />
}
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
</button>
}
</mat-dialog-actions>
@@ -0,0 +1,46 @@
.server-migration {
p {
margin: 0 0 12px;
color: var(--app-body-color);
}
}
.server-migration__list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
li {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--app-card-hover-bg);
color: var(--app-heading-color);
mat-icon {
flex-shrink: 0;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.server-migration .server-migration__hint {
color: var(--app-muted-color);
font-size: 0.8rem;
}
.server-migration .server-migration__error {
color: var(--app-expiry-expired-text);
}
@@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ServerMigrationService } from '@iptvnator/services/server-storage';
import { ServerMigrationDialogComponent } from './server-migration-dialog.component';
describe('ServerMigrationDialogComponent', () => {
let fixture: ComponentFixture<ServerMigrationDialogComponent>;
const migration = { skip: jest.fn(), moveBrowserData: jest.fn() };
const dialogRef = { close: jest.fn() };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ServerMigrationDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: [{ id: 'm', title: 'Family', kind: 'm3u' }],
},
{ provide: ServerMigrationService, useValue: migration },
{ provide: MatDialogRef, useValue: dialogRef },
],
}).compileComponents();
fixture = TestBed.createComponent(ServerMigrationDialogComponent);
fixture.detectChanges();
});
afterEach(() => jest.resetAllMocks());
const byTestId = (id: string) =>
fixture.nativeElement.querySelector(`[data-test-id="${id}"]`);
it('lists the browser playlists', () => {
expect(fixture.nativeElement.textContent).toContain('Family');
});
it('records "not now" and closes', () => {
byTestId('server-migration-not-now').click();
expect(migration.skip).toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalled();
});
it('moves the data and offers a reload', async () => {
const result = { imported: 1, merged: 0, failed: 0 };
migration.moveBrowserData.mockResolvedValue(result);
await fixture.componentInstance.move();
fixture.detectChanges();
expect(fixture.componentInstance.result()).toEqual(result);
expect(byTestId('server-migration-result')).not.toBeNull();
expect(byTestId('server-migration-reload')).not.toBeNull();
expect(byTestId('server-migration-move')).toBeNull();
});
it('keeps the dialog open with a message when moving fails', async () => {
migration.moveBrowserData.mockRejectedValue(new Error('offline'));
await fixture.componentInstance.move();
fixture.detectChanges();
expect(fixture.componentInstance.failed()).toBe(true);
expect(fixture.nativeElement.textContent).toContain(
'SERVER_STORAGE.MIGRATION_FAILED'
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import {
ServerMigrationService,
type ServerMigrationPlaylist,
type ServerMigrationResult,
} from '@iptvnator/services/server-storage';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-server-migration-dialog',
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
TranslatePipe,
],
templateUrl: './server-migration-dialog.component.html',
styleUrl: './server-migration-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServerMigrationDialogComponent {
private readonly migration = inject(ServerMigrationService);
private readonly dialogRef = inject(
MatDialogRef<ServerMigrationDialogComponent>
);
readonly playlists =
inject<readonly ServerMigrationPlaylist[]>(MAT_DIALOG_DATA);
readonly icons = SOURCE_TYPE_ICONS;
readonly busy = signal(false);
readonly failed = signal(false);
readonly result = signal<ServerMigrationResult | null>(null);
notNow(): void {
this.migration.skip();
this.dialogRef.close();
}
async move(): Promise<void> {
this.busy.set(true);
this.failed.set(false);
try {
this.result.set(await this.migration.moveBrowserData());
} catch {
this.failed.set(true);
} finally {
this.busy.set(false);
}
}
/** The library was replaced underneath every open view. */
reload(): void {
window.location.reload();
}
}
@@ -0,0 +1,62 @@
<main class="server-sign-in">
<form class="server-sign-in__card" (submit)="submit($event)">
<img
class="server-sign-in__logo"
src="./assets/icons/icon-tv-256.png"
alt=""
width="40"
height="40"
/>
<h1>{{ 'SERVER_STORAGE.SIGN_IN_TITLE' | translate }}</h1>
<p>{{ 'SERVER_STORAGE.SIGN_IN_DESCRIPTION' | translate }}</p>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SERVER_STORAGE.SIGN_IN_TOKEN_LABEL' | translate
}}</mat-label>
<input
matInput
id="server-access-token"
autocomplete="current-password"
[type]="tokenToggle.inputType()"
[value]="token()"
(input)="edit($any($event.target).value)"
data-test-id="server-sign-in-token"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#tokenToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ tokenToggle.icon() }}</mat-icon>
</button>
<mat-hint>{{ 'SERVER_STORAGE.SIGN_IN_HINT' | translate }}</mat-hint>
</mat-form-field>
<p
class="server-sign-in__feedback"
role="status"
data-test-id="server-sign-in-feedback"
>
@switch (feedback()) {
@case ('rejected') {
{{ 'SERVER_STORAGE.SIGN_IN_REJECTED' | translate }}
}
@case ('unreachable') {
{{ 'SERVER_STORAGE.SIGN_IN_UNREACHABLE' | translate }}
}
}
</p>
<button
mat-flat-button
type="submit"
[disabled]="busy() || !token().trim()"
data-test-id="server-sign-in-submit"
>
{{ 'SERVER_STORAGE.SIGN_IN_CONTINUE' | translate }}
</button>
</form>
</main>
@@ -0,0 +1,56 @@
:host {
display: block;
min-height: 100%;
background: var(--app-shell-bg);
}
.server-sign-in {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
box-sizing: border-box;
}
.server-sign-in__card {
width: min(100%, 420px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 28px;
box-sizing: border-box;
border-radius: 20px;
background: var(--app-widget-bg);
box-shadow: var(--app-widget-shadow);
color: var(--app-on-surface);
h1 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
color: var(--app-heading-color);
}
p {
margin: 0;
color: var(--app-body-color);
font-size: 0.9rem;
line-height: 1.45;
}
button[type='submit'] {
align-self: flex-end;
}
}
.server-sign-in__logo {
border-radius: 12px;
}
.server-sign-in .server-sign-in__feedback {
color: var(--app-expiry-expired-text);
&:empty {
display: none;
}
}
@@ -0,0 +1,85 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
ServerApiError,
ServerStorageSession,
} from '@iptvnator/services/server-storage';
import { ServerSignInComponent } from './server-sign-in.component';
const TOKEN_KEY = 'iptvnator-server:access-token';
describe('ServerSignInComponent', () => {
let fixture: ComponentFixture<ServerSignInComponent>;
let component: ServerSignInComponent;
const session = { fetchStatus: jest.fn() };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ServerSignInComponent, TranslateModule.forRoot()],
providers: [{ provide: ServerStorageSession, useValue: session }],
}).compileComponents();
fixture = TestBed.createComponent(ServerSignInComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
localStorage.clear();
jest.resetAllMocks();
});
const feedbackText = () =>
fixture.nativeElement
.querySelector('[data-test-id="server-sign-in-feedback"]')
.textContent.trim();
it('keeps an accepted token and reloads into the workspace', async () => {
session.fetchStatus.mockResolvedValue({ playlists: 0 });
const open = jest
.spyOn(component, 'openWorkspace')
.mockImplementation(() => undefined);
component.edit(' right ');
await component.submit(new Event('submit'));
expect(localStorage.getItem(TOKEN_KEY)).toBe('right');
expect(open).toHaveBeenCalled();
});
it('says the token was refused and forgets it', async () => {
session.fetchStatus.mockRejectedValue(
new ServerApiError(401, 'unauthorized', 'refused')
);
component.edit('wrong');
await component.submit(new Event('submit'));
fixture.detectChanges();
expect(component.feedback()).toBe('rejected');
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_REJECTED');
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
});
it('tells an unreachable server apart and clears the message on edit', async () => {
session.fetchStatus.mockRejectedValue(
new ServerApiError(0, undefined, 'offline')
);
component.edit('token');
await component.submit(new Event('submit'));
fixture.detectChanges();
expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE');
component.edit('token2');
fixture.detectChanges();
expect(feedbackText()).toBe('');
});
it('does not submit an empty token', async () => {
component.edit(' ');
await component.submit(new Event('submit'));
expect(session.fetchStatus).not.toHaveBeenCalled();
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
});
});
@@ -0,0 +1,78 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import {
clearServerAccessToken,
writeServerAccessToken,
} from '@iptvnator/services';
import {
ServerApiError,
ServerStorageSession,
} from '@iptvnator/services/server-storage';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
export type ServerSignInFeedback = 'rejected' | 'unreachable';
@Component({
selector: 'app-server-sign-in',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
PasswordVisibilityToggleDirective,
TranslatePipe,
],
templateUrl: './server-sign-in.component.html',
styleUrl: './server-sign-in.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServerSignInComponent {
private readonly session = inject(ServerStorageSession);
readonly token = signal('');
readonly busy = signal(false);
readonly feedback = signal<ServerSignInFeedback | null>(null);
edit(value: string): void {
this.token.set(value);
this.feedback.set(null);
}
async submit(event: Event): Promise<void> {
event.preventDefault();
const token = this.token().trim();
if (!token || this.busy()) {
return;
}
this.busy.set(true);
this.feedback.set(null);
writeServerAccessToken(token);
try {
await this.session.fetchStatus();
this.openWorkspace();
} catch (error) {
clearServerAccessToken();
this.feedback.set(
error instanceof ServerApiError && error.status === 401
? 'rejected'
: 'unreachable'
);
} finally {
this.busy.set(false);
}
}
/** Full reload so every service starts with the token. */
openWorkspace(): void {
window.location.assign(new URL('workspace', document.baseURI).href);
}
}