updated demo to use the new login redirect

This commit is contained in:
2026-06-11 20:59:02 +02:00
parent b6e9ddbb0e
commit 09c75c3f6b
8 changed files with 224 additions and 286 deletions

View File

@@ -57,7 +57,7 @@ Ein PHX-Frontend-Plugin ist eine Angular-Anwendung, die als **Custom Element** (
- Custom-Element-Tag: `frontend-plugin-demo`
- Beispiel-Routen: Hello World, Product View, Address List
- GraphQL-Abfragen über den PHX-Host-Apollo (Production) oder einen lokalen Apollo-Client (Development)
- Login-Flow für die eigenständige Entwicklung ohne vorkonfigurierten API-Key
- PHX-Login-Weiterleitung und Callback-Route für die eigenständige Entwicklung ohne vorkonfigurierten API-Key
- PrimeNG- und Tailwind-Styling im PHX-Stil
---
@@ -71,7 +71,7 @@ Ein PHX-Frontend-Plugin ist eine Angular-Anwendung, die als **Custom Element** (
Für die lokale eigenständige Entwicklung benötigen Sie außerdem eines der folgenden:
- Einen PHX-API-Benutzer-Token in Ihrer Development-Umgebung, oder
- Gültige Anmeldedaten für den integrierten Login-Bildschirm (siehe [Apollo, Auth Guard und Login](#apollo-auth-guard-und-login))
- Zugriff auf die PHX-Login-Seite (siehe [Apollo, Auth Guard und Login](#apollo-auth-guard-und-login))
---
@@ -117,7 +117,7 @@ Das Manifest verweist auf die kompilierte `main.js` im Verzeichnis `latest/` die
### Standalone client (üblicher Angular-Workflow)
Startet die App mit der **Development**-Build-Konfiguration auf Port **4201** — Routing, Login und ein lokaler Apollo-Client funktionieren ohne PHX.
Startet die App mit der **Development**-Build-Konfiguration auf Port **4201** — Routing, PHX-Login-Weiterleitung und ein lokaler Apollo-Client funktionieren ohne Einbettung des Plugins in PHX.
```bash
yarn client
@@ -175,7 +175,7 @@ phx-frontend-plugin-demo/
├── src/
│ ├── app/
│ │ ├── components/ # Demo pages (hello-world, product-view, …)
│ │ ├── login/ # Login form (standalone development)
│ │ ├── login.ts # Auth-Callback-Route (Standalone-Entwicklung)
│ │ ├── services/
│ │ │ ├── apollo.service.ts
│ │ │ └── phoenix-host-bridge.service.ts
@@ -342,8 +342,8 @@ export const environment: Environment = {
production: false,
apiUrl: 'http://localhost:3000/admin-api',
wsUrl: 'ws://localhost:3000/admin-api',
apiKey: undefined, // or a PHX API user token; otherwise use the login route
serverUrl: 'https://localhost:4200',
apiKey: undefined, // or a PHX API user token; otherwise redirects to PHX login
serverUrl: 'https://localhost:4200', // PHX instance used for standalone login redirect
};
```
@@ -607,7 +607,7 @@ Eigener Port: `PORT=8080 yarn serve`.
Im **Production**-Modus (eingebettet in PHX) übernimmt der Host die Authentifizierung. Nutzen Sie den Apollo Client aus `IPluginServices` über `PhoenixHostBridgeService` — ein separater Login-Flow ist nicht nötig.
Im **Development**-Modus (Standalone) stellen Sie einen eigenen Apollo Client und optional einen Login bereit.
Im **Development**-Modus (Standalone) stellen Sie einen eigenen Apollo Client bereit und leiten nicht authentifizierte Nutzer zur PHX-Login-Seite weiter. PHX leitet danach zurück auf eine lokale `/login`-Callback-Route, die den Token speichert und den Nutzer zur ursprünglichen Seite zurückbringt.
#### Apollo provider
@@ -648,54 +648,53 @@ export class ApolloService {
Wenden Sie dasselbe Muster auf andere Host-Services an (z. B. Notifications), wenn Sie Standalone-Fallbacks benötigen.
#### Login component
#### Login-Callback-Route
Für die Entwicklung ohne voreingestellten API-Key fügen Sie eine `/login`-Route hinzu, die die PHX-`login`-Mutation aufruft:
Für die Entwicklung ohne voreingestellten API-Key fügen Sie eine `/login`-Route hinzu, die als **Auth-Callback** dient — nicht als Login-Formular. Ist der Nutzer nicht authentifiziert, leitet der Auth Guard zur PHX-Login-Seite (`environment.serverUrl`) weiter. Nach erfolgreichem Login leitet PHX zurück auf die `/login`-Route Ihres Plugins mit einem `authToken`-Query-Parameter und einem base64-kodierten `redirectTo`-Ziel.
```gql
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
... on CurrentUser {
id
identifier
channels {
id
token
}
}
... on InvalidCredentialsError {
errorCode
message
}
... on NativeAuthStrategyError {
errorCode
message
}
... on EmailCodeAuthStrategyError {
errorCode
message
Die Callback-Komponente speichert den Token in `localStorage` und navigiert zurück zur ursprünglichen Seite:
```ts
@Component({
selector: 'app-login',
template: `
<div class="flex flex-col items-center justify-center h-screen w-screen opacity-20">
<div class="animate-fadein duration-1000 animate-infinite animate-alternate text-4xl">Signing in...</div>
</div>
`,
})
export class Login {
constructor(private readonly route: ActivatedRoute) {
this.route.queryParams.subscribe((params) => {
const token = params['authToken'];
if (token) {
const redirectTo = decodeURIComponent(atob(params['redirectTo']));
localStorage.setItem('api-key', token);
window.history.replaceState({}, '', redirectTo);
window.location.href = redirectTo;
}
});
}
}
```
Eine vollständige Formular-Implementierung finden Sie unter `src/app/login/` in diesem Repo.
Die vollständige Implementierung finden Sie unter `src/app/login.ts` in diesem Repo.
#### Auth guard
Nicht authentifizierte Nutzer nur im Development-Modus zum Login weiterleiten:
Nicht authentifizierte Nutzer nur im Development-Modus zur PHX-Login-Seite weiterleiten. Der `redirectTo`-Query-Parameter verknüpft die Weiterleitung über die `/login`-Callback-Route Ihres Plugins:
```ts
@Injectable()
export class AuthGuard implements CanActivate {
private readonly router = inject(Router);
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey ?? localStorage.getItem('api-key');
if (!environment.production && !token) {
this.router.navigate(['login'], {
queryParams: { redirectTo: btoa(window.location.pathname + window.location.search) },
});
const redirectTo = encodeURIComponent(
btoa(`${window.location.protocol}//${window.location.host}/login?redirectTo=${encodeURIComponent(btoa(window.location.href))}`)
);
window.location.href = `${environment.serverUrl}/login?redirectTo=${redirectTo}`;
return false;
}
return true;
}
@@ -748,7 +747,7 @@ Dieses Repository führt zusätzlich `copy-latest.mjs` parallel zu `build` aus,
| Symptom | Zu prüfen |
|---------|-----------|
| PHX zeigt eine alte Plugin-Version | Hard-Refresh; prüfen, ob `serve.json` `Cache-Control: no-store` für JS setzt; `yarn run plugin` neu starten |
| `401` / GraphQL-Auth-Fehler im Standalone-Modus | `apiKey` in `environment.development.ts` setzen oder über `/login` anmelden |
| `401` / GraphQL-Auth-Fehler im Standalone-Modus | `apiKey` in `environment.development.ts` setzen, oder sicherstellen, dass `serverUrl` auf Ihre PHX-Instanz zeigt, damit die Login-Weiterleitung funktioniert |
| `yarn add @phx-erp/shared` schlägt fehl | Netzwerkzugriff auf die öffentliche npm-Registry prüfen; `yarn install` erneut ausführen |
| Routing in PHX funktioniert nicht | Routen-Segmente zu `stripTrailingSegments` in `providePhoenixPluginWithPrimeNG` hinzufügen |
| Tailwind-Klassen fehlen in einer Komponente | `@tailwind`-Direktiven in den `styles` der Komponente ergänzen |

View File

@@ -57,7 +57,7 @@ A PHX frontend plugin is an Angular application packaged as a **custom element**
- Custom element tag: `frontend-plugin-demo`
- Sample routes: hello world, product view, address list
- GraphQL queries via PHX host Apollo (production) or a local Apollo client (development)
- Login flow for standalone development without a pre-configured API key
- PHX login redirect and callback route for standalone development without a pre-configured API key
- PrimeNG + Tailwind styling aligned with PHX
---
@@ -71,7 +71,7 @@ A PHX frontend plugin is an Angular application packaged as a **custom element**
For local standalone development you also need either:
- A PHX API user token in your development environment, or
- Valid credentials for the built-in login screen (see [Apollo, auth guard, and login](#apollo-auth-guard-and-login))
- Access to the PHX login page (see [Apollo, auth guard, and login](#apollo-auth-guard-and-login))
---
@@ -117,7 +117,7 @@ The manifest resolves to the compiled `main.js` in the `latest/` directory of th
### Standalone client (usual Angular workflow)
Runs the app with the **development** build configuration on port **4201** — routing, login, and a local Apollo client work without PHX.
Runs the app with the **development** build configuration on port **4201** — routing, PHX login redirect, and a local Apollo client work without embedding the plugin in PHX.
```bash
yarn client
@@ -175,7 +175,7 @@ phx-frontend-plugin-demo/
├── src/
│ ├── app/
│ │ ├── components/ # Demo pages (hello-world, product-view, …)
│ │ ├── login/ # Login form (standalone development)
│ │ ├── login.ts # Auth callback route (standalone development)
│ │ ├── services/
│ │ │ ├── apollo.service.ts
│ │ │ └── phoenix-host-bridge.service.ts
@@ -342,8 +342,8 @@ export const environment: Environment = {
production: false,
apiUrl: 'http://localhost:3000/admin-api',
wsUrl: 'ws://localhost:3000/admin-api',
apiKey: undefined, // or a PHX API user token; otherwise use the login route
serverUrl: 'https://localhost:4200',
apiKey: undefined, // or a PHX API user token; otherwise redirects to PHX login
serverUrl: 'https://localhost:4200', // PHX instance used for standalone login redirect
};
```
@@ -607,7 +607,7 @@ Use a custom port: `PORT=8080 yarn serve`.
In **production** (embedded in PHX), authentication is handled by the host. Use the Apollo client from `IPluginServices` via `PhoenixHostBridgeService` — you do not need a separate login flow.
In **development** (standalone), provide your own Apollo client and optional login.
In **development** (standalone), provide your own Apollo client and redirect unauthenticated users to the PHX login page. PHX redirects back to a local `/login` callback route that stores the token and returns the user to the original page.
#### Apollo provider
@@ -648,54 +648,53 @@ export class ApolloService {
Apply the same pattern for other host services (e.g. notifications) when you need standalone fallbacks.
#### Login component
#### Login callback route
For development without a preset API key, add a `/login` route that calls the PHX `login` mutation:
For development without a preset API key, add a `/login` route that acts as an **auth callback** — not a login form. When the user is unauthenticated, the auth guard sends them to the PHX login page (`environment.serverUrl`). After successful login, PHX redirects back to your plugin's `/login` route with an `authToken` query parameter and a base64-encoded `redirectTo` target.
```gql
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
... on CurrentUser {
id
identifier
channels {
id
token
}
}
... on InvalidCredentialsError {
errorCode
message
}
... on NativeAuthStrategyError {
errorCode
message
}
... on EmailCodeAuthStrategyError {
errorCode
message
The callback component stores the token in `localStorage` and navigates back to the original page:
```ts
@Component({
selector: 'app-login',
template: `
<div class="flex flex-col items-center justify-center h-screen w-screen opacity-20">
<div class="animate-fadein duration-1000 animate-infinite animate-alternate text-4xl">Signing in...</div>
</div>
`,
})
export class Login {
constructor(private readonly route: ActivatedRoute) {
this.route.queryParams.subscribe((params) => {
const token = params['authToken'];
if (token) {
const redirectTo = decodeURIComponent(atob(params['redirectTo']));
localStorage.setItem('api-key', token);
window.history.replaceState({}, '', redirectTo);
window.location.href = redirectTo;
}
});
}
}
```
See `src/app/login/` in this repo for a complete form implementation.
See `src/app/login.ts` in this repo for the full implementation.
#### Auth guard
Redirect unauthenticated users to login in development only:
Redirect unauthenticated users to the PHX login page in development only. The `redirectTo` query parameter chains back through your plugin's `/login` callback:
```ts
@Injectable()
export class AuthGuard implements CanActivate {
private readonly router = inject(Router);
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey ?? localStorage.getItem('api-key');
if (!environment.production && !token) {
this.router.navigate(['login'], {
queryParams: { redirectTo: btoa(window.location.pathname + window.location.search) },
});
const redirectTo = encodeURIComponent(
btoa(`${window.location.protocol}//${window.location.host}/login?redirectTo=${encodeURIComponent(btoa(window.location.href))}`)
);
window.location.href = `${environment.serverUrl}/login?redirectTo=${redirectTo}`;
return false;
}
return true;
}
@@ -748,7 +747,7 @@ This repository additionally runs `copy-latest.mjs` alongside `build` to publish
| Symptom | Things to check |
|---------|-----------------|
| PHX shows an old version of the plugin | Hard-refresh; confirm `serve.json` sets `Cache-Control: no-store` for JS; restart `yarn run plugin` |
| `401` / GraphQL auth errors in standalone mode | Set `apiKey` in `environment.development.ts` or log in via `/login` |
| `401` / GraphQL auth errors in standalone mode | Set `apiKey` in `environment.development.ts`, or ensure `serverUrl` points to your PHX instance so the login redirect works |
| `yarn add @phx-erp/shared` fails | Check network access to the public npm registry; retry `yarn install` |
| Routing broken inside PHX | Add route segments to `stripTrailingSegments` in `providePhoenixPluginWithPrimeNG` |
| Tailwind classes missing in a component | Add `@tailwind` directives to that component's `styles` |

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router';
import { HelloWorld } from './components/hello-world/hello-world';
import { ProductView } from './components/product-view/product-view';
import { Login } from './login/login';
import { Login } from './login';
import { AuthGuard } from './auth-guard';
import { environment } from '../environments/environment';
import { AddressList } from './components/address-list/address-list';

View File

@@ -1,15 +1,16 @@
import { inject, Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
import { environment } from '../environments/environment';
@Injectable()
export class AuthGuard implements CanActivate {
private readonly router = inject(Router);
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey??localStorage.getItem('api-key');
if (!environment.production && !token)
this.router.navigate(['login'], { queryParams: { redirectTo: btoa(window.location.pathname + window.location.search) } });
if (!environment.production && !token){
const redirectTo = encodeURIComponent(btoa(`${window.location.protocol}//${window.location.host}/login?redirectTo=${encodeURIComponent(btoa(window.location.href))}`));
window.location.href = `${environment.serverUrl}/login?redirectTo=${redirectTo}`;
return false;
}
return true;
}
}

33
src/app/login.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-login',
imports: [],
template: `
<div class="flex flex-col items-center justify-center h-screen w-screen opacity-20">
<div class="animate-fadein duration-1000 animate-infinite animate-alternate text-4xl">Signing in...</div>
</div>
`,
styles: [`
@tailwind base;
@tailwind components;
@tailwind utilities;
`],
})
export class Login {
constructor(
private readonly route: ActivatedRoute
) {
this.route.queryParams.subscribe((params) => {
const token = params['authToken'];
if(token){
const redirectTo = decodeURIComponent(atob(params['redirectTo']));
localStorage.setItem('api-key', token);
//remove the authToken from the url but keep the redirectTo
window.history.replaceState({}, '', redirectTo);
window.location.href = redirectTo;
}
});
}
}

View File

@@ -1,17 +0,0 @@
<div class="min-h-screen flex items-center justify-center p-8">
<div class="border-2 rounded-2xl bg-gray-100/50 drop-shadow-2xl w-full mx-auto p-4" style="max-width: 300px;">
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="flex flex-col gap-2">
<div class="flex flex-col">
<label for="username">Username</label>
<input type="text" pInputText id="username" formControlName="username" />
</div>
<div class="flex flex-col">
<label for="password">Password</label>
<input type="password" pInputText id="password" formControlName="password" />
</div>
</div>
<p-button type="submit" [loading]="loading()" label="Login" icon="fa fa-sign-in" class="w-full" styleClass="w-full mt-4" />
</form>
</div>
</div>

View File

@@ -1,77 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, FormsModule, Validators } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import { InputText } from 'primeng/inputtext';
import { Button } from 'primeng/button';
import { ApolloService } from '../services/apollo.service';
import { LOGIN } from '../../graphql/base-definitions';
import { LoginMutation } from '../generated';
import { lastValueFrom } from 'rxjs';
import { AuthenticationResult } from '../schema-types';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-login',
imports: [
FormsModule,
ReactiveFormsModule,
InputText,
Button,
],
templateUrl: './login.html',
styles: [`
@tailwind base;
@tailwind components;
@tailwind utilities;
input[pInputText] {
padding: 0.5rem !important;
border-width: 2px !important;
}
`],
})
export class Login {
readonly apollo = inject(ApolloService);
readonly fb = inject(FormBuilder);
readonly router = inject(Router);
readonly loginForm = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
redirectTo = '/';
readonly loading = signal(false);
constructor(private readonly route: ActivatedRoute) {
this.route.queryParams.subscribe(params => {
if(params['redirectTo']) {
this.redirectTo = atob(params['redirectTo']);
console.log('redirectTo', this.redirectTo);
} else {
this.redirectTo = '/';
}
});
}
async onSubmit() {
this.loading.set(true);
try {
const result = await lastValueFrom(this.apollo.apollo().mutate<LoginMutation>({
mutation: LOGIN,
variables: {
username: this.loginForm.value.username,
password: this.loginForm.value.password,
},
}));
const authResult = result.data?.login as AuthenticationResult;
if(authResult.__typename === 'CurrentUser')
this.router.navigate([this.redirectTo]);
} catch (error) {
console.error(error);
} finally {
this.loading.set(false);
}
}
}