refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal variants with different login flows. Actor #2 (apps/behandelportal) landed in WP-61/67 and the union never followed: grep -rn "Principal" returned one hit, a comment. Both apps' auth/domain/session.ts stayed byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar carried a BSN and logged into the backoffice as a citizen, by DigiD, under a fabricated citizen's name (login.page.ts). The divergence ADR-0002 predicted took an orthogonal side door instead (medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never touches SessionStore) -- which is why ssp/auth and bhp/auth still measured as 100%/84% duplicated after ADR-C-006 shared the route guards. RB-09 (landed the day before) made the backend's IIdentityProvider able to say "no identity" and fail closed; this ticket is its named FE half. Each app's auth/domain/session.ts becomes principal.ts, holding the one Principal variant that app actually has an actor for: ssp keeps `{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId, naam, rollen }` (no BSN to strip -- G2 shape validation only). A new MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving the existing MEDEWERKER_ID/currentRollen() dev stand-in into a Principal; because there is no credential to check, it returns Principal directly rather than a Result whose error variant could never occur. login.page.ts stops being a BSN/wachtwoord form -- one explainer line and an "Inloggen met SSO" button -- and its dead error-handling branch goes with the Result wrapper that justified it. Measured with tools/baseline-scan.mjs --dup: auth duplication drops from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the backlog's <40 target. What remains is the ADR-C-006 route-guard re-export (deliberately identical), generic test/story-file boilerplate, and one shared fragment of the root-singleton-store idiom -- not re-converged identity or login-flow logic. SS3's prediction that the two actors would authenticate differently enough to justify not sharing auth has now actually been tested, not just asserted, and held. Also: renamed Session.bsn to Principal.bsn in two doc comments (libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts) that cited the old type name; regenerated libs/shared/docs/behaviour-spec.mdx (generated file, per its own banner); recorded the resolution in ADR-0002 as a new amendment, replacing its "Known debt" section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session, parseStoredSession } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredSession`
|
||||
(`../domain/session`) — pure, spec'd, and testable without stubbing
|
||||
`localStorage`; this just supplies the raw value. */
|
||||
function restore(): Session | null {
|
||||
return parseStoredSession(localStorage.getItem(STORAGE_KEY));
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||
without stubbing `localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current medewerker principal for the whole backoffice app. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||
* comment explains why *that* app strips a field before writing. A real
|
||||
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private medewerker = inject(MedewerkerAdapter);
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
const p = this._session();
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||
resulting principal. No credential to pass in, and nothing that can fail
|
||||
today — see `MedewerkerAdapter`. */
|
||||
async login(): Promise<Principal> {
|
||||
const p = await this.medewerker.authenticate();
|
||||
this._session.set(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
logout() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
|
||||
|
||||
const principal: Principal = {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['behandelaar'],
|
||||
};
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present principal to Principal', () => {
|
||||
expect(isAuthenticated(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no principal as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredPrincipal', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredPrincipal(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when kind is not medewerker', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'zorgverlener',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: [],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when rollen holds an unrecognized token', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['geen'],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
|
||||
const restored = parseStoredPrincipal(JSON.stringify(principal));
|
||||
expect(restored).toEqual(principal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRollen', () => {
|
||||
it('parses a single recognized rol', () => {
|
||||
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims whitespace', () => {
|
||||
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
|
||||
});
|
||||
|
||||
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
|
||||
expect(parseRollen('geen')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty list for an empty string', () => {
|
||||
expect(parseRollen('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Who is logged in. Framework-free domain type.
|
||||
*
|
||||
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
|
||||
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
|
||||
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
|
||||
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
|
||||
* unrepresentable-by-construction distinction as the whole point of the union.
|
||||
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
|
||||
* already stamps onto every backend request — it does not itself grant anything;
|
||||
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
|
||||
* on what this principal may do (ADR-0001, ADR-0002 §3).
|
||||
*/
|
||||
export type Rol = 'behandelaar';
|
||||
|
||||
const ROLLEN: readonly Rol[] = ['behandelaar'];
|
||||
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
|
||||
|
||||
export interface Principal {
|
||||
readonly kind: 'medewerker';
|
||||
readonly medewerkerId: string;
|
||||
readonly naam: string;
|
||||
readonly rollen: readonly Rol[];
|
||||
}
|
||||
|
||||
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||
return p !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
|
||||
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
|
||||
* comma-separated, case-insensitive, unrecognized tokens dropped — so
|
||||
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
|
||||
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
|
||||
* stay a thin wire-up instead of holding logic of its own.
|
||||
*/
|
||||
export function parseRollen(raw: string): Rol[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(isRol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the shape
|
||||
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
|
||||
* — a medewerker carries no national identifier — so a well-shaped record is
|
||||
* restored as-is rather than reconstructed field-by-field.
|
||||
*/
|
||||
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||
return parsed?.kind === 'medewerker' &&
|
||||
typeof parsed.medewerkerId === 'string' &&
|
||||
typeof parsed.naam === 'string' &&
|
||||
Array.isArray(parsed.rollen) &&
|
||||
parsed.rollen.every(isRol)
|
||||
? {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: parsed.medewerkerId,
|
||||
naam: parsed.naam,
|
||||
rollen: parsed.rollen,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseStoredSession, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredSession', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredSession(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredSession('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||
const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||
expect(restored).toEqual({ bsn: '', naam: 'Test' });
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted session out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||
* restored session's `bsn` is always `''` — the BSN is never persisted (see
|
||||
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||
* cannot resurrect one.
|
||||
*/
|
||||
export function parseStoredSession(raw: string | null): Session | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Principal, parseRollen } from '../domain/principal';
|
||||
import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
||||
|
||||
/**
|
||||
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
||||
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
|
||||
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
|
||||
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
|
||||
* `Principal` directly rather than a `Result` with an error variant that can never
|
||||
* actually occur. A real SSO callback (which *can* fail — session expired, access
|
||||
* denied) swaps in behind this same method; that is the point where this return
|
||||
* type would gain a `Result`, not before.
|
||||
*
|
||||
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
|
||||
* `medewerkerInterceptor` already stamps onto every backend request as
|
||||
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
|
||||
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
|
||||
* not change what the backend resolves or authorizes.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MedewerkerAdapter {
|
||||
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
|
||||
async authenticate(): Promise<Principal> {
|
||||
return {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: MEDEWERKER_ID,
|
||||
naam: 'H. (Hassan) Bakker',
|
||||
rollen: parseRollen(currentRollen()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,27 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
/**
|
||||
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and,
|
||||
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
||||
* BSN, and this app has no password of its own to check either way. There is
|
||||
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
||||
* two apps' login flows are meant to look this different.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
<div class="form-horizontal">
|
||||
<p i18n="@@login.ssoExplainer">
|
||||
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||
</p>
|
||||
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||
Inloggen met SSO
|
||||
</app-button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
submitted = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
/**
|
||||
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
|
||||
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
|
||||
* this page would grow one back.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
imports: [PageShellComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
heading="Inloggen bij het behandelportal"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
intro="Voor medewerkers die aanvragen beoordelen."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
<app-login-form (submitted)="login()" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
async login() {
|
||||
await this.store.login();
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user