mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(pwa): server sign-in page and migration dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user